Workflow
- 1Follow the managed-database guide to publish a customers table with id and name columns. Set PERMUTE_DATABASE_ID to that database ID.
- 2Create an action with business action creation admin permission. Editing and publishing require admin permission on the action and each selected source.
- 3Save the handler, input and output contracts, invocation permission, and published schema bindings as one draft.
- 4Publish the exact draft through client.revisions, then invoke it with a stable requestId and input.
Complete example
import { PermuteClient } from '@permute/sdk';
const client = new PermuteClient({ apiKey: process.env.PERMUTE_API_KEY! })
.withWorkspace(process.env.PERMUTE_WORKSPACE_ID!);
const databaseId = process.env.PERMUTE_DATABASE_ID!;
const schema = await client.databases.definition(databaseId);
if (!schema.published) throw new Error('Publish the database schema first');
// Keep the ID after creation. Set PERMUTE_ACTION_ID to edit this action later.
const action = process.env.PERMUTE_ACTION_ID
? await client.actions.get(process.env.PERMUTE_ACTION_ID)
: await client.actions.create({ name: 'Create customer' });
console.log('Action ID:', action.id);
const current = await client.actions.definition(action.id);
const contract = {
type: 'object',
properties: { id: { type: 'string', minLength: 1 }, name: { type: 'string', minLength: 1 } },
required: ['id', 'name'],
additionalProperties: false,
};
const saved = await client.actions.saveDefinition(action.id, {
expectedDraft: current.draft
? { revisionId: current.draft.revisionId, sequence: current.draft.sequence }
: null,
definition: {
source: "exports.handler = ({ input, db }) => { db.insert('customers', input); return input; };",
inputSchema: contract,
outputSchema: contract,
invokePermission: 'write',
sources: [{
sourceId: databaseId,
revisionId: schema.published.revisionId,
readTables: [],
writeTables: ['customers'],
}],
},
});
const history = await client.revisions.list(action.id);
if (history.draft?.revisionId !== saved.revisionId) {
throw new Error('Action draft changed; review before publishing');
}
await client.revisions.publish(action.id, { expectedVersion: history.version });
// Persist this request before sending if retries must survive a process restart.
const request = { requestId: crypto.randomUUID(), input: { id: crypto.randomUUID(), name: 'Acme' } };
const receipt = await client.actions.invoke(action.id, request);
console.log(receipt.revisionId, receipt.output);
// After a lost response, retry with the identical requestId and input.Permissions and publication
The author delegates selected database tables and connector accounts when saving and publishing. Callers need the declared read, write, or admin permission on the action; they do not need general write access to its database. Use the narrowest action contract that fits the operation.
client.actions.list exposes published input and output contracts without source code. client.actions.definition requires admin permission and returns draft and published definitions. History, restore, and publication use client.revisions; restoring does not publish or execute the action.
Hosted apps bind an exact published action revision in apps.saveSite and call it through @permute/sdk/app. The deploy-app guide covers bindings and browser calls. API-key invocations use the current published action, while a retry retains the revision selected by its first attempt.
Read warehouse sources and operation outputs
Bind queryable warehouse sources with { sourceId }: data_ for processed files, conn_ for synced connectors, oper_ for Data Tables, Data Flows, reports, and dashboards, or enty_ for completed entity outputs. Operations need a completed output and entity outputs must be outside draft lifecycle state. Google Drive imports (like uploads) become individual data_ datasets; bind those file IDs. Saving and publishing delegate read access to the whole selected source and require its admin permission.
warehouse.read({ sourceId }) returns text blocks with their IDs, order, and page numbers, plus table IDs, display names, and column schemas. Pass a table block ID or an unambiguous display name to read rows. where accepts equality filters with string, boolean, or null values. Unknown or ambiguous tables and unknown columns are rejected.
Each read permits at most 1,000 rows or text blocks and 1 MB of data; the default limit is 100. Oversized reads fail, so narrow table filters or raise limit within that bound. Warehouse reads use current available output, including retained output after a failed refresh; publishing an action does not freeze source content. Schemas and subscriptions have no query capability. Live SQL and external writes use supported named connector calls.
// Add { sourceId: fileId } to the action definition's sources, then publish.
// Handler source:
exports.handler = async ({ inputs, warehouse }) => {
const document = await warehouse.read({ sourceId: inputs.fileId });
if (!document.tables.length) return { blocks: document.blocks, rows: [] };
const rows = await warehouse.read({ sourceId: inputs.fileId, table: document.tables[0].id, limit: 1000 });
return { blocks: document.blocks, rows };
};Reject a business operation
Handlers receive reject(code, message, details?). Use it for expected business failures: validation_failed (400), record_conflict or invalid_transition (409), permission_denied (403), or not_found (404). Rejection stops the handler and discards staged database writes; connector calls already sent cannot be undone.
Messages and details are returned to the caller, so keep diagnostics and secrets out. Messages permit 1–1,000 characters. Optional details accepts up to 20 string fields, with keys of 1–100 characters and values up to 500. Invalid rejections and unexpected exceptions return execution_failed (500), with private exception text in server logs.
Both the API-key and browser SDKs expose PermuteApiError.code, status, and details. Recover using codes rather than matching messages. Reload a stale record on record_conflict; correct input on validation_failed. For request_in_progress (409), wait and retry the same request. request_conflict (409) means the ID was already used for different input or a different action revision than the app’s pin. outcome_unknown (409) requires retaining the original request and checking external state before another write. Transport and authentication failures can still use general API error codes.
// Handler source, published with its input and output contracts.
exports.handler = ({ input, db, reject }) => {
if (!input.name.trim()) reject('validation_failed', 'Enter a name.', { field: 'name' });
db.insert('customers', input);
return input;
};Writes, retries, and limits
Handlers run sandboxed JavaScript. db.insert, db.update, and db.delete stage local writes until the handler completes and its output validates. An action can write to one managed database, with up to 100 mutations; the mutations and receipt commit atomically. Reads are outside that write transaction.
A new user operation needs a new requestId. Reuse both requestId and input when retrying an uncertain response (changing the payload is not a retry). Do not generate a new ID merely because a network request failed.
Optional connectors maps names to connector IDs. Handlers call connectors.<name>.request with provider-relative HTTP paths, query for supported SQL providers, or putObject for S3. Each external call needs a stable stepId; credentials remain on the server, and the connected account’s grants apply.
External effects happen when awaited and cannot be rolled back with local database writes. Successful steps replay their saved responses; uncertain outcomes block automatic resend and require checking provider state. There is no reconciliation API or long-running workflow runner. Changing a published database write target requires a new action.