Rows and realtime

Query, page, count and write your app's tables, many rows at once, subscribe to live changes and call custom actions with @authoric/js.

authoric.from(table) reaches one of your app's tables, as the signed-in user. What they see and change is what your policies allow, in their active organization.

const invoices = authoric.from("invoices");

const page = await invoices.list({ where: { status: "approved" }, order: "due_at", limit: 25 });
// { items, paging: { limit, offset, count, total, hasMore } }

const one = await invoices.get(invoiceUid);
const created = await invoices.create({ vendor_uid: vendorUid, amount: 4200 }); // { uid, ... }
const updated = await invoices.update(created.uid, { amount: 4500 }); // the row as it now stands
await invoices.delete(created.uid);

Rows are named by uid. A reference to another table's row is <name>_uid (vendor_uid), in what you send and what you get back. See rows for the full shape.

Querying

list takes the same query as the REST list:

const { items, paging } = await authoric.from("invoices").list({
  where: {
    status: { $in: ["received", "matched"] },
    amount: { $gte: 1000 },
    $or: [{ memo: { $contains: "tariff" } }, { due_at: { $lt: "2026-12-01" } }],
  },
  order: [{ column: "due_at", dir: "asc" }, "invoice_number"],
  columns: ["invoice_number", "amount", "due_at", "vendor_uid"],
  include: ["vendor"],
  limit: 50,
  total: true,
});

items[0].vendor?.name; // the referenced vendor, or null where the user cannot read it
paging.total; // counted because `total: true`; otherwise null
OptionDoes
whereThe filter tree: equality, null, $eq $ne $gt $gte $lt $lte, $in (up to 100), $between: [low, high], $contains and $startsWith (literal text), combined with $and / $or. At most 20 conditions, nested 3 deep.
orderA column (ascending), { column, dir }, or a list of up to 3.
columnsOnly these columns; uid always comes back.
includeUp to 3 references, named without _uid. Each referenced row is read with the user's own permissions on its table, and is null where they cannot read it.
limit, offsetOne page: 50 rows unless you ask for more, at most 1,000.
afterKeyset paging: null for the first page, then paging.nextCursor. Not with order or offset.
totalAlso count every matching row into paging.total.

A reference is filtered under its _uid name with uids: where: { vendor_uid: vendorUid }. A filter or sort on a redacted column is refused with COLUMN_REDACTED.

Walking every row

rows() yields every matching row, a page at a time, without holding them all at once:

for await (const invoice of authoric.from("invoices").rows({ where: { status: "approved" }, pageSize: 200 })) {
  await schedulePayment(invoice);
}

Without order, it pages by cursor in creation order, which stays correct and fast however large the table is: a row added or removed while you walk never shifts another onto two pages. With order, it pages by offset. pageSize is 100 unless you say otherwise, at most 1,000.

Counts and totals

const open = await authoric.from("invoices").count({ status: "received" }); // a number

const { rows } = await authoric.from("invoices").aggregate({
  groupBy: [{ column: "due_at", truncate: "month" }],
  measures: [
    { op: "count", as: "invoices" },
    { op: "sum", column: "amount", as: "total" },
  ],
  filters: { status: { $ne: "disputed" } },
  sort: "total DESC",
});

Both are computed on the server, over the same rows the user can list. aggregate takes the spec described in Querying.

Many rows at once

const { count, uids } = await authoric.from("invoices").createMany([
  { vendor_uid: vendorUid, invoice_number: "MS-88470", amount: 1200 },
  { vendor_uid: vendorUid, invoice_number: "MS-88471", amount: 860 },
]); // uids in the order given

await authoric.from("invoices").updateMany(uids.map((uid) => ({ uid, values: { status: "approved" } })));
await authoric.from("invoices").deleteMany(uids);

Up to 500 rows in one transaction. Each row is checked exactly as its single-row write would be, and one refused row refuses the whole batch and writes nothing: the error's details.index says which row. Rows are named by uid; there is no write by condition. See Batch writes.

Retry-safe writes

Every create, update, delete and batch carries a fresh Idempotency-Key, so the client can send it again after a dropped connection and the server writes once. To make a write safe across your own retries (a job that may run twice), pass your own key:

await authoric.from("payments").create(payment, { idempotencyKey: `payment-${orderUid}` });

A key belongs to the user who sent it: another user sending the same key never sees your response.

Live changes

const subscription = authoric.from("tasks").subscribe((change) => {
  if (change.op === "insert" || change.op === "update") board.upsert(change.row);
  if (change.op === "delete") board.remove(change.rowUid);
  if (change.op === "resync") board.reload(); // a bulk change: read the table again
});

subscription.close();

Each change arrives as this user may see it: the server re-reads the row as the subscriber, with their policy, organization and redaction, and skips rows they cannot read. Subscribing needs the same permission as listing.

The subscription reconnects on its own after a dropped connection and resumes from the last change it received, so nothing is missed. Pass onError to hear about drops (willRetry: true) and about a refusal that ends it (willRetry: false, for example the user signed out). To resume a subscription in a later page load, keep subscription.lastEventId and pass it back as lastEventId.

Custom actions

const approve = authoric.action("invoices", "/approve");
const result = await approve.call({ invoice_uid: invoiceUid, note: "Checked" });

action takes the table and the action's path, and { method } when the action is not a POST.

Errors you will meet

CodeMeans
TABLE_LOCKEDThe table does not allow this operation.
ROW_FIELD_NOT_ACCEPTEDA field the API does not take, such as id or a raw foreign key: details names the field to send instead.
ROW_REFERENCE_NOT_FOUNDA <name>_uid that names no row.
COLUMN_REDACTEDA filter or sort on a redacted column.
QUERY_INVALID, QUERY_TOO_COMPLEXA query the API cannot run, or one past a limit.
PAGE_CURSOR_INVALIDA cursor the API did not issue for this table.
QUERY_TIMEOUTThe query ran too long: narrow it, or index the columns it filters or sorts by.
BATCH_TOO_LARGEMore than 500 rows in one batch.
IDEMPOTENCY_KEY_REUSEDA key you passed was already used for a different write.

All of them: error codes.

On this page