Write your own storage adapter
Keep Latch's data in your own schema or another database, and prove it keeps the same guarantees.
The built-in adapters cover Postgres and SQLite. Write your own when you need Latch's data in a schema you already have, in another database, or behind a layer you control: an audit log, encryption, a cache.
First: do you need to wrap, or replace?
Most needs are a wrapper around a built-in adapter: same tables, one method changed.
const base = createPostgresAdapter<Principal>(db, { owner });
const storage: StorageAdapter<Principal> = {
...base,
appendMessages: async (chatId, messages, ...rest) => {
await audit.record(chatId, messages);
return base.appendMessages(chatId, messages, ...rest); // pass every argument on
},
};Write one from scratch only for a different database or a different schema.
Start from the checks
The guarantees an adapter must keep only show up under load: two servers claiming one run, a server waking up after its run was taken over. The conformance checks make them show up in a test, so write the test first and make it pass one check at a time.
import { describe, it } from "vitest";
import { registerStorageConformance } from "@intentface/latch-core/testing";
registerStorageConformance({ describe, it }, "my adapter", async () => {
const db = await openTestDatabase();
return {
store: createMyAdapter<Principal>(db, { owner: (p) => [p.orgId] }),
principals: [{ orgId: "a" }, { orgId: "b" }], // two different owners
close: () => db.close(),
};
});It runs under Vitest, Jest or node:test. The built-in adapters pass the same checks.
The guarantees, in the order to build them
- Every read is scoped to its owner. A chat from another owner is not found, not forbidden. Build the owner key with
encodeOwnerKey(owner(principal))from@intentface/latch-core: it escapes each segment and refuses a missing one, so a principal without auserIdcan't land in a bucket shared with every other one. - Messages keep their order and are replaced by id, never duplicated.
- One running answer per chat. Starting a second fails with the time left on the first.
- Claims are atomic. Of many servers claiming the same run, exactly one wins.
- Leases come from the database's clock, not each server's.
- Stale servers are fenced out. Writes carry the run's fencing number, and a write with an old number is refused.
The storage reference has every method and each guarantee in detail, with what breaks when it's missing.
One schema or database per tenant
If each tenant's data lives in its own schema or database, calls by id are the hard part: updateRun("run_8f3k") doesn't say which tenant's schema the run is in. The runtime solves that by handing back the owner key on every such call, so:
- Make the tenant the first segment of your owner key, e.g.
encodeOwnerKey([tenantId, userId]). - Return the key on every record:
ChatRecord.ownerandScheduleRecord.owneras usual, andRunRecord.ownerKeyon every run, reaped and reclaimed ones included. - Route each call by it:
decodeOwnerKey(scope.ownerKey)[0]is the tenant.Runtime.getRunis the one call that passes{ principal }instead; derive the key from it as your other methods do. - Keep a global table for the two sweeps,
reapExpiredRunsandclaimDueSchedules: active leases and due schedules across tenants, each with its owner key. - Run the checks with
checksOwnerKey: true.
registerStorageConformance({ describe, it }, "per-tenant adapter", async () => ({
store: createTenantAdapter<Principal>(cluster, { owner: (p) => [p.tenantId, p.userId] }),
principals: [{ tenantId: "t1", userId: "u1" }, { tenantId: "t2", userId: "u2" }],
checksOwnerKey: true,
}));