Datastores

Datastores are a Slack-hosted way to store data for your next-generation Slack apps. They are available for next-generation Slack apps only.

Datastores are backed by DynamoDB, a secure and performant NoSQL database. DynamoDB's data model uses three basic types of data units: tables, items, and attributes. Tables are collections of items, and items are collections of attributes. You will see how a collection of attributes comprises an item when we define a datastore later in this page.

Initialize a datastore

To initialize a datastore:

  • Define the datastore
  • Add it to your manifest

Defining a datastore

To keep your app tidy, datastores can be defined in their own source files just like custom functions.

If you don't already have one, create a datastores directory in the root of your project, and inside, create a source file to define your datastore.

Throughout this page, we'll use the example of the Announcement bot sample app. First, we'll create a datastore called Drafts and define it in a file named drafts.ts. It will hold information about an announcement the user drafts to send to a channel:

// /datastores/drafts.ts
import { DefineDatastore, Schema } from "deno-slack-sdk/mod.ts";

export default DefineDatastore({
  name: "drafts",
  primary_key: "id",
  attributes: {
    id: {
      type: Schema.types.string,
    },
    created_by: {
      type: Schema.slack.types.user_id,
    },
    message: {
      type: Schema.types.string,
    },
    channels: {
      type: Schema.types.array,
      items: {
        type: Schema.slack.types.channel_id,
      },
    },
    channel: {
      type: Schema.slack.types.channel_id,
    },
    message_ts: {
      type: Schema.types.string,
    },
    icon: {
      type: Schema.types.string,
    },
    username: {
      type: Schema.types.string,
    },
    status: {
      type: Schema.types.string, // possible statuses are draft, sent
    },
  },
});

Datastores can contain three primary properties. The primary_key property is the only one that is required. When using additional optional properties, make sure to handle them properly to avoid running into any TypeScript errors in your code.

Property Type Description Required
name String A string to identify your datastore No
primary_key String The attribute to be used as the datastore's unique key; ensure this is an actual attribute that you have defined Yes
attributes Object (see below) Properties to scaffold your datastore's columns No

Attributes can be custom types, built-in types, and the following basic schema types:

  • array
  • boolean
  • int
  • number
  • object
  • string

No nullable support
If you use a built-in Slack type for an attribute, there is no nullable support. For example, let's say you use channel_id for an attribute and at some point in your app, you'd like to clear out the channel_id for a given item. You cannot do this with a Slack built-in type. Change the data type to be a string if you'd like to support a null or empty value.

Adding the datastore to your app's manifest

The last step in initializing your datastore is to add it to the datastores property in your manifest and include the required datastore bot scopes.

To do that, first add the datastores property to your manifest if it does not exist, then list the datastores you have defined. Second, add the following datastore permission scopes to the botScopes property:

  • datastore:read
  • datastore:write

Here's an example manifest definition for the above drafts datastore in the Announcement bot sample app:

import { Manifest } from "deno-slack-sdk/mod.ts";
// Import the datastore definition
import AnnouncementDatastore from "./datastores/announcements.ts";
import DraftDatastore from "./datastores/drafts.ts";
import { AnnouncementCustomType } from "./functions/post_summary/types.ts";
import CreateAnnouncementWorkflow from "./workflows/create_announcement.ts";

export default Manifest({
  name: "Announcement Bot",
  description: "Send an announcement to one or more channels",
  icon: "assets/icon.png",
  outgoingDomains: ["cdn.skypack.dev"],
  datastores: [DraftDatastore, AnnouncementDatastore], // Add the datastore to this list
  types: [AnnouncementCustomType],
  workflows: [
    CreateAnnouncementWorkflow,
  ],
  botScopes: [
    "commands",
    "chat:write",
    "chat:write.public",
    "chat:write.customize",
    "datastore:read",
    "datastore:write",
  ],
});

Note that we've also added the required datastore:read and datastore:write bot scopes.

Updates to an existing datastore that could result in data loss (removal of an existing datastore or attribute from the app) may require the use of the force flag (--force) when re-deploying the app. See schema_compatibility_error for more information.

Interact with a datastore

There are two ways to interact with your app's datastore.

➡️ To interact with your datastore through the command-line tool, see the datastore commands section on the commands page.

⤵️ The other way to interact with your datastore is with a custom function. Let's do that now.

Interacting with your app's datastore requires hitting the SlackAPI. To do this from within your code, we first need to import a mechanism that will allow us to call the SlackAPI. That mechanism is SlackFunction. First we import it into our function file from the deno-slack-sdk package, then we add a SlackFunction into our code. SlackFunction contains a property, client, which allows us to call the datastore.

Check out the example here or below.

In all interactions with your datastore, double and triple-check the exact spelling of the fields in the datastore definition match your query, lest you should receive an error.

Visualizing the datastore

When interacting with your datastore, it may be helpful to first visualize its structure. In our drafts example, let's say we have stored the following users and their drafted announcements:

id created_by message channels channel message_ts icon username status
906dba92-44f5-4680-ada9-065149e4e930 U045A5X302V This is a test message ["C038M39A2TV"] C039ARY976C 1691513323.119209 Slackbot sent
b8457c38-4401-4dd1-b979-a0e56f7c9a3d BR75C7X4P90 Remember to submit your timesheets ["C038M39A2TV"] C039ARY976C 1691520476.091369 :robot_face: The Boss draft
194a52d8-c75b-4eff-9f8f-4c40292cd9e7 G98I9345NI2 Happy Friday, team! ["D870D2223M23"] D870D2223M23 2172813323.142610 :t-rex: Slackasaurus Bot sent

Beware of SQL injection
Be sure to sanitize any strings received from a user and never use untrusted data in your query expressions.

Creating or replacing an item with put

The apps.datastore.put method is used for both creating and replacing an item in a datastore. To update only some of an item's attributes rather than replacing the whole item, see the apps.datastore.update method.

Let's see how put works in the following examples where we pass in values for each of the datastore's attributes:

// /functions/create_draft/definition.ts
import { DefineFunction, Schema } from "deno-slack-sdk/mod.ts";

export const CREATE_DRAFT_FUNCTION_CALLBACK_ID = "create_draft";
/**
 * This is a custom function manifest definition which
 * creates and sends an announcement draft to a channel.
 *
 * More on defining functions here:
 * https://api.slack.com/automation/functions/custom
 */
export const CreateDraftFunctionDefinition = DefineFunction({
  callback_id: CREATE_DRAFT_FUNCTION_CALLBACK_ID,
  title: "Create a draft announcement",
  description:
    "Creates and sends an announcement draft to channel for review before sending",
  source_file: "functions/create_draft/handler.ts",
  input_parameters: {
    properties: {
      created_by: {
        type: Schema.slack.types.user_id,
        description: "The user that created the announcement draft",
      },
      message: {
        type: Schema.types.string,
        description: "The text content of the announcement",
      },
      channel: {
        type: Schema.slack.types.channel_id,
        description: "The channel where the announcement will be drafted",
      },
      channels: {
        type: Schema.types.array,
        items: {
          type: Schema.slack.types.channel_id,
        },
        description: "The channels where the announcement will be posted",
      },
      icon: {
        type: Schema.types.string,
        description: "Optional custom bot icon to use display in announcements",
      },
      username: {
        type: Schema.types.string,
        description: "Optional custom bot emoji avatar to use in announcements",
      },
    },
    required: [
      "created_by",
      "message",
      "channel",
      "channels",
    ],
  },
  output_parameters: {
    properties: {
      draft_id: {
        type: Schema.types.string,
        description: "Datastore identifier for the draft",
      },
      message: {
        type: Schema.types.string,
        description: "The content of the announcement",
      },
      message_ts: {
        type: Schema.types.string,
        description: "The timestamp of the draft message in the Slack channel",
      },
    },
    required: ["draft_id", "message", "message_ts"],
  },
});

// /functions/create_draft/handler.ts
import { SlackFunction } from "deno-slack-sdk/mod.ts";

import { CreateDraftFunctionDefinition } from "./definition.ts";
import { buildDraftBlocks } from "./blocks.ts";
import {
  confirmAnnouncementForSend,
  openDraftEditView,
  prepareSendAnnouncement,
  saveDraftEditSubmission,
} from "./interactivity_handler.ts";
import { ChatPostMessageParams, DraftStatus } from "./types.ts";

import DraftDatastore from "../../datastores/drafts.ts";

/**
 * This is the handling code for the CreateDraftFunction. It will:
 * 1. Create a new datastore record with the draft
 * 2. Build a Block Kit message with the draft and send it to input channel
 * 3. Update the draft record with the successful sent drafts timestamp
 * 4. Pause function completion until user interaction
 */
export default SlackFunction(
  CreateDraftFunctionDefinition,
  async ({ inputs, client }) => {
    const draftId = crypto.randomUUID();

    // 1. Create a new datastore record with the draft
    const putResp = await client.apps.datastore.put<
      typeof DraftDatastore.definition
    >({
      datastore: DraftDatastore.name,
      item: {
        id: draftId,
        created_by: inputs.created_by,
        message: inputs.message,
        channels: inputs.channels,
        channel: inputs.channel,
        icon: inputs.icon,
        username: inputs.username,
        status: DraftStatus.Draft,
      },
    });

    if (!putResp.ok) {
      const draftSaveErrorMsg =
        `Error saving draft announcement. Contact the app maintainers with the following information - (Error detail: ${putResp.error})`;
      console.log(draftSaveErrorMsg);

      return { error: draftSaveErrorMsg };
    }
    ...

If the call was successful, the payload's ok property will be true, and the item property will return a copy of the data you just inserted:

{
  "ok": true,
  "datastore": "drafts",
  "item": {
    "id": "906dba92-44f5-4680-ada9-065149e4e930",
    "created_by": "U045A5X302V",
    "message": "This is a test message",
    "channels": ["C039ARY976C"],
    "channel": "C038M39A2TV",
    "icon": "",
    "username": "Slackbot",
    "status": "draft",
  }
}

If the call was not successful, the payload's ok property will be false, and you will have a error code and message property available:

{
  "ok": false,
  "error": "datastore_error",
  "errors": [
    {
      "code": "some_error_code",
      "message": "A description of the error",
      "pointer": "/datastore/drafts"
    }
  ]
}

If you're adding new data via the put method, provide an item with a new primary key value in the id property shown here. If you're updating an existing item, provide the id of the item you wish to replace. Note that a put request replaces the entire object, if it exists.

Right-sized items
The total allowable size of an item (all fields in a record) must be less than 400 KB.

Creating or updating an item with update

Updating only some of an item's attributes is done with the apps.datastore.update API method. Let's see how that works by passing in values for only some of the datastore's attributes:

// /functions/create_draft_interactivity_handler.ts
...
export const saveDraftEditSubmission: ViewSubmissionHandler<
  typeof CreateDraftFunction.definition
> = async (
  { inputs, view, client },
) => {
  // Get the datastore draft ID from the modal's private metadata
  const { id, thread_ts } = JSON.parse(view.private_metadata || "");

  const message = view.state.values.message_block.message_input.value;

  // Update the saved message
  const updateResp = await client.apps.datastore.update({
    datastore: DraftDatastore.name,
    item: {
      id: id,
      message: message, // This call will update only the message of the draft announcement
    },
  });

  if (!updateResp.ok) {
    const updateDraftMessageErrorMsg =
      `Error updating draft ${id} message. Contact the app maintainers with the following - (Error detail: ${putResp.error})`;
    console.log(updateDraftMessageErrorMsg);
    return;
  }
  ...

If the call was successful, the payload's ok property will be true, and the item property will return a copy of the updated data:

{
  "ok": true,
  "datastore": "drafts",
  "item": {
    "id": "906dba92-44f5-4680-ada9-065149e4e930",
    "created_by": "U045A5X302V",
    "message": "This is a message that will be sent",
    "channels": ["C039ARY976C"],
    "channel": "C038M39A2TV",
    "icon": "",
    "username": "Slackbot",
    "status": "draft",
  }
}

If the call was not successful, the payload's ok property will be false, and you will have a error code and message property available:

{
  "ok": false,
  "error": "datastore_error",
  "errors": [
    {
      "code": "some_error_code",
      "message": "A description of the error",
      "pointer": "/datastore/drafts"
    }
  ]
}

If an item with the provided id doesn't exist in the datastore, update will insert the item using the provided attributes.

Retrieving a single item with get

Now, let's retrieve an item by its primary key attribute using the apps.datastore.get API Method. For example, consider the following:

// /functions/create_draft/interactivity_handler.ts
...
export const openDraftEditView: BlockActionHandler<
  typeof CreateDraftFunction.definition
> = async ({ body, action, client }) => {
  if (action.selected_option.value == "edit_message_overflow") {
    const id = action.block_id;

    // Get the draft
    const getResp = await client.apps.datastore.get<
      typeof DraftDatastore.definition
    >(
      {
        datastore: DraftDatastore.name,
        id: id,
      },
    );
...

Regardless of what you named your primary_key, the query will always use id.

If the call was successful and data was found, an item property in the payload will include the attributes (and their values) from the datastore definition:

{
  "ok": true,
  "datastore": "drafts",
  "item": {
    "id": "906dba92-44f5-4680-ada9-065149e4e930",
    "created_by": "U045A5X302V",
    "message": "This is a test message",
    "channels": ["C039ARY976C"],
    "channel": "C038M39A2TV",
    "icon": "",
    "username": "Slackbot",
    "status": "draft",
  }
}

If the call was successful but no data was found, the item property in the payload will be blank:

{
  "ok": true,
  "datastore": "drafts",
  "item": {}
}

If the call was unsuccessful, the payload will contain two fields:

{
  "ok": false,
  "error": "(some error string)"
}

It is possible to have records with undefined values, and it's important to be proactive in expecting those situations in your code. Here are some examples of how to code around a potential undefined field while retrieving an item. This example snippet supports the case where the function returns an optional output:

const getResponse = client.apps.datastore.get<typeof DraftsDatastore.definition>({...});
const announcementId = getResponse.item.id; // this is the primary key
const announcementIcon = getResponse.item.icon; // icon could be undefined

return {
  outputs: {
    id: announcementId, // id is always defined
    icon: announcementIcon,  // icon must be an optional output of the function
  }
}

This example snippet supports the case where the function assigns a default:

const getResponse = client.apps.datastore.get<typeof DraftsDatastore.definition>({...});
const announcementId = getResponse.item.id; // this is the primary key

// icon could be undefined, so use a fallback
const announcementIcon = getResponse.item.icon ?? "n/a";

return {
  outputs: {
    id: announcementId, // id is always defined
    icon: announcementIcon,  // email is always defined
  }
}

And finally, this example snippet supports the case where the function should error:

const getResponse = client.apps.datastore.get<typeof DraftsDatastore.definition>({...});
const announcementId = getResponse.item.id; // this is the primary key

if (getResponse.item.icon) {
  const announcementIcon = getResponse.item.icon;
  return {
    outputs: {
      id: announcementId,
      icon: announcementIcon }
  }
} else {
  return {
    error: "Announcement doesn't have an icon assigned"
  }
}

Retrieving multiple items with query

If you need to retrieve more than a single row or find data without already knowing the item's id, you'll want to run a query. Querying a datastore includes knowledge of a few different components. First let's look at the fields of a datastore query, how it might look in code, and then break down the details of each bit.

A Slack datastore query includes the following arguments:

Parameter Description Required
datastore A string with the name of the datastore to read the data from Yes
expression A DynamoDB filter expression, using DynamoDB filter expression syntax No
expression_attributes A map of columns used by the expression No
expression_values A map of values used by the expression No
limit The maximum number of entries to return, 1-1000 (both inclusive); default is 100 No
cursor The string value to access the next page of results No

Here's an example of how to query our drafts datastore and retrieve a list of all the announcements with messages containing "timesheet":

const result = await client.apps.datastore.query({
  datastore: "drafts",
  expression: "contains (#message_term, :message)",
  expression_attributes: { "#message_term": "message" },
  expression_values: { ":message": "timesheet" },
});

If that example looks wonky to you; read on while we explain. Under the hood, the apps.datastore.query API method is a DynamoDB scan, and thereby uses DynamoDB filter expression syntax.

Let's break down that previous query example:

The expression is the search criteria. The expression_attributes object is a map of the columns used for the comparison, and expression_values object is a map of values. The expression_attributes property must always begin with a #, and the expression_values property must always begin with a :.

To break that down further, #message_term seen here is a variable representing the message datastore attribute. So, why not just use message in the expression, such that it would be expression: "message = :message"? We do this to safeguard against anything that might break the search query, like double quotes or spaces in a name, or using DynamoDB's reserved words as attribute names. The second such variable used in the expression is :message. We see that defined in expression_values as the hard-coded value of "timesheet", but it's more likely that you'll use a variable here, perhaps a value obtained from a user interaction.

In summary, this query searches for items in the drafts datastore that have a value of "timesheet" (represented by :message) in their message attribute (represented by #message_term).

Filter expressions

Because datastore query is a DynamoDB scan, all query expressions are essentially filter expressions. It's what you put in the value of the expression argument. Filter expressions are applied post-hoc. This is important context to understand because it can yield some confusing results; i.e. return fewer results than requested yet have additional pages of results to be queried and paginated (see pagination section below). Each query can return a maximum of 1MB of data per page of results, and it returns all results of the datastore before applying any filter conditions. The filter conditions are applied to each page of results individually. This is how you could end up with the first page of zero results, yet still have a cursor for a following page of results.

Here is the full list of comparison operators to use in a filter expression, followed by some examples:

Operator Description Example
= True if both values are equal a = b
< True if the left value is less than but not equal to the right a < b
<= True if the left value is less than or equal to the right a <= b
> True if the left value is greater than but not equal to the right a > b
>= True if the left value is greater than or equal to the right a >= b
BETWEEN ... AND True if one value is greater than or equal to one and less than or equal to another #time_stamp BETWEEN :ts1 AND :ts2
begins_with(str, substr) True if a string begins with substring begins_with("#message_term", ":message")
contains (path, operand) True if attribute specified by path is a string that contains the operand string contains (#song, :inputsong)

Expressions can only contain non-primary key attributes
If you try to write an expression that uses a primary key as its attribute (for example, to pull a single row from a datastore), you will receive a cryptic error. Please use apps.datastore.get instead. We're hard at work on making these types of errors easier to understand!

Revisiting our drafts datastore, here we retrieve all the announcements created by user C123ABC456:

const result = await client.apps.datastore.query({
  datastore: "drafts",
  expression: "#announcement_creator = :user",
  expression_attributes: { "#announcement_creator": "created_by"},
  expression_values: {":user": "C123ABC456"},
});

If you wanted to verify the query before putting it in your app code, the CLI query for that same search would be:

slack datastore query '{
  "datastore": "drafts",
  "expression": "#announcement_creator = :user",
  "expression_attributes": { "#announcement_creator": "created_by"},
  "expression_values": {":user": "C123ABC456"}
}'

Here's an example of a function that receives a string message via an input and queries for the announcement record that matches the provided message:

  const result = await client.apps.datastore.query({
  datastore: "drafts",
  expression: "contains (#message_term, :message)",
  expression_attributes: { "#message_term": "message" },
  expression_values: { ":message": input.message },
});

You could also chain expressions together to narrow your results even further:

  const result = await client.apps.datastore.query({
  datastore: "drafts",
  expression: "contains (#message_term, :message) AND #announcement_creator = :creator",
  expression_attributes: { "#message_term": "message", "#announcement_creator": "created_by" },
  expression_values: { ":message": input.message, ":creator": input.creator },
});

Pagination

For cursor-paginated methods, use the cursor parameter to retrieve the next page of your query results.

If your initial query has another page of results, the next_cursor response parameter is the key returned that will unlock your next page of results. Use this key to query the datastore again and set cursor to the value of next_cursor.

That request might look like:

const result = await client.apps.datastore.query({
  datastore: "drafts",
  cursor: "eyJ...n19"
});

Remember that filters are applied post-hoc, so you should always be sure to check subsequent pages for results, even if the initial page has fewer results than expected. Read the filter section above for context.

Deleting an item with delete

Now, let's delete an item from the datastore by its primary key attribute using the apps.datastore.delete API Method. For example, consider the following:

// Somewhere in your function:
const uuid = "6db46604-7910-4684-b706-ac5929dd16ef";
const response = await client.apps.datastore.delete({
  datastore: "drafts",
  id: uuid,
});

if (!response.ok) {
  const error = `Failed to delete a row in datastore: ${response.error}`;
  return { error };
}

Regardless of what you named your primary_key, the query will always use id.

If the call was successful, the payload's ok property will be true, and if not, it will be false and provide an error in the errors property.

Generic types for datastores

You can provide your datastore's definition as a generic type, which will provide some automatic typing on the arguments and response:

import { DefineDatastore, Schema } from "deno-slack-sdk/mod.ts";

export const DraftDatastore = DefineDatastore({
  name: "drafts",
  primary_key: "id",
  attributes: {
    id: {
      type: Schema.types.string,
    },
    created_by: {
      type: Schema.slack.types.user_id,
    },
    message: {
      type: Schema.types.string,
    },
    channels: {
      type: Schema.types.array,
      items: {
        type: Schema.slack.types.channel_id,
      },
    },
    channel: {
      type: Schema.slack.types.channel_id,
    },
    message_ts: {
      type: Schema.types.string,
    },
    icon: {
      type: Schema.types.string,
    },
    username: {
      type: Schema.types.string,
    },
    status: {
      type: Schema.types.string, 
    },
  },
});

You can use the result of your DefineDatastore() call as the type in a function by using its definition property:

import { DraftDatastore } from "../datastores/drafts.ts";
...
    const putResp = await client.apps.datastore.put<
      typeof DraftDatastore.definition
    >({
      datastore: DraftDatastore.name,
      item: {
        id: draftId,
        created_by: inputs.created_by,
        message: inputs.message,
        channels: inputs.channels,
        channel: inputs.channel,
        icon: inputs.icon,
        username: inputs.username,
        status: DraftStatus.Draft,
      },
    });
    ...

By using typed methods, the datastore property (e.g. DraftDatastore.datastore) will enforce that its value matches the datastore definition's name property across methods and the item matches the definition's attributes in arguments and responses. Also, for get() and delete(), a property matching the primary_key will be expected as an argument.

Deleting a datastore

If you need to delete a datastore completely, for instance you've changed the primary key, you have a couple of options. Datastores do support primary key changes, so first try using the --force flag on a datastore CLI operation if the Slack CLI informs you that the datastore has changed. Otherwise, do the following:

  • Remove the datastore definition from the app's manifest
  • Run slack deploy
  • Modify the datastore definition to your heart's content and add it back into the app's manifest
  • Run slack deploy again

Troubleshooting

If you're looking to audit or query your datastore from the terminal without having to go through code, see the datastore commands.

If you're getting errors, check the following:

  • The primary key is formatted as a string
  • The datastore is included in the manifest's datastores property
  • The datastore bot scopes are included in the manifest (datastore:read and datastore:write)
  • The spelling of the fields in your query match exactly the spelling of the fields in the datastore's definition

The information stored when initializing your datastore using slack run will be completely separate from the information stored in your datastore when using slack deploy.


Have 2 minutes to provide some feedback?

We'd love to hear about your experience building modular Slack apps. Please complete our short survey so we can use your feedback to improve.