Skip to content

Database

Accessible via: Main Menu -> Database

A table is structured data — rows and columns — that Sammy and your workflows can read, filter, and update. Use it for anything you’d otherwise track in a spreadsheet: leads, properties, tasks, inspections, inventory.

Tables are the difference between an automation that starts from nothing every run and one that carries state: a workflow can write what it did into a table, and the next run can read it back.

Tables hold your structured records. For documents you want Sammy to answer from — policies, contracts, SOPs — use a knowledge base instead. A table is queried; a knowledge base is searched.

Creating a table

  1. Open Database in the left rail.
  2. Click New table.
  3. Name it and define its columns.

Each column has a type: text, numeric, boolean, timestamp, or JSON. A column can also be a reference to another table, which links the two records together so a row can point at a row elsewhere.

Columns can be marked unique, and the schema can be modified in place after creation — add, rename, or drop a column, toggle required, change a default, or add a foreign key or unique constraint. Changing a column’s type is not an in-place change: it needs --force-recreate, which drops and recreates the table and destroys every row.

Linking columns to the data dictionary

A column can be linked to a data-dictionary field. Do this where the data also appears on forms: it means your forms, agents, and tables all use the same canonical name for the same thing, so nothing has to be mapped by hand later.

Working with the data

Pick a table in the column to open its data view: browse rows, sort, filter, and edit inline. Rows can be added and edited by hand, but the point of a table is that Sammy and your workflows do it for you.

Several writes in one transaction

POST /orgs/{organizationId}/tables/_transaction applies an ordered list of writes across any of the organisation’s tables in one database transaction: either all of them commit or none does.

{ "writes": [
  { "op": "assert", "table": "erp_purchase_order", "where": { "id": 42, "version": 3 }, "expectRowCount": 1 },
  { "op": "update", "table": "erp_bin", "where": { "id": 41 }, "increment": { "on_hand": 6, "stock_value": "590.94" }, "returning": ["on_hand", "stock_value"] },
  { "op": "insert", "table": "erp_stock_ledger_entry", "data": { "qty": 6, "qty_after": { "$ref": 1, "field": "on_hand" }, "value_after": { "$ref": 1, "field": "stock_value" } }, "returning": ["id"] }
] }
  • The operations are insert, update, delete and assert. where is an equality map; an update or delete that matches no row fails the whole transaction (condition_failed), which is how you detect a concurrent change: read a row’s version, send it in where.
  • increment adds to a numeric column atomically, treating an empty column as zero; returning gives the row back; { "$ref": i, "field": "c" } uses a value from an earlier write’s returned row. A $ref cannot fill a JSON column, whose values are always stored as the document you send, and it fails rather than matching rows when the value it reads is empty.
  • A null written in where matches rows whose column is empty.
  • assert checks a row count without writing anything. It does not lock either: two concurrent requests can both count zero and both insert. To enforce an at-most-one rule, claim the parent row earlier in the same transaction with a conditional update on the column that records the claim, or an increment on its version; the second request then fails with condition_failed or waits for the first to commit.
  • At most 300 writes. Every failure names the failing write: { "ok": false, "index": 1, "code": "condition_failed", "retryable": false, "message": "…" }. Codes: condition_failed, assert_failed, ambiguous_returning, unique_violation, fk_violation, validation, limit_exceeded, bad_ref, table_not_found, retryable_conflict, timeout, internal.
  • A transaction has 25 seconds in total, planning included; no single statement may run longer than 20 seconds. Past either limit the request fails with timeout and nothing is written.
  • retryable_conflict and timeout are the two codes that come back with retryable: true — send the same request again. timeout returns HTTP 503; the rest of the conflict group (condition_failed, assert_failed, unique_violation, retryable_conflict) returns 409. A timeout is reported to you rather than retried server-side, so you keep control of how long the work is allowed to take.
  • Numeric columns are returned as strings ("970.94"), and you may send them as strings too; they are stored exactly.

Using tables from workflows and agents

  • Workflows can read from and write to tables as steps, so an automation can look something up, act, and record the result.
  • Agents pointed at a table answer from your live data rather than a stale export.
  • Scheduled workflows turn a table into recurring work — read the rows due today, act on each one, write back what happened.

From the CLI

Tables can be created and queried from the command line, which is the easiest way to script bulk changes or seed a table from an existing file. See CLI › Tables.

A table can also be defined in YAML and deployed, in which case its live schema is reconciled against the file — columns and constraints are brought into line on every deploy, not just created once. Changes that would destroy data (dropping a column, say) fail the deploy unless you explicitly pass --allow-destructive, so an unattended CI/CD run can’t quietly drop a column. Use --dry-run to see the pending schema change first. See CLI › Deploy.