Writing an operationRef by hand works until someone renames an argument, and then it points at a name the server no longer serves. The sirannon-codegen binary reads the registry your server is built from and writes the references your client calls it through, so the names, the arguments, and the columns come from the definitions the server runs. It imports a module, so it runs in continuous integration with no server up.
Run the generator
Installing the package installs the binary, so pnpm exec reaches it in any workspace that depends on @delali/sirannon-db.
pnpm exec sirannon-codegen --registry ./src/operations.ts --out ./src/generated/operations.tsFlags come in pairs, so passing one without a value prints the usage text and exits non-zero.
| Flag | Required | What it does |
|---|---|---|
--registry | Yes | The module holding the registry. The generator imports it, so the runtime has to be able to load that file. |
--out | Yes | Where to write the generated TypeScript. The generator creates any missing directory. |
--manifest | No | Also write the manifest as JSON, which is useful for diffing a registry between deploys. |
--export | No | Read a named export other than operations. Without it the generator reads operations, then a default export. |
--shared-export | No | Read the shared operations from a named export other than sharedOperations. Without it the generator reads a sharedOperations export wherever the module has one. |
--package | No | Import OperationRef from a package name other than @delali/sirannon-db, for a workspace that re-exports it. |
The generator imports the registry module as it stands, so how you invoke it depends on what your runtime can load. A recent Node strips TypeScript types on its own, and the command above then reads a .ts registry with no extra setup. Put a loader in front of it when your runtime keeps those types, or when the registry uses TypeScript syntax that type stripping cannot erase, such as an enum or a parameter property.
pnpm exec tsx node_modules/@delali/sirannon-db/dist/codegen/cli.mjs --registry ./src/operations.ts --out ./src/generated/operations.tsWhat it emits
Running the generator against the two reads and one write from the registered operations guide writes this file.
import type { OperationRef } from '@delali/sirannon-db'
export const registryDigest = "40c2fd904f25bbc10e88bf0ad329c1a073b4201042b02399da7e7c8c4d12009a"
export interface OrdersMyOrdersRow {
id: unknown
total: unknown
status: unknown
}
export interface OrdersOrdersByStatusRow {
id: unknown
total: unknown
status: unknown
}
export const orders = {
reads: {
myOrders: { name: "myOrders" } as OperationRef<Record<string, never>, OrdersMyOrdersRow>,
ordersByStatus: { name: "ordersByStatus" } as OperationRef<{ status: unknown }, OrdersOrdersByStatusRow>,
},
writes: {
placeOrder: { name: "placeOrder" } as OperationRef<{ total: unknown }, never>,
},
}Four details in that output are worth reading closely:
- The generator exports one constant per database, named after the database identifier, and that constant holds a
readsmap and awritesmap. Once you import it, every operation is a property on that constant. - It emits one row interface per read that declared
columns, named from the database and the operation. A read that declares no columns, and that takes arguments, leaves its row shape open. myOrderstakesRecord<string, never>, because every value it needs comes fromfromIdentity. The call site passes{}and TypeScript rejects anything else.- Each property is typed
unknown. The registry declares which columns exist, not what SQLite stores in them, so the generator leaves each value type open. Narrow at the edge of your application, the same way you would narrow a parsed JSON payload.
For a module that exports a shared set, the generator writes one more constant, named sharedOperations, and gives its row types the prefix Shared. A module may export the shared set alone, as shared-operations.ts in the registered operations guide does, and the generator writes this file from that module.
pnpm exec sirannon-codegen --registry ./src/shared-operations.ts --out ./src/generated/shared-operations.tsimport type { OperationRef } from '@delali/sirannon-db'
export const registryDigest = "e32a04e09391bbb508f4be0cecbfc12bb90196fdfb5cc4717e1e712a29d8a129"
export interface SharedOpenInvoicesRow {
id: unknown
total: unknown
}
export const sharedOperations = {
reads: {
openInvoices: { name: "openInvoices" } as OperationRef<Record<string, never>, SharedOpenInvoicesRow>,
},
writes: {
raiseInvoice: { name: "raiseInvoice" } as OperationRef<{ total: unknown }, never>,
},
}registryDigest records the digest the registry had at the moment you generated the file. Compare it against the registry.digest in GET /capabilities to catch a client built against a registry the server no longer runs. A live query can echo the same digest, so the server refuses a stale subscription with REGISTRY_MISMATCH.
Call through the generated file
The references replace every hand-written operationRef, and the row types follow the read.
import { SirannonClient } from '@delali/sirannon-db/client'
import { orders } from './generated/operations'
const client = new SirannonClient('http://localhost:9876', {
transport: 'websocket',
headers: { Authorization: 'Bearer session-maria' },
})
const db = client.database('orders')
const pending = await db.query(orders.reads.ordersByStatus, { status: 'pending' })
const mine = await db.query(orders.reads.myOrders, {})
await db.execute(orders.writes.placeOrder, { total: 4999 })Once you rename an operation in the registry and regenerate, every call site still passing the old name fails to compile, so the compiler reports the rename before the code runs.
Catch a stale generated file in CI
The generator needs no running server, so a check that regenerates and compares is cheap:
pnpm exec sirannon-codegen --registry ./src/operations.ts --out ./src/generated/operations.ts
git diff --exit-code src/generated/operations.tsA non-empty diff means someone changed the registry and left the generated file behind, and that change would otherwise reach production as a runtime UNKNOWN_QUERY or MISSING_ARGUMENT. Committing the generated file also keeps the digest visible in review, so a reviewer reads a rename as a contract change.
Two names that generate the same identifier stop the run with an error naming both. Rename one of the operations to clear it.