takibi
v0.8.0
Published
Typed multi-tenant collection store on Cloudflare Durable Objects.
Downloads
1,696
Maintainers
Readme
takibi
Typed multi-tenant collection store on Cloudflare Durable Objects — with end-to-end types from typeof handler to createClient, REST-shaped HTTP, tenant isolation, and access control.
npm install takibiimport { createTakibi, fullAccess } from "takibi";
import { createClient } from "takibi/client";The official public API is createTakibi, policy helpers, and errors on
takibi, createClient on takibi/client, and opt-in createWatchClient on takibi/watch.
OpenTelemetry support is distributed separately as
@takibi/opentelemetry. Cloudflare Workers dashboard traces use
@takibi/cloudflare-tracing.
AuthN vs AuthZ
AuthN (who is calling, which tenant they may use) is owned by your application.
AuthZ (what that identity may do to a collection) is owned by takibi
via accessPolicy.
createTakibi()({ resolve }) is the trust boundary. Inside resolve you must:
- Verify a credential, session, or trusted gateway assertion
- Decide the storage partition for this request (from the identity claim and/or an application-approved selector)
- Confirm the caller may use that partition, then return your application context
Do not trust client-declared identity or tenant headers (for example
x-user / x-tenant-id). The library never parses those and does not reserve
tenantId, user, or any other execution-context key. Throw
UnauthorizedError from resolve when authentication or partition selection
fails for the whole request.
When some actions must accept anonymous callers, resolve may return a nullable
identity (for example user: User | null) instead of throwing. Protect
authenticated actions with .use() on the action builder so AuthN failures stay
UNAUTHORIZED / 401 and role checks stay in the gate as FORBIDDEN / 403 — see
Anonymous and protected actions.
Identity is whatever resolve returns. The Durable Object does not re-resolve
or re-verify the caller. Its fetch is only for the same Worker's stub
call; do not expose that class on a public route.
Partition routing is owned by your application in stub. Named and unnamed
objects both accept any serializable context; Takibi does not compare an object
name with tenantId or any other context property. Verify that resolve
authorizes the selected partition and test that stub maps the authorized
context to the intended object. This replaces the old name/context mismatch
guard, which caught some wiring mistakes but was not an authentication boundary.
Existing getByName(resolved.tenantId) routing remains valid. Prefixes and
other application-owned naming schemes are also supported, but changing an
existing object's name selects different Durable Object storage and therefore
requires an application data migration.
Server
createTakibi<Input, Env>()({ resolve, stub?, services? }) creates a framework-independent
handler. Every request supplies its input through handle(request, { context, stripPrefix? }).
resolve infers the execution context; stub receives both the original input and
that resolved context. Env types the Durable Object constructor environment and
the bindings received by services; it does not type context.env. Put Worker
bindings in Input when the stub resolver needs them. Without services or a need
to type the Durable Object constructor environment, createTakibi<Input>() is sufficient.
For empty input use createTakibi() and pass context: {}; with typed bindings use
createTakibi<Record<string, never>, Env>().
For Hono, install @takibi/hono-adapter and mount takibiServer on a
path with app.route(prefix, takibiServer(...)). The application chooses the prefix and supplies request context;
Takibi interprets all collection, action and batch paths beneath it. matched: true
means the handler owns the path, even when an unknown collection or invalid route
produces an error response. The adapter checks the context supplier against the
handler's required input type.
stripPrefix accepts a string or (pathname: string) => string. A string is removed
at a path-segment boundary; paths outside it return matched: false. A function
receives the original percent-encoded pathname and returns the internal pathname
with a leading slash; the adapter owns route matching. Omit stripPrefix to use
the whole pathname. The original Request is preserved in all cases.
Migration: remove factory entry options. Handlers and SQLite test forks expose
handle and DurableObject, without Hono's request or fetch methods. Use
takibiServer({ handler, createContext }) with a Hono application for those methods,
or call handle directly with an explicit context.
services is a synchronous factory ({ env }) => TServices that runs once per
Durable Object instance in the generated constructor. Action handlers receive
the result as services — wire-crossing data stays on ctx, side-effect ports
stay on services. The factory never sees request or resolved ctx.
services is not placed on the Worker → Durable Object wire body, is not
JSON-safe-checked, and is invisible to collection accessPolicy, action
.policy() gates, and .use() guards. Omit services and handler
services.foo is a compile error.
import { UnauthorizedError, createTakibi, fullAccess, grant, read } from "takibi";
import { Hono, type Context } from "hono";
import { takibiServer } from "@takibi/hono-adapter";
import { z } from "zod";
type User = { id: string; role: "admin" | "member"; clinicIds: string[] };
type Initial = {
di: { getSession(request: Request): Promise<User | null> };
env: { TENANT_STORE: DurableObjectNamespace };
};
type AppCtx = { tenantId: string; principal: User | null };
const takibi = createTakibi<Initial>()({
resolve: async ({ request, context }): Promise<AppCtx> => {
const user = await context.di.getSession(request);
const requested = request.headers.get("x-clinic-id"); // optional hint only
const tenantId =
(user && requested && user.clinicIds.includes(requested) ? requested : null) ??
user?.clinicIds[0] ??
null;
if (!tenantId) {
throw new UnauthorizedError("Unknown tenant");
}
return { tenantId, principal: user };
},
stub: ({ context, resolved }) => {
const ns = context.env.TENANT_STORE;
return ns.get(ns.idFromName(resolved.tenantId));
},
});
const memberAccess = grant("create", "get", "list", "update", "delete");
const takibiApp = takibi.defineCollections({
posts: {
schema: z.object({
title: z.string(),
body: z.string(),
}),
accessPolicy({ principal }) {
if (principal?.role === "admin") return fullAccess;
if (principal != null) return memberAccess;
return read;
},
},
});
// `defineCollections` returns an app definition; `app.actions({ ... })`
// registers the maps and returns the handler.
const handler = takibiApp.actions({});
export type Handler = typeof handler;
export class TenantStore extends handler.DurableObject {}
type HonoEnv = { Bindings: Initial["env"]; Variables: { di: Initial["di"] } };
const app = new Hono<HonoEnv>();
// Register your middleware that sets c.var.di before this route.
app.route(
"/foo",
takibiServer({
handler,
createContext: (c: Context<HonoEnv>) => ({ di: c.var.di, env: c.env }),
}),
);
export default app;Durable Object mode needs stub (and usually handle so AuthN / env reach
resolve / stub).
Node integration tests
Use the Node-only takibi/testing entry to exercise a production
handler against an isolated DatabaseSync(":memory:") database. The helper
reuses Takibi's production SQLite storage, migrations, index reconciliation,
seeds, actions, logging, and tracing paths. Each call creates a separate
database.
The returned handler implements Symbol.dispose; use using or call the
method from test teardown when a suite keeps handlers alive for a long time.
import { createClient } from "takibi/client";
import { withSqliteTestBackend } from "takibi/testing";
const handler = withSqliteTestBackend(takibiHandler, {
resolve: ({ request }) => {
const raw = request.headers.get("x-test-user");
const user = raw == null ? null : JSON.parse(raw);
if (user == null) throw new UnauthorizedError("Sign in required");
return { tenantId: "test", principal: user };
},
});
const client = createClient<typeof takibiHandler>("https://fire.test", {
fetch: handler.request,
headers: { "x-test-user": JSON.stringify({ id: "u1", role: "member" }) },
});An explicit resolve replaces the production resolver and defines the returned
handler's input context. A zero-argument resolver, like one that returns a fixed
test context, gives handler.handle() an empty input context. Annotating the
resolver input with { request: Request; context: TestContext } makes
handler.handle() require TestContext instead. The replacement must still
return the production resolved context type.
Omit resolve to keep both the production resolver and its original input
context requirement. Use that when tests call handle(request, { context })
with fake application dependencies.
When createTakibi()({ services }) is configured,
withSqliteTestBackend(handler, { services }) takes the test services
value, not the production factory. The value is required when TServices
is non-empty. Omitting it is a compile error. Each test handler keeps its own
services.
This entry imports node:sqlite and is for Node integration tests only. Do not
import it from Worker or browser code. Workers tests must continue to use a real
SQLite-backed Durable Object when they verify blockConcurrencyWhile, stub
wiring, services factories, Durable Object concurrency, or other runtime
behavior that Node SQLite does not provide.
Breaking migration from memory mode
The { memory: true } option, .with({ memory: true }), and
createMemoryStorage() were removed without a compatibility overload. Migrate
test forks as follows:
// Before
const testHandler = baseHandler.with({ memory: true, resolve, services });
// After
const testHandler = withSqliteTestBackend(baseHandler, { resolve, services });Import withSqliteTestBackend from takibi/testing. That entry
re-exports @takibi/testing. Keep pure
query, policy, and protocol tests storage-free; the helper is for tests that
need storage-backed behavior.
On 2026-08-31, five warm vp test runs on the same development machine had a
0.77 s median before this migration and a 0.74 s median after it. The SQLite
backend therefore introduced no wall-clock regression that requires shared
databases or bypassing the production SQL path.
Collection seeds
Use seed for production defaults. It returns schema inputs keyed by document
ID, so IDs do not need to be repeated inside document data:
const handler = context
.defineCollections({
settings: {
schema: z.object({
bookingUrl: z.string(),
}),
accessPolicy: fullAccess,
seed: () => ({
default: {
bookingUrl: "",
},
}),
},
})
.actions({});Seeds run before the Durable Object accepts requests and before Node SQLite test
backend storage operations. They are create-only: an existing document is never
overwritten, including when a Durable Object is reactivated. Adding another ID
to the returned record creates that default on the next activation. Seed values
are validated by the collection schema and bypass accessPolicy, like trusted
$collections operations.
Unique constraints
Declare named top-level scalar key tuples with unique. Takibi checks them in
the same transaction as every add, set, and update, including public CRUD,
policy-bound collection calls, trusted $collections, seeds, and lazy
migration writes:
const members = context.defineCollection({
schema: z.object({
tenantId: z.string(),
email: z.string().nullable().optional(),
}),
unique: {
byTenantEmail: ["tenantId", "email"],
},
accessPolicy: fullAccess,
});Constraint fields must be distinct top-level string, finite-number, or boolean
fields. A tuple containing a missing or null value does not participate, so
multiple documents may omit email in the example. Empty strings do
participate. Updates automatically exclude their own document ID. Status fields
have no special meaning: archived documents remain constrained unless status is
part of the tuple. Violations return ALREADY_EXISTS (409) and name the
constraint.
Unique checks currently scan the collection inside the write transaction; they do not use a declared read index. Adding a constraint does not eagerly audit untouched existing documents, so clean up historical duplicates before deploying it.
Lazy document migrations
Use migrations when a collection schema changes incompatibly. Each step
converts one stored document version to the next; a document is migrated on
access before it reaches accessPolicy, action code, query filtering, or a
write merge:
type SettingsV0 = { bookingUrl: string };
const handler = context
.defineCollections({
settings: {
schema: z.object({
bookingUrl: z.string(),
reminders: z.boolean(),
}),
migrations: {
// Omit base for 0. The current version is base + steps.length.
base: 0,
steps: [
(data) => ({
...(data as SettingsV0),
reminders: true,
}),
],
},
accessPolicy: fullAccess,
},
})
.actions({});Documents written by the current definition carry a private library version
marker. Existing documents without one are version 0. The marker is not part
of schema input or output, policies, client responses, or where queries.
Treat steps as append-only. To retire old steps, remove them and raise base
to the oldest version still accepted; accessing an older document then fails.
A thrown step or current-schema validation failure leaves the original document
and marker unchanged, so the next access retries the migration. Steps are
synchronous and receive only unvalidated domain data—never id, timestamps, or
the version marker. Before persisting a migrated value, Takibi rechecks the
stored revision and schema version inside the write transaction; if the source
changed, it migrates the latest row instead of overwriting it with stale data.
Migration is per-document and lazy. Takibi does not enumerate tenants, eagerly migrate a whole deployment, report global progress, provide deployment-level rollback/PITR orchestration, or guarantee when inactive tenants finish migrating. Owner-local logical snapshots are described below.
accessPolicy receives doc / nextDoc (schema output plus id / createdAt / updatedAt / rev) so you can authorize on document attributes — not only collection-level actions:
| operation | doc | nextDoc |
| ------------------------ | ----------------------------------- | ---------------------------- |
| add | — | validated create candidate |
| get | saved value | — |
| list | — | — |
| update | saved value | merge + validation candidate |
| delete | saved value | — |
| set | saved value if present, else absent | validated replace candidate |
| invoke (document action) | target document (gate) | — |
Missing get / update / delete never call accessPolicy (NOT_FOUND). Denying get / update / delete / set also returns NOT_FOUND so IDs are not leaked — set uses the same code for a new id and an existing id. Denying create (add) / list returns FORBIDDEN. Denying a document-action gate returns FORBIDDEN when the document exists; a missing id is NOT_FOUND.
Safe local policy evaluation
Compute grants locally from the resolved context, doc / nextDoc, and query.
A local policy may return a Promise; Promise support does not make awaited I/O
safe. Do not perform external HTTP or other awaited I/O in a policy. External
requests can hold the transaction boundary for their entire duration.
Do not await another operation through a root collections facade or storage driver, or fetch/RPC back into the same Durable Object. Root operations queue behind the active transaction: if its policy waits for one of them, neither can finish. These are caller obligations; Takibi does not detect arbitrary I/O or reentry, reject it immediately, or provide a timeout guarantee.
Policy-bound operations other than add use a full transaction boundary.
add can also evaluate policy inside an enclosing atomic action transaction.
Moving I/O into an atomic handler, document guard, or gate is therefore not a
safe preparation step. Keep authorization with the row read, revision checks,
uniqueness checks, and mutation; do not release that boundary to perform I/O.
Prepare external identity and membership inputs in createTakibi()({ resolve,
stub }). Takibi awaits resolve and checks that its result is JSON-safe before
dispatching the invocation and entering its transaction. For example, replace a
policy that awaits loadMembership with this local Promise-returning policy:
import { createTakibi, none, read, UnauthorizedError } from "takibi";
import { z } from "zod";
type Initial = {
authenticate(request: Request): Promise<{ userId: string; tenantId: string } | null>;
loadMembership(userId: string, tenantId: string): Promise<{ canRead: boolean }>;
tenantStore(tenantId: string): { fetch(request: Request): Promise<Response> };
};
export function createPolicyExample() {
const context = createTakibi<Initial>()({
resolve: async ({ request, context }) => {
const identity = await context.authenticate(request);
if (!identity) throw new UnauthorizedError("Sign in required");
const { userId, tenantId } = identity;
const { canRead } = await context.loadMembership(userId, tenantId);
return { userId, tenantId, canRead };
},
stub: ({ context, resolved }) => context.tenantStore(resolved.tenantId),
});
return context
.defineCollections({
posts: {
schema: z.object({ title: z.string() }),
accessPolicy: async ({ canRead }) => (canRead ? read : none),
},
})
.actions({});
}authenticate, loadMembership, and tenantStore are application-owned
helpers supplied through handle(request, { context }). Authentication must
verify credentials and authorize the selected tenant; return null on failure
so the resolver throws UnauthorizedError. tenantStore wraps your namespace's
get(idFromName(tenantId)); that exact-name choice belongs to this application,
not Takibi. Preserve your application's routing inputs in the resolved context.
Only serializable decision data crosses the wire, not the helpers, requests, or
stubs.
The resolved membership is a point-in-time input. It does not freeze the external service's state or guarantee immediate revocation, cross-row authorization, or external consistency. Those requirements need a separate consistency design. Local evaluation also does not guarantee cheap computation or eliminate waiting from asynchronous schema validation.
Grants: fullAccess / write / read / none / grant(...)
accessPolicy returns an AccessGrant — an opaque value for the permissions the subject may perform on this collection / document — not a yes/no for the current request. Build a grant with grant(...) or a predefined grant (fullAccess / write / read / none) and return it from the policy. The executor allows the call when that grant includes the required permission (create / get / list / update / delete / invoke).
| helper | permissions |
| --------------------------------- | ----------------------------------------- |
| fullAccess | create, get, list, update, delete, invoke |
| write | create, update, delete |
| read | get, list |
| none | (empty) |
| grant("create", "get") | the permissions you list |
| grant((g) => [g.create, g.get]) | the same grant, via catalog properties |
A constant grant is valid (accessPolicy: write). Combine or(read, write) for
CRUD without action invocation, and use fullAccess when invoke is also
intended. Prefer returning a grant without switching on permission;
permission stays on the context for logging and policies that need to
distinguish list from get.
Typed policies with context.policy
context.policy keeps reusable accessPolicy functions typed with user from
resolve and, when you pass a schema, types doc / nextDoc inside the
callback. Pass the collection schema or a pick of its fields. A pick-schema
policy assigns to a collection iff those keys exist on the document (optional
vs required does not matter). and / or infer that pick from their arguments.
and intersects grants; or unions them. Import the root functions. Identity
rules and document rules compose:
import { and, fullAccess, none, read } from "takibi";
const staffPolicy = context.policy(({ user }) => (user != null ? fullAccess : none));
const isSeededData = context.policy(itemSchema, ({ doc, nextDoc }) =>
doc?.isSeeded || nextDoc?.isSeeded ? read : fullAccess,
);
const handler = context
.defineCollections({
items: {
schema: itemSchema,
accessPolicy: and(staffPolicy, isSeededData),
},
})
.actions({});Staff can read and write unseeded documents and invoke actions; seeded documents
stay readable. and(staffPolicy, read) is the same pattern with a constant
grant.
Public policy denial reasons
A policy can declare one static, machine-readable reason. Use
{ schema, reason } for a schema-bound policy or { reason } for a
context-only policy:
const seededDataPolicy = context.policy(
{
schema: z.object({ isSeeded: z.boolean() }),
reason: {
code: "SEEDED_DATA_IMMUTABLE",
description: "Seeded data cannot be modified.",
},
},
({ doc }) =>
doc?.isSeeded ? read : grant("create", "get", "list", "update", "delete", "invoke"),
);reason.code stays a literal union through and / or, collection and action
definitions, TakibiResult, and ClientOf<typeof handler>. description is an
optional developer description. Both values are serialized, so they must be
static, public, and free of document data, identity data, secrets, or other
sensitive details. Do not use description as localized UI copy.
When and denies a permission, the first policy in declaration order that
drops that permission supplies the reason. When or denies because every
policy drops the permission, only the first policy supplies the reason. If that
selected policy has no reason, Takibi does not fall back to a later policy.
Existing and / or short-circuit order is unchanged.
Reasons are exposed only on FORBIDDEN failures from add, list, and action
gates. Concealed get, update, delete, and set denials remain NOT_FOUND with no
reason. Missing documents, UNAUTHORIZED, and validation failures also never
carry one. Policies without a reason keep the existing generic failure.
Owner-scoped collections
Ownership is domain-specific policy rather than library metadata. See the owner-scoped collection policy recipe for a policy that prevents owner reassignment and supplies the authorized range for member list/count operations. Bind the scope callback to the document schema:
import { grant, listWhere, none } from "takibi";
import { z } from "zod";
const postSchema = z.object({ ownerId: z.string(), title: z.string() });
type Post = z.infer<typeof postSchema>;
const ownerReadPolicy = context.policy(postSchema, ({ user, operation, doc }) => {
if (!user) return none;
if (operation === "list" || operation === "count") {
return grant(listWhere<Post>((q) => q.ownerId.eq(user.id)));
}
return operation === "get" && doc?.ownerId === user.id ? grant("get") : none;
});Clients supply view filters only; the server ANDs them with the policy range
before index planning, pagination and count. or(adminPolicy, ownerReadPolicy)
can grant unrestricted admin reads. Bare grant("list"), read and fullAccess
remain unrestricted. queryImpliesEquality is available for optional application
proofs, rather than being required for owner-scoped list authorization.
Each listWhere callback uses the existing 32-node/depth-8 query limits;
composed server ranges use 64 nodes/depth 16. Invalid scopes and composition
overflow fail with a safe server error, never unrestricted access.
Cursor v4 binds the requested filter and the reauthorized effective query using an unkeyed SHA-256 digest. It omits the server predicate AST, but the digest is not secret or authentication and indexed tuples still expose returned-row fields. A forged position may skip rows inside the authorized set. Changed effective queries invalidate cursors; equivalent principals with the same AST can reuse them. Old v2/v3 tokens are rejected; restart pagination after upgrading.
Throw UnauthorizedError (or return only after membership checks) from resolve
when AuthN or storage-partition authorization fails for the whole request. When
anonymous actions share the same handler, return a nullable identity from
resolve and use action .use() for per-action AuthN — see
Anonymous and protected actions.
Takibi validates that the resolved context is a JSON-safe object but does not
interpret its keys.
Actions
Use actions for named server-side work that CRUD cannot express. Actions are
defined on the app definition returned by defineCollections:
app.<collection>.actions(cb) for collection actions, app.defineAction()
for root actions. Register every action map with a single app.actions({ ... })
call (root actions go under the reserved $ key) to obtain the handler.
A collection defineAction() starts as a document action: it targets one
existing document, the client passes the target id as the first argument, and
the handler receives { id, doc } without re-fetching. Call .detached()
(before .input() / .policy()) for actions that are not bound to one
existing document — creation, aggregation, no-target pings.
Call .use(fn) only immediately after defineAction() (or another .use())
to refine the handler and gate context — typically to require a signed-in user
when resolve returns a nullable identity. Guards run before document load and
gate evaluation; thrown UnauthorizedError becomes UNAUTHORIZED / 401.
Public actions omit .use() and name an explicit invoke grant. See
Anonymous and protected actions.
const posts = context.defineCollection({
schema: postSchema,
accessPolicy: postPolicy,
});
const app = context.defineCollections({ posts });
const postsActions = app.posts.actions((defineAction) => ({
duplicate: defineAction()
.input(z.object({ title: z.string() }))
.policy(staffPolicy)
.handler(async ({ input, doc, collection }) => {
const { id: _id, createdAt: _c, updatedAt: _u, rev: _rev, ...fields } = doc;
return collection.add({ ...fields, title: input.title });
}),
stats: defineAction()
.detached()
.requires("list")
.policy(staffPolicy)
.handler(async ({ collection }) => {
const page = await collection.list();
return { count: page.items.length };
}),
}));
const exportAll = app
.defineAction()
.policy(staffPolicy)
.handler(async ({ collections }) => ({
posts: (await collections.posts.list()).items,
}));
const handler = app.actions({ $: { exportAll }, posts: postsActions });Every action handler receives collections (CollectionsApi) and
$collections (TrustedCollectionsApi) for every collection. Collection-scope
actions also receive collection (CollectionApi) and $collection
(TrustedCollectionApi) for their own collection. Document actions also
receive { id, doc } — doc is the target document (schema output plus
metadata), fetched before the handler runs. A missing id fails with
NOT_FOUND before the handler.
The TypeScript types follow the same split as runtime. Policy-bound
collection / collections expose document CRUD that evaluates
accessPolicy. Trusted $collection / $collections add
metadata-preserving add, updateMany, deleteMany, consumeOne,
incrementOne, and root $transaction. Those operations are not on the
policy-bound types.
.input(...) takes a Standard Schema, including refinements. Document action
inputs must not contain the target id — the id travels in the path. Input
failures become { kind: "validation", code: "VALIDATION" } with field
issues. Throw a TakibiError subclass from the handler for operation
failures.
Every action has a mandatory gate policy. A document action's .policy()
accepts the same schema-bound policy as that collection's accessPolicy and
evaluates it with the target document
({ operation: "invoke", permission, doc }), so document-attribute rules work
in the gate. A gate callback receives target: { id, doc }. A missing document
is NOT_FOUND. Gate denial on a found document is FORBIDDEN — existence
concealment stays on CRUD get / update / delete / set.
Detached and root actions have no target document: their gate takes a grant, a
context-only policy, or a gate callback (without target) — schema-bound
policies are rejected at the type level and with INVALID_ACTION at
registration. Gate denial is FORBIDDEN.
Normal collection / collections CRUD evaluates each collection's
accessPolicy; $collection / $collections bypasses only that document
policy and never bypasses the action gate. These server-side facades throw
TakibiError on failure.
Add .atomic() before .handler() when all Takibi collection storage
operations in an action must commit or roll back together:
const placeOrder = app
.defineAction()
.input(placeOrderSchema)
.atomic()
.policy(staffPolicy)
.handler(async ({ input, $collections }) => {
const order = await $collections.orders.add(input.order);
await $collections.inventory.update(input.itemId, { stock: input.stock });
await $collections.events.add({ orderId: order.id });
return order;
});For detached and root actions the gate and input validation run before the
transaction. The handler, collection schema validation and storage operations,
void-to-null normalization, and JSON output validation run inside it; they
commit only when all succeed. An atomic document action runs the target
lookup, gate, input parse, and handler inside one transaction, so the doc the
handler receives is consistent with its writes. Any thrown failure rolls back
writes already completed by that atomic action. Actions without .atomic()
keep the normal per-operation behavior, so an earlier write remains after a
later failure.
Atomic handlers execute at most once per call in one invocation attempt, and may not run if admission fails. Neither execution failures nor rollback, commit failure, or an unknown commit outcome trigger transparent replay. A client retry or separate invocation can execute the handler again; this does not guarantee exactly-once external delivery.
Atomic actions cover only operations performed through Takibi's
collection(s) / $collection(s) facades. HTTP requests, email, queue
publishes, and other external side effects cannot be rolled back, even when
they occur inside an atomic handler. Split those effects into a separate action
or use an application-level delivery pattern when they must be coordinated.
The public client is flat; document actions take the target id first:
await client.posts.duplicate("p1", { title: "Copy" }); // document action
await client.posts.stats(); // detached action
await client.exportAll(); // root actionDocument actions use POST {baseUrl}/{collection}/{id}:{name} (the id is
percent-encoded, so an id containing : travels as %3A). Detached collection
actions use POST {baseUrl}/{collection}:{name} and root actions use
POST {baseUrl}/$:{name}. Query parameters are rejected. Input is validated
with Standard Schema; omitted input remains undefined, while JSON null
remains explicit. Outputs must be JSON-safe; void becomes data: null.
Client
createClient<typeof handler>(baseUrl) infers the collection and action maps from the handler
type. Transport is REST-shaped HTTP; the oRPC-style part is that type inference,
not an RPC wire.
| operation | HTTP |
| --------- | ------------------------------------------------------------------------------------ |
| add | POST {baseUrl}/{collection} — caller-chosen id: POST {baseUrl}/{collection}/{id} |
| set | PUT {baseUrl}/{collection}/{id} |
| get | GET {baseUrl}/{collection}/{id} |
| update | PATCH {baseUrl}/{collection}/{id} |
| delete | DELETE {baseUrl}/{collection}/{id} |
| list | GET {baseUrl}/{collection}?limit=&cursor=&where=&index=&orderBy= |
listAll is a client convenience over repeated list calls, not a new HTTP
operation or policy permission. It reuses list (and its grant) page by page,
follows nextCursor, and returns the concatenated documents. Configure the
safety cap on createClient; the call site may only lower pageSize and
maxItems. Remaining documents after maxItems fail with LIST_ALL_LIMIT
instead of truncating.
POST / PUT / PATCH bodies are the document input (not an internal wire request).
GET / DELETE have no body. Worker→Durable Object forwarding stays an internal
JSON POST and is not part of the public HTTP contract.
Opt-in batching of collection reads coalesces nearby get / list calls into one
POST {baseUrl}/_batch. The first queued read starts a fixed window of
maxWaitMs extra wait; later reads in that window do not extend the deadline.
0 waits only until the next timer task. Writes and actions skip the queue and
go to their existing endpoints immediately. They neither flush a pending read
batch nor change its deadline, and Takibi does not guarantee ordering between a
read batch and those immediate requests — wait for the earlier Promise if the
next call depends on it. All items in a batch share the headers captured at
flush, the resolved context, and the tenant. One item's operation failure is
returned to that Promise and does not reject the others. At most 20 reads share
a batch by default. Set maxSize from 1 through 20 to flush earlier; 1
sends every read immediately in its own batch request.
const client = createClient<Handler>("https://localhost:3000/foo", {
batch: { maxWaitMs: 10, maxSize: 20 },
headers: () => ({
Authorization: `Bearer ${getAccessToken()}`,
}),
});Omit batch to keep one REST fetch per call.
Success and failure use the envelope { ok: true, data } / { ok: false, error }.
HTTP status matches error.status on failure (200 on success). This envelope is
the public HTTP response contract.
Carry credentials your server trusts — not self-declared role or membership JSON.
import { createClient } from "takibi/client";
import type { Handler } from "./server";
const client = createClient<Handler>("https://localhost:3000/foo", {
headers: () => ({
Authorization: `Bearer ${getAccessToken()}`,
// Optional routing hint; the server must authorize it inside resolve.
"x-tenant-id": "acme",
}),
});
const created = await client.posts.add({ title: "Hi", body: "..." });
// Or pick the document id yourself:
// const created = await client.posts.add({ title: "Hi", body: "..." }, { id: "post-1" });
if (!created.ok) {
if (created.error.kind === "validation") {
// Field errors for forms: message + path only
for (const issue of created.error.issues) {
console.error(issue.path?.join("."), issue.message);
}
} else if (created.error.code === "ALREADY_EXISTS") {
// add is create-only; use set(id, data) to upsert
console.error(created.error.message);
} else {
console.error(created.error.code, created.error.message);
}
return;
}
const post = created.data;All public client collection methods return Promise<TakibiResult<T>>.
Server-decided failures (NOT_FOUND, FORBIDDEN, VALIDATION, ALREADY_EXISTS, …)
resolve as { ok: false, error } — they do not reject.
For a typed policy denial, keep FORBIDDEN as the operation classification and
map reason.code to client-owned localized copy. Always fall back to the
existing operation message when the reason is absent or unknown:
const result = await client.items.archive(id);
if (!result.ok && result.error.kind === "operation") {
const message =
result.error.code === "FORBIDDEN" && result.error.reason?.code === "SEEDED_DATA_IMMUTABLE"
? t("errors.seededDataImmutable")
: result.error.message;
showError(message);
}Transport / protocol problems still reject the Promise (fetch failure, abort, invalid
JSON, invalid response envelope). Use try/catch only for those.
Document id is not part of the collection schema. Pass domain fields only in data;
use add(data, { id }) when you need a caller-chosen id. add fails with
ALREADY_EXISTS (409) if that id already exists — use set(id, data) to upsert.
Collection schema outputs must be plain JSON objects. Nested plain objects, arrays,
strings, finite numbers, booleans, null, and absent optional fields round-trip
without conversion. undefined, non-finite numbers, bigint, symbols, accessors,
cycles, sparse/custom arrays, and objects such as Date are rejected before the
storage write with INVALID_DOCUMENT. Schemas whose output type is visibly not a
JSON object are rejected by defineCollection; transforms with an unknown output
are checked at runtime.
Every saved document also carries server-managed createdAt / updatedAt (UTC ISO 8601
via Date.prototype.toISOString(), e.g. 2026-08-09T14:12:00.000Z) and rev (a positive
integer). Do not define those fields — or reserved id and $schemaVersion — in the
collection schema. defineCollection rejects those keys at the type level; both input own
properties and schema transforms that emit them fail validation. add and create-via-set
set both timestamps to the same write-time value and start rev at 1; overwrite
set / update keep createdAt, refresh updatedAt, and increment rev even when
field values are unchanged. Same-millisecond writes may share a timestamp. Rows stored
without rev read as 1.
createdAt / updatedAt are observational only — not revisions, ETags, or optimistic
lock tokens. Use rev for that. Include the document's current rev on set /
update to require that generation; a mismatch or a rev-qualified write to a missing
document fails with STALE_WRITE (409) and leaves storage unchanged. Omitting rev
keeps last-write-wins; concurrent successful writes still derive distinct,
consecutive revisions from committed storage. add still rejects rev.
list.where cannot query rev.
Each public or policy-bound collection call is isolated as one operation.
set, update, and delete keep their stored-document read, revision check,
policy decision, validation, unique checks, and mutation in one transaction.
get, list, and count use the same boundary because reads may persist lazy
migrations. Calls made inside an atomic action join its transaction; calls in a
non-atomic action commit independently.
List queries
list.where is a typed AST builder callback, not a JavaScript predicate over
documents. It runs synchronously once in the client or server facade and sends
only the normalized expression to the server:
const page = await client.posts.list({
where: (query) => query.and(query.ownerId.eq(currentUser.id), query.createdAt.gte(yesterday)),
limit: 50,
});
const published = await client.posts.listAll({
where: (query) => query.published.eq(true),
});Every top-level field supports present(), which tests whether the document has
that key; a stored JSON null value is present. Use
query.not(query.optionalField.present()) to match a missing optional field.
Top-level scalar fields support eq; string and number fields also support
gt, gte, lt, and lte. Scalar fields also support in with 1–32
values. String fields support case-sensitive contains, startsWith, and
endsWith; an empty search string matches every stored string. Compose
expressions with and, or, and not:
const page = await client.appointments.list({
where: (query) =>
query.and(
query.status.in(["pending", "confirmed"]),
query.email.endsWith("@clinic.example"),
query.not(query.token.in(revokedTokens)),
),
});Unindexed list results stay in document id order. Filtering happens before
cursor and limit.
Declare named composite indexes on required top-level string / finite number
fields plus id / createdAt / updatedAt. index selects that field order;
it is not a planner hint. Omit index to keep the existing id-ascending scan.
orderBy is allowed only with index, and only for a field of that index.
Fields before the chosen order field must be single-value equalities in where;
otherwise the request is BAD_REQUEST and does not fall back to another scan
or an in-memory sort.
const posts = context.defineCollection({
schema: postSchema,
indexes: {
byOwner: ["ownerId", "createdAt"],
byStatus: ["status", "updatedAt"],
},
accessPolicy,
});
const page = await client.posts.list({
index: "byOwner",
where: (query) => query.ownerId.eq(user.id),
orderBy: (query) => query.createdAt.desc(),
limit: 20,
});Equality prefix plus one range field can narrow the index; remaining where
clauses are residual predicates. limit applies after that filter. Indexed
cursors bind collection, query, index descriptor, order field, direction, and
the last index tuple. Reuse a cursor only with that same request.
Indexed collections backfill existing documents to the current schema when the
index is added or the document schema version advances. That backfill runs
during Durable Object activation and blocks request handling for that tenant
until it succeeds. Writes still go only to takibi_documents; SQLite
expression indexes maintain themselves and add write amplification on those
columns. There is no unindexed orderBy and no automatic index selection.
The SQLite storage implementation compiles predicates to parameterized SQL,
uses the declared expression index for the chosen order, and performs a final
JavaScript check to preserve missing / null / type semantics. The Node test
backend exercises this same storage path. Treat nextCursor as opaque; do not
inspect, modify, or guess cursor values.
Migrating from the previous throw / null API
// Before
const post = await client.posts.get(id); // null when missing
try {
await client.posts.update(id, patch);
} catch (err) {
if (err instanceof TakibiError && err.code === "NOT_FOUND") {
/* ... */
}
}
// After
const result = await client.posts.get(id);
if (!result.ok) {
if (result.error.code === "NOT_FOUND") {
/* ... */
}
return;
}
const post = result.data;get / update / delete use the same NOT_FOUND failure when the document is missing.
set remains upsert and succeeds for a new id.
Migrating to createTakibi
Removed: the createContext shortcut, function shorthand, AuthBits, getTenantId,
getUser, context, and any default parsers for x-user / x-tenant-id.
// Before (trusted client-declared headers — do not keep this)
createContext(({ tenantId, user }) => ({ tenantId, user }));
createContext({
getUser: async (request) => {
/* ... */
},
context: ({ tenantId, user }) => ({ tenantId, user }),
});
// After — one trust boundary for AuthN + partition membership
createTakibi()({
resolve: async ({ request }) => {
const user = await authenticate(request);
const tenantId = await authorizeClinic(request, user);
return { tenantId, principal: user };
},
});Durable Object collections
Inside the DO (trusted / admin path, accessPolicy bypassed):
const post = await this.$collections.posts.add({ title: "Hi", body: "..." });
const pending = await this.$collections.posts.count({
where: (query) => query.status.eq("pending"),
});Use $transaction on the trusted collections facade to commit or roll back
mutations across collections as one storage transaction:
await this.$collections.$transaction(async ($collections) => {
await $collections.orders.add(order);
await $collections.inventory.update(itemId, { stock });
});Nested $transaction calls join the enclosing transaction. Each supplied callback
executes at most once per call in one invocation attempt (zero times if admission
fails), including nested callbacks. Failures and unknown commit outcomes do not
trigger transparent replay. External effects may already have happened on failure,
and separate invocations or client retries can repeat them. See the
transaction contract.
The root facade also owns transport-neutral logical snapshot and reset operations:
const stream = await this.$collections.$exportSnapshot();
await env.BACKUPS.put(objectKey, stream);
const object = await env.BACKUPS.get(objectKey);
if (object === null) throw new Error("Snapshot not found");
const report = await this.$collections.$restoreSnapshot(object.body);
await this.$collections.$resetAll();These methods do not appear on transaction callbacks, action $collections,
policy-bound APIs, public clients, or the Durable Object RPC method surface.
Export is pull-driven NDJSON and preserves stored timestamps, revision, and
document schema version. Restore validates the complete stream in local staging
before atomically replacing live Takibi documents. During export, restore, and
reset, normal reads and writes fail with retryable MAINTENANCE_LOCKED status 503.
Takibi does not choose the object store, key, encryption, retention policy, or
maintenance endpoint authorization. A full snapshot can contain password
hashes and session tokens. See
@takibi/snapshot logical snapshot spec
for the format, lease, compatibility, and atomicity contract.
Server-side policy-bound collections and trusted $collections expose
count, which pages through the same query, index selection, and migration
transforms as list. A policy-bound count requires the list permission.
count is not available on the public HTTP client.
Trusted $collections additionally expose atomic conditional writes:
await this.$collections.posts.updateMany(
{ archived: true },
{ where: (query) => query.ownerId.eq(ownerId), index: "byOwner" },
);
await this.$collections.sessions.deleteMany({
where: (query) => query.userId.eq(userId),
});
const token = await this.$collections.verifications.consumeOne({
where: (query) => query.value.eq(value),
});
const counter = await this.$collections.counters.incrementOne(
{ attempts: 1 },
{
where: (query) => query.id.eq(id),
set: { lastAttemptAt: new Date().toISOString() },
},
);The where clause is mandatory. Bulk operations process every match;
consumeOne and incrementOne use the first document in normal list order.
They join an enclosing trusted/action transaction or open one when called
directly. These methods bypass collection policy and are unavailable through
policy-bound collections, the public client, and HTTP.
Documents are stored with state.storage.sql in one library-managed
takibi_documents table shared by all collections in the Durable Object. Domain
fields are JSON text; collection, id, timestamps, and document schema version are
separate columns. The server-managed rev is also stored in a dedicated REAL NOT
NULL column, not in the domain JSON, so revisions above JavaScript's safe-integer and
SQLite's signed 64-bit integer boundaries retain their IEEE-754 value. Takibi does not
create a table or columns from each application schema, and applications do not manage
or query this internal table.
Auto-generated document ids are monotonic ULIDs (26 Crockford Base32 characters). Caller-supplied ids are still accepted; creation-order lexicographic sort is guaranteed only for library-generated ULIDs.
Takibi versions its internal SQL layout in takibi_metadata and migrates known
layout versions synchronously during activation. A newer unknown layout fails closed.
Layout version 2 moves a valid version 1 data.rev value into the revision column,
defaults a missing or invalid legacy value to 1, and removes rev from the JSON.
The table rebuild and layout-version update are one transaction.
Layout version 4 adds the Takibi-owned maintenance lease and restore staging
tables without changing existing documents or indexes.
This internal layout migration is separate from collection migrations: layout
migrations change Takibi's tables, while collection migrations lazily transform one
domain document after it is read.
Wrangler
Register a Durable Object class with SQLite storage:
{
"durable_objects": {
"bindings": [{ "name": "TENANT_STORE", "class_name": "TenantStore" }],
},
"exports": {
"TenantStore": {
"type": "durable-object",
"storage": "sqlite",
},
},
}Notes:
- Select named or unnamed Durable Objects inside
stubafterresolvehas authenticated the caller and authorized the partition. Takibi treats object names and resolved context as application-owned values and does not compare them.fetchon the class is stub-only — do not route public HTTP to it. - The root
takibiimport does not requirenodejs_als,nodejs_compat, or a minimum compatibility date.
Observability
Logging and tracing are separate signals. Both are off by default. Configure a
logger on createTakibi(), defineCollections(), or
withSqliteTestBackend(); the more local setting wins field by field:
import { createPrettyConsoleLogger, createTakibi } from "takibi";
import { withSqliteTestBackend } from "takibi/testing";
const takibi = createTakibi()({
resolve,
stub,
logger: createPrettyConsoleLogger(),
logLevel: "debug",
});
const production = takibi.defineCollections(definitions, { logLevel: "info" }).actions({});
const silent = takibi.defineCollections(definitions, { logger: false }).actions({});
const captured = withSqliteTestBackend(production, { logger: testLogger });logger: true sends the LogEvent object directly to the matching
console.debug / info / warn / error method so platform structured fields
are preserved. Supplying only logLevel enables the same structured console
logger. The default level for an enabled logger is info;
debug < info < warn < error. createPrettyConsoleLogger() is a dependency-free
single-line formatter for local development, not a production structured logger
or an OpenTelemetry exporter. ANSI colors are off unless { colors: true } is
explicitly passed, and it does not add a timestamp. Logger failures are ignored
and never change a request result.
Takibi logs request boundaries and failures at info / error, and emits
debug timing events for resolve, Worker → Durable Object wire, executor,
policy, schema, storage, and actions. A failure log uses the public error
message (not a generic "request failed") and includes the HTTP method and
path so decode-time errors are diagnosable without opening a trace. Events
can contain only operation metadata: collection, operation, document ID,
HTTP method/path, duration, error code/status, the normalized list query
AST, and batch size for batched reads. They never contain documents, action
input or output, resolved context, request/response bodies or headers, cookies,
credentials, stubs, or bindings.
A query comparison value can still be a name, phone number, or other personal
data. Restrict access to debug logs and retain them only briefly.
Install @takibi/opentelemetry to enable OpenTelemetry spans and,
optionally, map permitted LogEvent values to OpenTelemetry Logs. The
integration package owns its OpenTelemetry peer dependencies, adapters, setup
documentation, and tests; the core package has no OpenTelemetry dependency.
Install @takibi/cloudflare-tracing to map the same Takibi spans onto
Workers native enterSpan for the Cloudflare dashboard. That adapter does
not require nodejs_compat. Do not enable both adapters in one isolate.
Export logs and traces to the same observability backend when you need native
trace-log correlation. logger: true writes structured console output only; it
does not export OpenTelemetry logs. See the integration package README for
providers, context management, correlation, and Workers flushing.
Takibi core owns span semantics as well as span placement. takibi.wire is a
client span, a Durable Object takibi.executor is a server span, and local
executor, policy, schema, storage, and action work is internal. Relevant spans
carry only operation metadata: takibi.collection.name,
takibi.operation.name, takibi.action.name, takibi.action.scope,
takibi.storage.operation, takibi.batch.size on batched reads, and, when
available, takibi.document.id. They do
not include document contents, resolved context, request headers, or action
input/output. Core also decides exception normalization and error status; the
integration package only maps that structural contract to OpenTelemetry.
takibi.wire is a transport span. It covers the Durable Object fetch, full
response-body read, JSON decoding, and wire-envelope validation. Fetch or body
failures and malformed responses mark it as an error. A valid { ok: false }
envelope, including one received with a non-2xx status, is a successfully
received remote-operation result and leaves the wire span successful; the
Durable Object's executor, policy, schema, storage, or action span records that
operation failure.
Limits and layout
Each stored document is limited to 2 MB.
get/update/listalways read or write the whole document value. There is no field projection or partial array read.Growing collections belong in child collections (for example
postItemswith a parent id field), not as unbounded arrays embedded in a parent document. Keep embedded arrays small and bounded.listreturns full documents. Withoutindexthe order is id ascending. Withindex, order follows the declared field tuple (andorderByon that index). It does not offer unindexedorderBy, offset, or projection. Each page defaults to 50 documents and is capped at 200.listAllwalks those pages (default page size 200) and stops at a client safety cap of 10_000 documents unlesscreateClient({ listAll })or the call site sets a smallermaxItems.whereremains an arbitrary boolean AST. Indexed lists scan the selected index in its declared order and apply residual predicates beforelimit. Unindexed lists may still scan the collection.
Watch list snapshots
Opt in through takibi/watch. createWatchClient composes the ordinary HTTP
client with collection watches; takibi/client remains independent of the
WebSocket runtime. No registration or external WebSocket library is required.
import { createWatchClient } from "takibi/watch";
import type { handler } from "./server";
const client = createWatchClient<typeof handler>("https://example.com/api/takibi");
const subscription = client.posts.watch(
{
index: "byRoomCreatedAt", // declared as ["roomId", "createdAt"]
where: (q) => q.roomId.eq(roomId),
orderBy: (q) => q.createdAt.desc(),
limit: 50,
},
{
next: ({ items }) => renderPosts(items),
state: (state) => renderConnectionState(state),
},
);
// HTTP methods remain available on the same client.
const page = await client.posts.list({ limit: 50 });
subscription.unsubscribe();
const outcome = await subscription.closed; // { reason: "unsubscribed" }Types and UI ownership
The observer's items are inferred from the server collection, including document
metadata. When a separate render function or component field needs a named type,
derive it from the handler instead of copying the schema:
import type { InferCollectionDoc, InferHandlerCollections } from "takibi";
import type { WatchClientOf, WatchState } from "takibi/watch";
import type { handler } from "./server";
type Post = InferCollectionDoc<InferHandlerCollections<typeof handler>["posts"]>;
type PostsSubscription = ReturnType<WatchClientOf<typeof handler>["posts"]["watch"]>;
type ConnectionStatus = WatchState | "disconnected";WatchSubscription<TCode> and WatchClosed<TCode> take a policy reason code,
not a document type. Deriving the subscription from the collection preserves its
policy reason literals; writing WatchSubscription<string> widens those literals.
It does not change the closed.reason discriminator.
state reports connection attempts and connectivity, not subscription completion.
open means the socket is connected; wait for next to show the initial data.
Observe closed separately to display terminal status:
void subscription.closed.then((outcome) => {
if (outcome.reason === "unsubscribed") return; // intentional UI cleanup
renderConnectionState("disconnected");
if (outcome.reason === "server-error" || outcome.reason === "protocol-error") {
showError(outcome.error.message);
}
});
// On unmount or before replacing the subscription:
// subscription.unsubscribe();A UI-only disconnected state is a normal projection of closed, not a value
emitted by state. Retryable failures keep closed pending; a terminal outcome
requires a new watch call to retry. When switching rooms or replacing a view,
unsubscribe the old handle and guard its callbacks and closed.then with the
view's current generation. Unsubscribe suppresses future observer callbacks but
the old handle's completion promise still settles.
Named Durable Objects and testing
For one Durable Object per room, validate the room in the Worker, pass it in the
initial context, and select namespace.get(namespace.idFromName(resolved.room))
in stub. Mount the handler at /api/${room} and use that same absolute HTTP(S)
base URL for createWatchClient. Both HTTP writes and watch handshakes use the
stub resolver. resolve may simply project validated input into JSON-safe context;
Worker bindings stay in the initial context rather than being sent to the object.
See the realtime chat example for this arrangement,
including newest-first snapshots reversed for display.
withSqliteTestBackend creates one independent SQLite store per handler and
bypasses stub; it does not emulate named Durable Object routing or WebSocket
upgrades. Two SQLite handlers demonstrate independent storage, not that production
room routing is correct. Test the production handler with a namespace test double
to verify idFromName/get, and use the Workers runtime to verify actual object
isolation and watch delivery. Overriding resolve currently retains the production
input context type, so it does not remove required Worker bindings from handle.
Snapshot semantics
The handle is returned synchronously; callbacks start asynchronously. Options
have the same schema inference, filter compilation, limits (default 50, maximum
200), declared indexes, equality-prefix rules, direction, suffix order, and id
tie-breaking as list. Without an index, results are id-ascending. Watch has no
cursor or nextCursor. Every delivery is a full { items } snapshot; the
client suppresses consecutive identical serialized results, including after a
reconnect. Inserts, deletes, and ordering-field changes re-run the ordered,
limited query, so items can enter or leave the window.
Initial delivery and every refresh use the existing list policy and its
policy-owned range. Successful commits invalidate the changed collections in
that Durable Object. Atomic actions and trusted $collections.$transaction
publish after the outer commit; rollback and unknown commit outcomes do not
publish. Public CRUD, policy-bound actions, trusted writes, and generated
Durable Object facades share this path. Lazy migration alone is not an
invalidation; direct state.storage.sql writes are outside this contract.
Multiple writes outside a transaction may expose intermediate snapshots.
Authentication and connection lifetime
The browser uses native WebSocket GET upgrades on the collection route. Normal
GET remains list. Every handshake runs your existing resolve and stub,
including reconnections; partition routing makes no assumptions about context
field names. Only JSON-safe resolved context and normalized query options go
to the chosen Durable Object. Its internal upgrade endpoint must remain private.
Use same-origin cookies, or supply webSocketProtocols: async () => [token] to
createWatchClient. The callback runs on every attempt, allowing credential
refresh. Tokens must obey browser WebSocket subprotocol syntax: nonempty,
unique HTTP tokens, with no spaces, commas, or padding characters such as =.
The application verifies its credential format in resolve by reading the
original Sec-WebSocket-Protocol header. Takibi offers and selects the reserved
takibi.watch.v1 protocol; do not return that token from the callback. Cookie
handshakes require an Origin exactly matching the public request origin.
Cross-origin cookie watches are not supported; HTTP CORS middleware does not
authorize WebSocket origins.
headers configures HTTP methods only. Its values are never copied into watch
URLs, protocols, or attachments. Never put authentication values in query
filters or the base URL. Resolved context is a persisted JSON snapshot from the
handshake, not live identity-provider state. Resolve identity and expiry into
credential-free fields, and have list policy check expiry using those trusted
local inputs. Expiry is checked before the next snapshot, not by an idle
socket deadline timer. Denial closes terminally without delivering that
snapshot. Policy performs no external I/O; external revocation or role changes
require fresh resolution on a new handshake. Takibi cannot identify raw
credentials hidden in arbitrary application context, so the application must
exclude them. HTTP headers themselves are never persisted by the watch transport.
state reports connecting, open, and reconnecting. Abnormal network closes,
opaque browser handshake failures, and maintenance closures retry with jittered
exponential backoff (250 ms cap initially, growing to 30 seconds). An opaque
handshake failure cannot reliably be classified as a typed policy error.
closed always fulfills exactly once with unsubscribed, server-error,
protocol-error, or server-closed; retryable failures leave it pending.
server-error retains the server's failure and typed policy reason. Normal
server closes and terminal errors never reconnect. Repeated unsubscribe is safe
and synchronously suppresses subsequent callbacks, pending credential results,
and retries. Observer exceptions go to the host's reportError hook (or an
asynchronously thrown error on hosts without it), without ending subscriptions.
Hibernation uses acceptWebSocket, getWebSockets, and versioned attachments;
there is no SQLite subscription registry. Reactivation validates attachments
and refreshes recovered queries. Invalid or obsolete versions close terminally.
Restore/reset disconnect existing watches and reject handshakes during
maintenance; reconnect obtains a new full snapshot after cutover. Export
suspends queries and flushes pending invalidation after lease release.
As checked on 2026-09-17, Cloudflare documents a 16,384-byte serialized attachment limit, a [32,768-connection ceiling per o
