Custom Table

Custom tables provide multi-tenant data storage within Hyphen. Create tables with typed fields, then read, write, update, and upsert records from workflows. Every mutation is audit-logged.


Creating a Table

Tables are created via API before use in workflows:

bash
curl -X POST https://your-hyphen.example.com/custom-tables \
  -H "X-Org-Id: acme-corp" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "reconciliation_log",
    "fields": [
      { "name": "run_id", "type": "text", "required": true },
      { "name": "invoice_id", "type": "text", "required": true },
      { "name": "amount", "type": "numeric", "required": false },
      { "name": "status", "type": "text", "required": true },
      { "name": "matched", "type": "boolean", "required": false },
      { "name": "processed_at", "type": "timestamptz", "required": false }
    ],
    "key_fields": ["run_id", "invoice_id"]
  }'

key_fields names the columns that identify one row, and becomes a unique constraint. Without it, the key is the system id column every table has. Update and upsert select a row by exactly these columns (or by id), so declare them for any table you update or upsert into.

POST/custom-tables

Create a new custom table with typed field definitions.

Field Types

Type Description Example Values
text String value, optional maxLength "INV-001", "pending"
integer Whole numbers 42, 0, -5
numeric Decimal numbers 1500.50, 0.02
boolean True/false true, false
timestamptz Timestamp with timezone "2025-01-15T10:30:00Z"

A numeric column has no fixed precision or scale, so large values such as amounts in minor units are stored exactly. Tables created by earlier releases had numeric columns limited to values below one million with two decimals; a maintenance step widens them in place, keeping every stored value, and until it has run on a deployment those columns keep that limit.

An object or array written to a text column is stored as JSON text.


Usage in Workflows

Use type: "custom-table" as a workflow step with the desired operation.

Write (Insert)

json
{
  "type": "custom-table",
  "properties": {
    "table": "reconciliation_log",
    "operation": "write",
    "keys": ["run_id", "invoice_id", "amount", "status", "matched", "processed_at"],
    "values": ["@__run_id", "@input.invoice_id", "@input.amount", "processed", true, "@now"]
  }
}

A write inserts one row made of the keys and values pairs. Put every column you want stored in them; a write does not use fields. A row whose key fields already exist fails the step.

Read (Query)

json
{
  "type": "custom-table",
  "properties": {
    "table": "reconciliation_log",
    "operation": "read",
    "keys": ["invoice_id"],
    "values": ["@input.invoice_id"],
    "outputKey": "previous_runs"
  }
}

Results are stored in context under the outputKey (default records) as an array of rows. Without where, the keys and values pairs are the filter; with where ({ "column": "@value" }), set keys and values to []. A read returns at most limit rows: 100 by default and at most 1,000.

Update

json
{
  "type": "custom-table",
  "properties": {
    "table": "reconciliation_log",
    "operation": "update",
    "key_fields": ["run_id", "invoice_id"],
    "keys": ["run_id", "invoice_id"],
    "values": ["@__run_id", "@input.invoice_id"],
    "fields": {
      "status": "approved",
      "matched": true
    }
  }
}

An update needs a selector: either where, or key_fields naming which keys and values pairs select the row. The selector must be exactly the table's key fields, or id; any other selector fails the step with custom_table_ambiguous_selector. The row it selects gets the values in fields (or, without fields, the remaining keys and values pairs). If no row matches, nothing changes and the step completes.

Upsert (Insert or Update)

json
{
  "type": "custom-table",
  "properties": {
    "table": "vendor_preferences",
    "operation": "upsert",
    "key_fields": ["vendor_id"],
    "keys": ["vendor_id"],
    "values": ["@input.vendor_id"],
    "fields": {
      "preferred_payment": "@input.payment_method",
      "last_updated": "@now"
    }
  }
}

Here vendor_preferences was created with "key_fields": ["vendor_id"]. An upsert selects the row the same way an update does. If the row exists, it gets the values in fields (or, without fields, the remaining keys and values pairs). Otherwise a new row is created from the selector, the keys and values pairs, and fields.

Transaction

A transaction applies ordered writes, across one or more tables, in one database transaction. Every write commits, or none does. Each write names its own table, so the step has no top-level table.

json
{
  "type": "custom-table",
  "properties": {
    "operation": "transaction",
    "outputKey": "applyResult",
    "writes": [
      {
        "op": "insert",
        "table": "invoice_claims",
        "rows": {
          "from": "@input.lines",
          "fields": {
            "invoice_id": "@item.invoice_id",
            "line_id": "@item.line_id",
            "run_id": "@__run_id"
          },
          "require": ["invoice_id", "line_id"]
        }
      },
      {
        "op": "update",
        "table": "review_queue",
        "where": { "batch_id": "@input.batch_id" },
        "expect": { "status": "awaiting_approval" },
        "fields": { "status": "approved", "decided_at": "@now" }
      }
    ]
  }
}

If invoice_claims has invoice_id and line_id as its key fields, a line that another run already claimed turns the whole step into a conflict, and the review row stays awaiting_approval.

Write property Applies to Required Description
op All Yes "insert" or "update"
table All Yes Table the write targets
rows insert Yes A row object, a list of rows, an @path that resolves to either, or a row mapping
rows update No An update mapping instead of where, expect, and fields: one compare-and-swap per element of a list
where update Yes, without rows Selects the row to update
fields update Yes Values to set
expect update No Values the row must still hold for the update to apply (compare-and-swap)

A row mapping ({ "from", "fields", "require" }) turns a list into rows. from is an @path to the list, and each element becomes one row. In fields, @item is the current element and @item.<path> reads from it; other values resolve against the workflow context. A row missing a require column (absent, null, or "") is skipped. A mapping that yields no rows drops its write, and the remaining writes still commit together. If every write is dropped, the step completes as applied without writing.

A mapping may add filter to keep only some elements, by their fields: { "case_key": { "in": "@proposal.proposed_case_ids" } } keeps elements whose case_key is in that list, and { "status": "open" } keeps elements whose status equals that value. An element whose filtered field is blank is not kept by in.

An update can map a list too: "rows": { "from", "filter", "where", "expect", "fields" } becomes one single-row compare-and-swap per element, with @item available in all three objects. An element whose where has a blank value is skipped. All of the write's updates commit with the rest of the transaction, and one stale expectation rolls back everything; the conflict names its update_index. Each update counts as one row toward the limit.

json
{
  "op": "update",
  "table": "claims",
  "rows": {
    "from": "@packet.claim_moves",
    "filter": { "case_key": { "in": "@proposal.proposed_case_ids" } },
    "where": { "source_row_id": "@item.source_row_id" },
    "expect": { "decision_ref": "@item.decision_ref", "claim_version": "@item.claim_version" },
    "fields": { "decision_ref": "@__approval_decisions.3.approval_id", "claim_version": "@item.next_claim_version" }
  }
}

Limits and checks:

  • 1 to 10 writes per step, and at most 1,000 rows across all writes (an update counts as one row).
  • Every column a write names must exist on its table. An unknown column fails the step.
  • An update's where and expect together must match exactly one row.

Result

The result is stored in context under outputKey (default transactionResult).

Field Description
status "applied" or "conflict"
writes Applied only: one entry per write with write_index, table, op, and count
write_index, table, op Conflict only: the write that conflicted
update_index Conflict only, for a mapped update with more than one element: the update that failed its expectation
reason Conflict only: "unique_violation" or "expectation_failed"
key Conflict only: the existing row's key fields for unique_violation (when that row is found), or the update's where for expectation_failed
matched_rows expectation_failed only: how many rows matched where and expect
existing_decision_ref unique_violation only, when the table has a decision_ref column: that column's value on the existing row
replayed true when the result comes from an earlier attempt of the same step

Outcomes:

  • Applied: every write committed.
  • Conflict: an insert collided with an existing row on the table's key fields (key_fields when the table was created), or an update's where and expect did not match exactly one row. Nothing is written. The step still completes, so the workflow branches on the result, for example with a filter on { "equal": ["@applyResult.status", "conflict"] }. When two runs race for the same key or the same expected value, one commits and the other gets the conflict.
  • Error: a missing or unready table, an unknown column, or a write over the limits fails the step. Nothing is written.
  • Retry: a retry of the step after the transaction committed (for example after a lost acknowledgement) returns the stored result with replayed: true and writes nothing. A conflict is stored and returned the same way.

Properties Reference

Property Type Required Description
table string Yes, except transaction Table name
operation string Yes "read", "write", "update", "upsert", or "transaction"
keys string[] No Column names paired with values. write inserts them as the row; read filters on them when there is no where; update and upsert take their selector from them through key_fields
values array No Values for keys, in the same order (supports @path)
where object No read: the filter. update, upsert: the selector, exactly the table's key fields or id
key_fields string[] For update and upsert without where Which keys form the selector; must be the table's key fields
fields object No update and upsert: the values to set (supports @path). Not used by write
limit number No read: most rows returned (default 100, max 1,000)
writes array For transaction Ordered writes; see Transaction
outputKey string No Context key for read results (default records) or the transaction result (default transactionResult)

Direct API Access

Tables can also be accessed directly via REST API, outside of workflows:

GET/custom-tables/:tableName

View all data in a custom table.

POST/custom-tables/:tableName/insert

Insert a single record.

POST/custom-tables/:tableName/bulk-insert

Insert multiple records at once.

:::api PUT /custom-tables/:tableName/update Update the row that where selects: the table's key fields, or id. Body: { "where": { "id": "<row_id>" }, "updates": { "field": "value" } } :::

Row-Based Endpoints

These endpoints provide SDK-compatible row-level access:

GET/custom-tables/:tableName/rows

List all rows in the table.

POST/custom-tables/:tableName/rows

Insert a single row. Body is the record object.

PATCH/custom-tables/:tableName/rows/:rowId

Update a single row by ID. Body contains the fields to update.


Audit Log

The audit endpoint currently exposes table lifecycle audit entries, such as creation requests, successful table creation, and table deletion. It does not yet provide row-level mutation history with actor identity, previous values, and new values.

bash
curl -X GET https://your-hyphen.example.com/custom-tables/reconciliation_log/audit \
  -H "X-Org-Id: acme-corp"
GET/custom-tables/:tableName/audit

View lifecycle audit entries for a custom table.


Multi-Tenant Isolation

Custom tables are fully scoped per organization. A table named reconciliation_log in org acme-corp is completely separate from a table with the same name in org globex-inc. There is no cross-org data access.


Common Patterns

Workflow audit log: Write a record after every workflow run with key metrics: matched count, exception count, approval status, timestamp.

Operational memory: Store intermediate results across workflow runs. An agent can read from a custom table to check whether an invoice was previously processed before investigating it again.

State tracking: Track the status of long-running multi-step processes. Each workflow run updates the record as it progresses through stages.

→ Next: Actions: registering reusable operations