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| Option | Does |
|---|---|
where | The 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. |
order | A column (ascending), { column, dir }, or a list of up to 3. |
columns | Only these columns; uid always comes back. |
include | Up 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, offset | One page: 50 rows unless you ask for more, at most 1,000. |
after | Keyset paging: null for the first page, then paging.nextCursor. Not with order or offset. |
total | Also 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
| Code | Means |
|---|---|
TABLE_LOCKED | The table does not allow this operation. |
ROW_FIELD_NOT_ACCEPTED | A field the API does not take, such as id or a raw foreign key: details names the field to send instead. |
ROW_REFERENCE_NOT_FOUND | A <name>_uid that names no row. |
COLUMN_REDACTED | A filter or sort on a redacted column. |
QUERY_INVALID, QUERY_TOO_COMPLEX | A query the API cannot run, or one past a limit. |
PAGE_CURSOR_INVALID | A cursor the API did not issue for this table. |
QUERY_TIMEOUT | The query ran too long: narrow it, or index the columns it filters or sorts by. |
BATCH_TOO_LARGE | More than 500 rows in one batch. |
IDEMPOTENCY_KEY_REUSED | A key you passed was already used for a different write. |
All of them: error codes.