The Actions API

The @teamkeel/sdk package is generated based on your schema and contains a typed method for every action your APIs expose. These methods are all available on the exported actions object.

import { actions } from "@teamkeel/sdk";

This is a different tool from the Model API. models.item.create() writes the row behind the Item model. actions.createItem() runs the createItem action the way an API caller would, so the action's input validation, @permission rules, @validate rules, action hooks and events all apply. Reach for it when you want the rules that guard an action to guard your backend code too, and for the Model API when you want to write a table directly.

Given the following Keel schema:

schema.keel
role StockController {
    domains {
        "warehouse.example.com"
    }
}
 
model Item {
    fields {
        sku Text @unique
        name Text
    }
 
    actions {
        get getItem(id)
        create createItem() with (sku, name)
        update renameItem(id) with (name)
 
        write importItem(ImportItemInput) returns (ItemResponse) {
            @permission(expression: ctx.isAuthenticated)
        }
    }
 
    @permission(expression: true, actions: [get])
    @permission(roles: [StockController], actions: [create, update])
}
 
message ImportItemInput {
    sku Text
    name Text
}
 
message ItemResponse {
    id ID
    sku Text
    name Text
}

importItem can create the item through the action rather than through the table, so the StockController rule on createItem decides whether the import is allowed:

functions/importItem.ts
import { ImportItem, actions } from "@teamkeel/sdk";
 
export default ImportItem(async (ctx, inputs) => {
  const item = await actions.createItem({
    sku: inputs.sku,
    name: inputs.name,
  });
 
  return { id: item.id, sku: item.sku, name: item.name };
});

The actions object is available anywhere the SDK is: custom functions, action hooks, jobs, subscribers, flow function steps and route handlers.

The testing package exports an actions object too, and the two have the same per-action methods. @teamkeel/testing's version is for calling actions from a test, and it can choose the identity to run as. The one described here is for calling actions from your application code, and it cannot.

What you can call

Every action that one of your api blocks exposes has a method on actions: the built-in get, list, create, update and delete actions, your read and write custom functions, and the actions of a union. If your schema declares no api block at all, the default API exposes everything, so everything is callable. See APIs in the schema reference for how to control what an API exposes.

An action that no API exposes has no method and cannot be called. Because the generated types only offer the actions you can call, this is normally a TypeScript error rather than a runtime one. From untyped code, or against a build made before you narrowed an api block, the call fails with ERR_ACTION_NOT_FOUND.

Each method takes the same inputs and returns the same response as the action does over the JSON API, rehydrated into the SDK's own types: a date field comes back as a Date, and a file field as a File you can read. A get that matches nothing resolves to null, exactly as it does for an API caller, rather than throwing.

A file input is the one place the types are wider than the action's own. Your function usually has to build the file rather than fetch it, so a File input also accepts an InlineFile:

functions/archiveDocument.ts
import { ArchiveDocument, actions, InlineFile } from "@teamkeel/sdk";
 
export default ArchiveDocument(async (ctx, inputs) => {
  const file = new InlineFile({
    filename: `${inputs.name}.txt`,
    contentType: "text/plain",
  });
  file.write(Buffer.from(inputs.text, "utf8"));
 
  const created = await actions.createDocument({
    name: inputs.name,
    attachment: file,
  });
 
  return { id: created.id };
});

Who the action runs as

CallRuns as
actions.createItem(...)the identity the current invocation is running as
actions.withAuthToken(token).createItem(...)the token's owner

A bare call carries an access token for the identity your code is already running as. A request-triggered function forwards the token its caller presented. A flow step, or a job someone ran by hand, gets a short-lived token the runtime mints for the person who started it. A scheduled job, a subscriber and an unauthenticated request carry no token at all, so their calls run anonymously and only reach actions whose rules allow that.

Whichever it is, the action runs exactly as it would for that identity through the API. @permission rules, @where filters and row-level checks are all applied, so a function can do no more than its caller could have done directly. There is no backend bypass and, deliberately, no way to override the identity: if you need a call to run as somebody else, hold a token for them and pass it to withAuthToken.

// Runs as whoever called this function.
const item = await actions.createItem(inputs);
 
// Runs as the owner of the token, whoever the caller is.
const other = await actions.withAuthToken(serviceToken).createItem(inputs);

withTimezone(tz) sets the timezone that the action's relative date expressions are evaluated in. It takes an IANA time zone (opens in a new tab) string, and without it those expressions default to UTC, as they do for any other API caller:

const todays = await actions.withTimezone("Europe/London").listOrders({
  where: { placedAt: { equalsRelative: "today" } },
});

Both methods return a new instance and leave the original untouched, so the shared actions object cannot be re-scoped by something that ran before you. Chain them to use both.

Handling failures

A failed call throws an ActionError carrying the same code and status the JSON API would have returned. isActionError narrows a caught value, optionally to a single code:

functions/tryRenameItem.ts
import { TryRenameItem, actions, isActionError } from "@teamkeel/sdk";
 
export default TryRenameItem(async (ctx, inputs) => {
  try {
    await actions.renameItem({
      where: { id: inputs.id },
      values: { name: inputs.name },
    });
  } catch (e) {
    if (isActionError(e, "ERR_RECORD_NOT_FOUND")) {
      return { renamed: false };
    }
    throw e;
  }
 
  return { renamed: true };
});

An ActionError carries code, status, message, action (the action that was called) and, for some codes, data. The codes are:

CodeTypical cause
ERR_INVALID_INPUTAn input failed schema validation, or a @unique field was given a duplicate value
ERR_INPUT_MALFORMEDThe request body was not the shape the action expects
ERR_PERMISSION_DENIEDA @permission rule refused the identity the call runs as
ERR_RECORD_NOT_FOUNDAn update or delete matched no record
ERR_AUTHENTICATION_FAILEDThe token the call carried was rejected
ERR_ACTION_NOT_FOUNDNo API exposes an action by that name
ERR_CONFLICTA write left a record breaking a model-level @validate rule
ERR_HTTP_METHOD_NOT_ALLOWEDThe action does not accept the method used
ERR_REQUEST_BODY_TOO_LARGEThe inputs exceeded the request size limit
ERR_INTERNALThe action failed for a reason it does not describe
ERR_UNKNOWNAnything else

For ERR_INVALID_INPUT, data holds the per-field failures as { field, error }[], typed as ActionValidationErrorData:

import { actions, isActionError } from "@teamkeel/sdk";
import type { ActionValidationErrorData } from "@teamkeel/sdk";
 
try {
  await actions.createItem(inputs);
} catch (e) {
  if (isActionError(e, "ERR_INVALID_INPUT")) {
    const data = e.data as ActionValidationErrorData | undefined;
    console.log(data?.errors[0]?.field);
  }
  throw e;
}

A call that never reached the runtime at all, such as a connection that was reset, is an ERR_INTERNAL with the underlying error on cause.

An ActionError you do not catch reaches the original caller as the same kind of error rather than as a generic internal one. A permission failure inside your function makes the outer action return ERR_PERMISSION_DENIED, a missing record ERR_RECORD_NOT_FOUND, invalid inputs ERR_INVALID_INPUT. See Error handling for how this fits with the errors you throw yourself.

Transactions

An action call is executed by the runtime on its own database connection, in its own transaction. It does not join the transaction your function is running in, which has two consequences worth planning for:

  • A row your function has written but not yet committed is not visible to the action, and a foreign key pointing at it fails.
  • Rows your function holds locks on can block the action until your function's statement timeout.

So for a write function or a beforeWrite/afterWrite hook that calls a mutating action, turn the automatic transaction off and let each step commit as it runs:

functions/syncItem.ts
import { SyncItem, actions, models } from "@teamkeel/sdk";
 
const fn = async (ctx, inputs) => {
  const item = await models.item.create({
    sku: inputs.sku,
    name: inputs.name,
  });
 
  // With the transaction off the item above is committed, so the action can see it.
  return actions.getItem({ id: item.id });
};
 
fn.config = { dbTransaction: false };
 
export default SyncItem(fn);

That trade is real rather than free: without the transaction, a later failure leaves the earlier writes in place. A read custom function, a query hook, a job and a subscriber have no automatic transaction to begin with, so they need no change.

Nesting

A function can call an action that is itself a custom function, which can call further actions. The runtime refuses a chain deeper than 10 nested calls, failing that call with ERR_INVALID_INPUT and a message naming the depth limit. That is what stops a function which calls itself, directly or through other actions, from recursing forever.

Every level of the chain is a live function invocation holding a database connection while it waits for the level below, so keep chains short even well inside the limit.