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.

Table of Contents

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 rather than talking to a server, so it runs in continuous integration with nothing else 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, and 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. Missing directories are created.
--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.
--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 does not, or when the registry uses TypeScript syntax that cannot be erased, 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 things are worth reading off that output:

  • One exported constant per database, named after the database identifier, holding a reads map and a writes map. Import it and every operation is a property rather than a string.
  • One row interface per read that declared columns, named from the database and the operation. A read that declared none, and takes arguments, leaves its row shape open instead.
  • 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 will not invent a type it cannot prove. Narrow at the edge of your application, the same way you would narrow a parsed JSON payload.

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, and let a live query echo it 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-amara' },
})
 
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 })

After a rename in the registry and a regeneration, every call site still passing the old name fails to compile. That is what generating the references buys you over writing them by hand.

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 without regenerating, which is exactly the change that 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 rename shows up as a contract change rather than a line of SQL.

Two names that generate the same identifier stop the run with an error naming both, rather than emitting a file that declares one of them twice. Rename one of the operations to clear it.