TandemServer from @tanishqkancharla/tandem-server is a server-side Tandem database. It implements RemoteApi, so clients can sync against it, and it also exposes its own query and transaction API for server code.TandemServer takes the same runtime schema and relations as the client, plus a storage adapter.12345678910111213import { TandemServer, TandemServerJsonFileStorage, } from "@tanishqkancharla/tandem-server" import { schema, type TodoSchema } from "./schema" const server = new TandemServer({ schema, relations: {}, storage: new TandemServerJsonFileStorage<TodoSchema>({ filePath: "./data/tandem.json", }), })
server.close() on shutdown to close storage.mutators; optional hooks add server-only authorization and post-commit work. server.mutate.name(args) retains each registered mutation's argument type. Pass authenticated request context to server.push(request, { context }); the hooks factory receives it for that request. See Agent chat for the full pattern.TandemServer has connect, push, and pull methods that match RemoteApi. Clients in the same process can use the server as their remote directly:1const client = new TandemClient({ schema, remote: server })
remote is a transport that forwards push and pull to the server and calls poke when there may be new data. The Quickstart shows a minimal HTTP transport, and examples/todo has a complete Hono server.ready starts. The poke carries the commit's revision and the changed records inside that view, so the client shows them before it pulls:set holds each changed record in the view, in full, even after a partial update.remove holds records the client already has that the commit deleted or moved out of its view.append holds text inferred from a committed string extension to a record the client already has, instead of the whole record.livePokeInterval (50 ms by default) merge into one sent when the interval ends: each record appears once, at its latest value, and consecutive appends to a field join into one. A burst of commits, such as a reply streamed token by token, therefore costs each viewer at most one payload per interval. Pass livePokeInterval: 0 to send each commit's payload on its own.limit, arrives with the pull that every poke still starts. A client that has not pulled yet gets a bare poke, and the client whose push made the commit always gets one, so it pulls the acknowledgement at once instead of waiting for a stream of payloads to go quiet. Bare pokes are never delayed; a client's merged payload goes out right before one.createTandemEventStreamResponse serves those pokes to browsers as server-sent events, and connectTandemEventSource from @tanishqkancharla/tandem-core turns them back into poke calls. A poke event's data is the payload as JSON, or empty for a bare poke; pass schema to both helpers to apply collection codecs. A stream whose reader falls behind drops payloads and sends one bare poke when it catches up. The stream sends ready after it registers, so the client pulls once per connection and catches writes it missed while disconnected. It also sends a keep-alive comment every 15 seconds and closes when the request aborts or the body is cancelled. Open streams keep an HTTP server from closing, so combine the request signal with a shutdown signal, as examples/todo does. The Quickstart shows both sides.12345678910const todos = await server.query({ collection: "todos", where: { complete: false }, }) const { result, destroy } = await server.subscribe( { collection: "todos" }, (todos) => console.log(todos), { onError: (error) => console.error(error) }, )
1234567891011const tx = server.transact() const existing = await tx.get("todos", "welcome") if (!existing) { tx.set("todos", { id: "welcome", text: "Build something", complete: false, createdAt: Date.now(), }) } await server.commit(tx)
list, scan, update, remove, query, and cancel.update accepts partial assignments and awaits the current record. Reads in the same transaction see those assignments; missing records and empty payloads are no-ops. Supplied nested objects and arrays replace their entire field. Use set for complete replacements; updates cannot change identity or delete fields.123const tx = server.transact() await tx.update("todos", "welcome", { complete: true }) await server.commit(tx)
1234567const appendReply = defineMutator(schema, { args: z.object({ id: z.string(), text: z.string() }), run: async (tx, { id, text }) => { const message = await tx.get("messages", id) if (message) tx.set("messages", { ...message, text: message.text + text }) }, })
(message.note ?? "") + text. The app's argument schema should reject malformed text. Concurrent named calls each read the latest committed value in the server queue. Raw transactions whose reads ran before another commit do not gain that guarantee.scan accepts a prefix of them.12345678910111213import { collection, defineSchema } from "@tanishqkancharla/tandem-core" type Entry = { id: readonly [sessionId: string, sequence: number] body: string } const schema = defineSchema({ entries: collection<Entry>({ fields: ["id", "body"] }), }) const tx = server.transact() const sessionEntries = await tx.scan("entries", { prefix: ["session-1"] })
scan call.TandemServerStorageApi:12345interface TandemServerStorageApi<Schema> { scan(args?: ScanStorageArgs): Promise<TandemTuple<Schema>[]> commit(writes: WriteOps<TandemTuple<Schema>>): Promise<void> close(): Promise<void> }
TandemServerJsonFileStorage keeps tuples in memory and writes the whole file on each commit. It suits development and examples, not production.TandemServer instance that committed the write, or from storage subscribe notifications. Writes made by other processes or directly to the database do not poke clients unless storage reports them. Event streams connected to another process miss those pokes and rely on the client's fallback pull. Storage notifications are bare pokes, without records.subscribe, the server makes appends wait for a pull read after it. A write that storage does not report can let a client extend text that another process replaced, until its next pull.push or pull, and authorize connect (including the event stream route) the same way: pokes carry the records of the client's latest pulled view to whoever holds its connection. A view lasts until the client's next pull or until its last connection closes, so when a user loses access, close that client's connections: pokes then carry no records until a pull your transport authorizes.