Networked access

Code generation

Emit typed operation references from the registry your server is built from, so a client calls each read and write by name with the argument and row types the server serves.

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.ts

Flags come in pairs, so passing one without a value prints the usage text and exits non-zero.

FlagRequiredWhat it does
--registryYesThe module holding the registry. The generator imports it, so the runtime has to be able to load that file.
--outYesWhere to write the generated TypeScript. The generator creates any missing directory.
--manifestNoAlso write the manifest as JSON, which is useful for diffing a registry between deploys.
--exportNoRead a named export other than operations. Without it the generator reads operations, then a default export.
--shared-exportNoRead the shared operations from a named export other than sharedOperations. Without it the generator reads a sharedOperations export wherever the module has one.
--packageNoImport 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.ts

What it emits

Running the generator against the two reads and one write from the registered operations guide writes this file.

src/generated/operations.ts
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 reads map and a writes map. 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.
  • myOrders takes Record<string, never>, because every value it needs comes from fromIdentity. 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.ts
src/generated/shared-operations.ts
import 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.

client.ts
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.ts

A 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.