Datastores are a Slack-hosted way to store data for your next generation Slack apps. 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.
To initialize 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.
In the following example, we'll create a datastore called "Good Tunes" and define it in a file named good_tunes_datastore.ts
. It will hold information about music artists and their songs:
// datastores/good_tunes_datastore.ts
import { DefineDatastore, Schema } from "deno-slack-sdk/mod.ts";
export const GoodTunesDatastore = DefineDatastore({
name: "good_tunes",
primary_key: "id",
attributes: {
id: { type: Schema.types.string },
artist: { type: Schema.types.string },
song: { type: Schema.types.string },
},
});
Datastores can contain three primary properties. While primary_key
is the only required property, unhandled optional properties may cause 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:
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.
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 given the above GoodTunesDatastore
:
import { Manifest } from "deno-slack-sdk/mod.ts";
// Import the datastore definition
import { GoodTunesDatastore } from "./datastores/good_tunes.ts";
export default Manifest({
name: "Good Tunes",
description: "Add a song to the Good Tunes queue",
icon: "assets/icon.png",
outgoingDomains: [],
datastores: [GoodTunesDatastore], // Add the database to this list
botScopes: [
"commands",
"chat:write",
"chat:write.public",
"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.
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.
⤵️ 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.
When interacting with your datastore, it may be helpful to first visualize its structure. In our GoodTunesDatastore
example, let's say we have stored the following artists and their songs:
id | artist | song |
---|---|---|
1 |
Aretha Franklin | Respect |
2 |
The Beatles | Yesterday |
3 |
Led Zeppelin | Stairway to Heaven |
4 |
Whitney Houston | I Will Always Love You |
5 |
Fred Rogers | Won't You be My Neighbor? |
We're almost ready to start diving into datastore interactions, but first:
Beware of SQL injection
Be sure to sanitize any strings received from a user and never use untrusted data in your query expressions.
The apps.datastore.put
method is used for both creating and updating an item in a datastore. Let's see how that works in the following examples where we pass in values for each of the datastore's attributes:
// functions/insert_into_datastore.ts
import { DefineFunction, Schema, SlackFunction } from "deno-slack-sdk/mod.ts"; // Note the SlackFunction import here
export const InsertIntoDatastoreFunctionDefinition = DefineFunction({
callback_id: "insert_into_datastore",
title: "Insert into datastore",
description: "Adds artist and song to a datastore",
source_file: "functions/insert_into_datastore.ts",
input_parameters: {
properties: {
artist: {
type: Schema.types.string,
description: "The name of the artist",
},
song: {
type: Schema.types.string,
description: "The name of the artist's song",
},
},
required: ["artist", "song"],
},
});
export default SlackFunction(
InsertIntoDatastoreFunctionDefinition,
// Note the `async`, required since we `await` any `client` call.
async ({ inputs, client }) => {
// The below will create a *new* item, since we're creating a new ID:
const uuid = crypto.randomUUID();
// Use the client prop to call the SlackAPI
const response = await client.apps.datastore.put({ // Here's that client property we mentioned that allows us to call the SlackAPI's datastore functions
datastore: "good_tunes",
item: {
// To update an existing item, pass the `id` returned from a previous put command
id: uuid,
artist: inputs.artist,
song: inputs.song,
},
});
if (!response.ok) {
const error = `Failed to save a row in datastore: ${response.error}`;
return { error };
} else {
console.log(`A new row saved: ${response.item}`);
return { outputs: {} };
}
},
);
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": "good_tunes",
"item": {
"artist": "The artist you entered",
"song": "The song you entered",
"id": "unique_id_for_this_item"
}
}
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/good_tunes"
}
]
}
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.
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/update_datastore.ts
import { DefineFunction, Schema, SlackFunction } from "deno-slack-sdk/mod.ts"; // Note the SlackFunction import here
export const UpdateDatastoreFunctionDefinition = DefineFunction({
callback_id: "update_datastore",
title: "Update a datastore",
description: "Updates a song in a datastore",
source_file: "functions/update_datastore.ts",
input_parameters: {
properties: {
item_id: {
type: Schema.types.string,
description: "The ID of the datastore item to update",
},
song: {
type: Schema.types.string,
description: "The updated name of the song",
},
},
required: ["item_id", "song"],
},
});
export default SlackFunction(
UpdateDatastoreFunctionDefinition,
// Note the `async`, required since we `await` any `client` call.
async ({ inputs, client }) => {
// Use the client prop to call the SlackAPI
const response = await client.apps.datastore.update({
datastore: "good_tunes",
item: {
id: inputs.item_id,
song: inputs.song,
},
});
if (!response.ok) {
const error = `Failed to update datastore: ${response.error}`;
return { error };
} else {
console.log(`A row updated: ${response.item}`);
return { outputs: {} };
}
},
);
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": "good_tunes",
"item": {
"artist": "The artist that was already in the datastore",
"song": "The new song title you entered",
"id": "unique_id_for_this_item"
}
}
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/good_tunes"
}
]
}
If an item with the provided id
doesn't exist in the datastore, update
will insert the item using the provided attributes.
Now, let's retrieve an item by its primary key attribute using the apps.datastore.get
API Method. For example, consider doing following:
// Somewhere in your function:
const uuid = "6db46604-7910-4684-b706-ac5929dd16ef";
const response = await client.apps.datastore.get({
datastore: "good_tunes",
id: uuid,
});
if (!response.ok) {
const error = `Failed to get a row from datastore: ${response.error}`;
return { error };
}
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": "good_tunes",
"item": {
"artist": "Fred Rodgers",
"song": "Won't You be My Neighbor?",
"id": "6db46604-7910-4684-b706-ac5929dd16ef"
}
}
If the call was successful but no data was found, the item
property in the payload will be blank:
{
"ok": true,
"datastore": "good_tunes",
"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 UserDatastore.definition>({...});
const artistId = getResponse.item.id; // id is the primary_key
const artistEmail = getResponse.item.email; // email could be undefined
return {
outputs: {
id: artistId, // id is always defined
email: artistEmail, // email 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 UserDatastore.definition>({...});
const artistId = getResponse.item.id; // id is the primary_key
// email could be undefined, so use a fallback
const artistEmail = getResponse.item.email ?? "support@good-tunes.com";
return {
outputs: {
id: artistId, // id is always defined
email: artistEmail, // email is always defined
}
}
And finally, this example snippet supports the case where the function should error:
const getResponse = client.apps.datastore.get<typeof UserDatastore.definition>({...});
const artistId = getResponse.item.id; // id is the primary_key
if (getResponse.item.email) {
const artistEmail = getResponse.item.email;
return {
outputs: {
id: artistId,
email: artistEmail }
}
} else {
return {
error: "Artist doesn't have an email assigned"
}
}
If you need to retrieve more than a single row or find data without already knowing the id
, you'll want to run a query. The apps.datastore.query
API method uses DynamoDB syntax to do just that.
Parameter | Description |
---|---|
datastore |
A string with the name of the datastore to read the data from |
expression |
(Optional) A DynamoDB filter expression |
expression_attributes |
(Optional) A map of columns used by the expression |
expression_values |
(Optional) A map of values used by the expression |
limit |
(Optional) The maximum number of entries to return, 1-1000 (both inclusive); default is 100 |
cursor |
(Optional) The string value to access the next page of results |
Here's an example of how to query our GoodTunesDatastore
and retrieve a list of all the songs with names starting with "Won't":
const result = await client.apps.datastore.query({
datastore: "good_tunes",
expression: "begins_with (#song_name, :name)",
expression_attributes: { "#song_name": "song" },
expression_values: { ":name": "Won't" },
});
In this example, we're doing the following:
#song_name
to use when iterating through the :name
(rows) of the song
(column).:name
value begins with "Won't"
, return that item.Here's another example with GoodTunesDatastore
where we retrieve all the songs written by Fred Rodgers:
const result = await client.apps.datastore.query({
datastore: "good_tunes",
expression: "#artist_name = :name",
expression_attributes: { "#artist_name": "artist" },
expression_values: { ":name": "Fred Rodgers" },
});
Similarly, in this example, we're doing the following:
#artist_name
to use when iterating through the :name
(rows) of the artist
(column).:name
value matches "Fred Rodgers"
, return that item.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: "good_tunes",
cursor: "eyJfX2NWaVhnOTJ4Ym5fXyI6eyJTIjoiMDExYWIwNzItMTU4Yy00ZmJlLTg4OTYtNWExMjdjZmE4ZDUxIn19"
});
An expression is a way to build a query using comparison operators like =
, <
, <=
.
An expression_attributes
object is a map of the columns used for the comparison, and an 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 :
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!
Here's an example of a function that receives a song via an input
and queries for the song record that matches the provided song name:
const result = await client.apps.datastore.query({
datastore: "good_tunes",
expression: "#song = :song",
expression_attributes: {"#song": "song"},
expression_values: {":song": input.song}
});
You could also chain expressions together to narrow your results even further:
const result = await client.apps.datastore.query({
datastore: "good_tunes",
expression: "#song = :song AND #artist = :artist",
expression_attributes: {"#song": "song", "#artist": "artist"},
expression_values: {":song": input.song, ":artist": input.artist}
});
A full list of comparison operators is below:
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 do 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 do the right | a >= b |
BETWEEN ... AND |
True if one value is greater than or equal to one and less than or equal to another | a BETWEEN b AND c ( a : 6,b : 3, c : 6) |
begins_with(str, substr) |
True if a string begins with substring |
begins_with("racecar", "race") |
contains (path, operand) |
True if attribute specified by path is a string that contains the operand string |
contains (#song, :inputsong) |
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: "good_tunes",
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.
You can provide your datastore's definition as a generic type, which will provide some automatic typing on the arguments and response:
// datastores/my_datastore.ts
import { DefineDatastore, Schema } from "deno-slack-sdk/mod.ts";
export const MyDatastore = DefineDatastore({
name: "my_datastore",
primary_key: "id",
attributes: {
id: { type: Schema.types.string },
email: { type: Schema.types.string },
},
});
You can use the result of your DefineDatastore()
call as the type by using its definition
property:
// functions/save_my_data.ts
import { MyDatastore } from "../datastores/my_datastore.ts";
const response = await client.apps.datastore.put<typeof MyDatastore.definition>(
{
datastore: MyDatastore.definition.name,
item: { id: `${Date.now()}`, email: "test@test.com" },
},
);
By using typed methods, the datastore
property (e.g. MyDatastore.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.
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:
slack deploy
slack deploy
againIf 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:
datastores
propertydatastore:read
and datastore:write
)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
.