# @odla-ai/db

> ⚠️ **Early access — pre-1.0.** Agents work from bounded runbooks; humans
> approve credentials, production changes, releases, and merges. APIs and exact
> package availability can change. Review the documented guarantees and
> limitations; this software is MIT-licensed and provided without warranty.

The official clients for **odla-db** — a realtime, graph-shaped database on
Cloudflare. The package has separate entry points for trusted administration,
permission-governed realtime applications, and Preact bindings:

- `@odla-ai/db` — backend/admin HTTP client and schema tooling.
- `@odla-ai/db/client` — WebSocket subscriptions, optimistic writes, presence,
  and end-user authentication.
- `@odla-ai/db/preact` — `OdlaProvider`, `useQuery`, and `useTransact`.
  The old `/react` subpath is a deprecated alias to this same Preact build.

- **Isomorphic transport** — runs in Node 20+, Cloudflare Workers, and the
  browser. The root admin entry still belongs only in trusted backends because
  its full application key bypasses rules.
- **Self-contained** — zero runtime dependencies; the wire protocol, the `tx`
  builder, and the schema builder are bundled in.
- **Graph-native** — entities are nodes, typed `link`s are edges, and `Lookup`
  refs give you upsert-by-natural-key (`MERGE`) for re-ingestion-safe writes.
- **Default-deny permissions** — end-users (and rules-scoped credentials) can't
  read or write a namespace until its CEL rules are set; a missing namespace or
  action means "no". Full app API keys bypass rules (trusted backends only).
- **Provenance on every node** — the server maintains `$createdAt` / `$createdBy`
  / `$updatedAt` / `$updatedBy` from the *verified* writer identity. `$`-prefixed
  attrs are reserved: client writes to them are rejected.
- **Runtime-neutral federation signing** — `@odla-ai/db/federation` signs and
  verifies one app-to-app HTTP request with Web Crypto. The envelope binds the
  asserted sender, timestamp, nonce, method, path/query, and exact body digest;
  callers exchange neither browser sessions nor database application keys.

> **Ask the runbooks first.** odla's operational procedures live in a database,
> not in this file: `npx @odla-ai/cli runbook ask "<question>"` returns the current
> steps, and unlike anything written here it cannot be out of date. Use it before
> searching the web or working from memory. This README and the JSDoc in the
> shipped `.d.ts` are the version-matched **API** reference; a runbook is the
> **procedure**. Most tasks need an answer from both.

## Install

```bash
npm i @odla-ai/db
```

## Use

```ts
import { initAdmin, tx } from "@odla-ai/db";

const db = initAdmin({
  appId: "myapp",
  adminToken: process.env.ODLA_SK!,          // an app API key: odla_sk_...
  endpoint: process.env.ODLA_URL!,           // your odla-db worker, e.g. https://<worker>.workers.dev
});

// write
await db.transact(
  tx.notes[crypto.randomUUID()].update({ text: "hi", createdAt: Date.now() }),
);

// read
const { notes } = await db.query({ notes: { $: { order: { createdAt: "desc" } } } });
```

Every query node returns at most 100 rows unless it sets `limit`; the maximum is
1,000. Queries are also bounded to six relation levels, 32 nodes, 100 where
nodes, a 512-character search string, and a 10,000-row candidate scan. Add an
indexed filter and paginate instead of relying on an unbounded materialization.
Encoded results and aggregate/query materialization are capped at 1 MB;
aggregates have the same 10,000-row scan guard. `$like` is deliberately
indexable and case-sensitive: use an exact literal (`"Ada"`) or one trailing
wildcard (`"Ada%"`). Leading/interior `%` and `_` patterns are rejected; use
full-text `search` for substring/token search. Per-entity full-text documents
index at most 1 MB of text fields in attribute-name order.

For a direct browser or end-user connection, use a signed-in user token. Never
expose an `odla_sk_` application key:

```ts
import { init } from "@odla-ai/db/client";

const db = init({
  appId: "myapp",
  endpoint: "wss://db.odla.ai",
  getToken: () => auth.getToken(),
});

const unsubscribe = db.subscribeQuery({ notes: {} }, ({ notes }) => render(notes));
```

That direct connection is safe when the browser presents a signed-in user's
JWT and the app's default-deny CEL rules intentionally grant that user the
requested rows. It is not a reason to put an `odla_sk_` application key in the
browser: a full app key bypasses rules and belongs only in a trusted backend.

### Worker-authorized realtime without a browser database token

An app that keeps authorization in its Cloudflare Worker can expose a
same-origin WebSocket route with `AdminDb.realtime.proxy()`. The Worker first
authenticates its own application session, then grants that connection an exact
list of read queries. The app key stays inside the Worker. After the handshake,
the database AppDO owns the hibernatable socket to the browser; the Worker does
not need to keep a relay socket or invocation alive.

```ts
import { initAdmin } from "@odla-ai/db";

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);
    const livePath = `/db/app/${env.ODLA_APP_ID}/connect`;
    if (url.pathname !== livePath) return new Response("Not found", { status: 404 });

    // Use your app's normal cookie/session verifier before authorizing data.
    const session = await appAuth.verifyRequest(request, env);
    if (!session) return new Response("Unauthorized", { status: 401 });

    const db = initAdmin({
      appId: env.ODLA_APP_ID,
      adminToken: env.ODLA_ADMIN_TOKEN,
      endpoint: env.ODLA_DB_URL,
    });
    const ownNotes = {
      notes: {
        $: {
          where: { ownerId: session.userId },
          order: { createdAt: "desc" },
        },
      },
    } as const;

    return db.realtime.proxy(request, {
      allowedOrigins: ["https://app.example.com"],
      queries: [ownNotes],
      expiresInSeconds: 300,
    });
  },
};
```

The helper requires the request's `Origin` to be an exact member of
`allowedOrigins`; wildcards and a missing `Origin` are rejected. It ignores the
incoming path, query string, and route `appId` when choosing a database, and
always targets the app configured on that `AdminDb`. Keep the Worker route
exact as an additional boundary. One grant may contain at most 16 exact queries
with an approximately 4 KiB serialized allow-list, and at least one query is
required. `expiresInSeconds` may be 1–300 seconds and defaults to 300.
Because the Worker leaves the frame path after the `101` upgrade, that TTL is
also the maximum delay before an app-session or app-key revocation takes effect;
choose a shorter value for especially sensitive data.

The browser uses the ordinary reactive client against the same origin and does
not provide `getToken`:

```ts
import { init } from "@odla-ai/db/client";

const ownNotes = {
  notes: {
    $: {
      where: { ownerId: currentUser.id },
      order: { createdAt: "desc" },
    },
  },
} as const;

const db = init({
  appId: "myapp",
  endpoint: `${location.origin.replace(/^http/, "ws")}/db`,
  // No getToken: the same-origin Worker authorizes the WebSocket handshake.
});
const unsubscribe = db.subscribeQuery(ownNotes, ({ notes }) => render(notes));
```

The browser's subscription query must exactly match one of the queries the
Worker granted. A query returns the complete matching entities, so authorize
every field on every matched root and relation row—not just the fields used by
its `where` or `order`. This path deliberately bypasses CEL view rules; the
Worker's exact query is the authorization boundary. Do not mix fields with
different sensitivity on an entity exposed by a proxy grant.
This proxy is read-only: only the exact authorized query subscriptions work.
It cannot transact, join presence/rooms, subscribe to reserved namespaces, or
expand a query after the connection is open. Every reconnect returns through
the Worker, revalidates the app session and `Origin`, and creates a new bounded
grant. For mixed or dynamic per-user access, use the normal signed-user-JWT
connection under default-deny CEL rules. If all data must remain behind the
Worker, use the server-side `changes()` cursor and stream authorized results to
the browser over the app's own SSE route.

```tsx
import { OdlaProvider, useQuery } from "@odla-ai/db/preact";
```

## Getting a token (for agents) — no secret required

You need an odla-db credential to read/write. An **agent** should never be handed the
platform admin secret. Instead, do a **device-authorization handshake** at the
odla platform: name the existing account by email, show that human a short code,
and wait for the same signed-in account to explicitly review and approve it in
`odla.ai/studio`. The email is a non-secret identity hint; never ask for a
password, Clerk session, or other human credential. Approval registers a named
agent principal and returns a tracked, revocable, short-lived developer token
bound to it. The token does not inherit the human's projects or platform-admin
role: the human separately grants exact capabilities on exact projects. A
repeat device handshake without `agentHandle` registers a new agent principal.
Requesting the same stable handle again and receiving human approval reconnects
the replacement credential to the existing principal; the handle is covered by
the same immutable digest as the project/capability request.

```ts
import { requestToken, initAdmin } from "@odla-ai/db";

const platform = "https://odla.ai";
const endpoint = "https://db.odla.ai";

const { token } = await requestToken({
  endpoint: platform,
  email: process.env.ODLA_USER_EMAIL!,
  agentHandle: "customer-app",
  projectIds: ["my-app"], // exact request; approval cannot rewrite it
  // Required only to create/configure this app or administer its tenants.
  // Baseline collaboration handshakes omit this optional capability.
  optionalProjectCapabilities: ["app.manage"],
  onCode: ({ userCode, verificationUriComplete }) =>
    console.log(`Review ${userCode}: ${verificationUriComplete ?? "https://odla.ai/studio"}`),
});

// `token` (odla_dev_…) can act only on explicitly approved project ids. If
// `my-app` is absent, this approved app.manage request may create that exact app
// once; Registry then binds the grant to the new app incarnation. It cannot
// create another project or perform ownership/lifecycle mutations.
const db = initAdmin({ appId: "my-app", adminToken: token, endpoint });
```

Under the hood: `POST https://odla.ai/handshake` with
`{ email, projectIds, optionalProjectCapabilities, ... }` → show
`userCode` → the named user signs in and explicitly claims that exact code →
poll `POST https://odla.ai/handshake/poll` with the returned `deviceCode` until
the approval is collected once. Unknown and never-signed-in accounts do not
create a claimable request, and their public response is deliberately
indistinguishable from a valid start to prevent account enumeration. Only one
claimed pending or approved-but-uncollected request may be active per account.
Studio shows the immutable request and its fingerprint. The owner either
approves it exactly or declines it with feedback returned to the polling
client as `handshake_denied`; changing access requires a fresh request. An
approved project id may be new: collection records a one-time exact-id
reservation, and app creation consumes it while binding the grant to the new
app incarnation. `app.manage` is an explicit provisioning opt-in, not a
baseline grant. The reservation grants no unrelated app administration;
existing archived or foreign ids remain unavailable.
The constants above are the hosted odla defaults. For a
self-hosted or alternate deployment, supply its platform and database endpoints
through configuration rather than hard-coding either URL in application logic.
Hosted odla-db treats the platform registry as the credential authority: an
unknown developer token is rejected and never falls back to a stale local token
row. A self-hosted deployment without that registry may retain the local
compatibility resolver, but local manager metadata still grants no project
access.

### Permission rules (required before end-users can touch data)

odla-db is **default-deny**: until you install rules, end-users see nothing and
every end-user write is rejected with `permission_denied`. Install per-namespace
CEL rules with a signed-in human operator, the platform machine, or the exact
owner-approved `app.manage` credential used by provisioning (this replaces the
app's rule set). A baseline collaboration credential cannot rewrite rules:

```ts
await fetch(`${endpoint}/app/${appId}/admin/rules`, {
  method: "POST",
  headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
  body: JSON.stringify({
    notes: {
      view: "auth.id == data.ownerId",     // read own rows
      create: "auth.id == data.ownerId",   // may only create rows they own
      update: "auth.id == data.ownerId",
      delete: "false",                     // nobody deletes
    },
  }),
});
```

Rule context: `auth.id/email/signedIn/entitlements/user/claims`, `auth.kind`
(`"user" | "agent" | "admin"`), `auth.agent` (`{ id, label }` for scoped keys),
`data.<field>`, `newData.<field>`, `ref('label.field')`. Reserved namespaces
default to own-rows-only reads (`$users`, `$entitlements`, `$subscriptions`) or
fully closed (`$files`).

### Scoped keys — the right credential for an agent

A full `odla_sk_` key bypasses rules. A signed-in human, the platform machine,
or an exact owner-approved `app.manage` provisioning credential may mint the
app's deployment key. A human or machine may instead mint a key for an active,
project-granted agent and pass `scopes` so it is **governed by the rules**
(`auth.kind == "agent"` in CEL), optionally read-only and/or restricted to
specific namespaces. A baseline agent credential cannot mint a successor. Once
a key has a scope object, vault access is also default-deny; list the exact
secret names it needs in `secrets` (or deliberately grant `"*"`):

```ts
await fetch(`${endpoint}/admin/apps/${appId}/keys`, {
  method: "POST",
  headers: { authorization: `Bearer ${operatorToken}`, "content-type": "application/json" },
  body: JSON.stringify({
    label: "kanban-bot",
    scopes: {
      mode: "rules",
      agentId: "agent_palette_scout",
      namespaces: ["cards", "columns"],
      secrets: ["anthropic_api_key"],
    },
  }),
});
```

Scoped credentials are data-plane only: they can never edit rules, mint keys, or
act as operators. A rules/read-only/namespaced key without `secrets` receives
`403 missing_capability` from `db.secrets.get()`.

### Graph writes — upsert by natural key + link

`Lookup` refs (`{ ns, attr, value }`) resolve to an existing entity by a unique
attribute, or create one — so re-discovering the same node is idempotent. Emit
raw `Op` literals for full control (node `update`s first, then `link`s):

```ts
await db.transact([
  { t: "update", ns: "company", id: { ns: "company", attr: "slug", value: "acme" }, attrs: { name: "Acme" } },
  { t: "update", ns: "person",  id: { ns: "person",  attr: "slug", value: "ada"  }, attrs: { name: "Ada Lovelace" } },
  { t: "link",   ns: "company", id: { ns: "company", attr: "slug", value: "acme" }, label: "founded_by",
    target: { ns: "person", attr: "slug", value: "ada" } },
], { mutationId: "ingest:acme-foundedby-ada" });   // stable id => exactly-once
```

### Author & push a schema

```ts
import { i } from "@odla-ai/db";

const schema = i.schema({
  entities: {
    company: i.entity({ slug: i.string().unique(), name: i.string() }),
    person:  i.entity({ slug: i.string().unique(), name: i.string() }),
  },
  links: {
    founded: {
      forward: { on: "company", has: "many", label: "founded_by" },
      reverse: { on: "person",  has: "many", label: "founded" },
    },
  },
});

await fetch(`${endpoint}/app/${appId}/schema`, {
  method: "POST",
  headers: { authorization: `Bearer ${odlaSk}`, "content-type": "application/json" },
  body: JSON.stringify({ schema: schema.serialize() }),
});
```

### Porting relational code — semantics that differ from SQL

Field notes from porting a production Postgres app (a membership flow) onto
odla-db. Each of these is a silent behavior difference; design for them up
front rather than discovering them in production:

- **Entity ids are not attributes.** `where: { id }` matches nothing. If you
  query rows by id (every `SELECT ... WHERE id = $1` port does), mirror the id
  as a unique attr on each row:
  `{ t: "update", ns, id: rowId, attrs: { id: rowId, ... } }`. Query results
  are unchanged — hydration returns the same `id` either way.
- **Null depends on the declared type.** JSON attributes can preserve JSON
  `null`; a typed scalar attribute rejects it in strict schema mode. Omit an
  optional scalar on write when absence is the intended state. To CLEAR a
  scalar that was already written, use the `retract` op —
  `tx.things[id].retract(["dueAt", "ownerId"])`, the scalar sibling of
  `unlink`. It removes those attributes' triples (a no-op when already absent)
  and fails the transaction only if it would leave a required attribute missing.
- **Always pass `order` on list queries.** Unordered results sort
  lexicographically by entity id: creation order for `uuidv7()` ids, arbitrary
  for anything else.
- **Pagination is bounded.** Omitted `limit` means 100; explicit limits may be
  0–1,000 and offsets 0–100,000. Each nested relation node is bounded too.
- **Realtime state is bounded.** One socket may retain 32 subscriptions, and
  its total hibernation state (identity, subscriptions, and presence) must fit
  16 KiB.
  Excess operations fail as `subscription_limit` or
  `session_state_too_large` before registering state. The server retains at
  most 4 MB of prior diff snapshots per app Durable Object; subscriptions over
  that budget continue with full `query-result` snapshots instead of diffs.
- **Wire bodies are bounded.** JSON HTTP bodies are stream-read; both HTTP
  bodies and WebSocket messages cap at 1.1 MB (`request_too_large` /
  `message_too_large`). Query results have the stricter 1 MB encoded cap.
- **Uniqueness is single-attr.** A composite SQL unique becomes a derived
  unique attr (for example `group_email: "<groupId>|<email>"`); a violation
  aborts the whole transact with `unique_violation`, exactly like an
  ON CONFLICT abort in a SQL transaction.
- **A multi-op `transact` is atomic** (one Durable Object transaction), so
  batch a change and its audit row together. There is no
  `SELECT ... FOR UPDATE`: bind a read to its write with `guards`, which check
  existence, selected field values, and explicitly absent legacy fields inside
  the same transaction. A failed guard aborts every op with
  `transact_guard_failed`. Pair that compare-and-swap check with a stable
  `mutationId` for exactly-once retries:

  ```ts
  const { duplicate } = await db.transact(ops, {
    mutationId: `edit:${id}:${reviewed.version}`,
    guards: [{
      ns: "documents",
      id,
      exists: true,
      equals: { version: reviewed.version },
      absent: ["supersededAt"],
    }],
  });
  const fresh = !duplicate; // first delivery: this call did the write
  ```

- **Aggregates** are one-shot:
  `await db.aggregate("applications", { count: true }, { where: { status: "pending" } })`
  → `{ count }`.
- **Trusted change cursors** are resumable and content-free:

  ```ts
  const checkpoint = await db.changes(); // snapshot boundary; no history replay
  const page = await db.changes({ cursor: checkpoint.cursor, limit: 200 });
  ```

  Each transaction reports only its `txId`, commit time, and touched/created
  namespace+entity keys—never operation bodies or attribute values. This
  surface requires a full server app key; rules-scoped credentials are denied.
  Persist `page.cursor` only after consuming the page. Restore, copy, or
  truncated history returns `checkpointRequired: true` with a replacement
  checkpoint instead of an apparently valid empty result.
- **Health probes:** `GET /app/:id/schema` (Bearer: the API key) is the cheap
  connectivity and schema-presence check — compare
  `Object.keys(schema.entities)` against the namespaces you expect.
- **Schema pushes validate compatibility.** Declared namespaces are strict;
  unsafe type, required-field, uniqueness, link, and removal changes are
  rejected before metadata changes. See the [database schema
  contract](docs/schema-contract.md) for strict/schemaless behavior and the
  accepted migration envelope.

## API surface

- `initAdmin(opts)` (legacy alias: root `init`) → HTTP administration client;
  its application key bypasses rules and belongs only in trusted backends.
  The returned client includes `changes()` for server-issued transaction
  checkpoints and bounded resume pages.
- `@odla-ai/db/client` → realtime `init`, subscriptions, optimistic writes,
  persistence, aggregate queries, rooms, and content-free connection health
  under an end-user token. `db.connectionHealth()` plus `onHealth` distinguish
  live/reconnecting/stale/closed state and expose heartbeat RTT and bounded
  queue depth; `onTelemetry` emits low-cardinality events without app, user,
  query, mutation, room, or payload data.
- `@odla-ai/db/preact` → `OdlaProvider`, `useDb`, `useQuery`, `useTransact`.
  `transact` resolves to `{ txId, duplicate }` (`TransactResult`).
- `tx`, `flattenTx`, `TxChain`, `uuidv7`, `PROTOCOL_VERSION`.
- `@odla-ai/db/federation` (also exported from the trusted root entry) —
  `signFederatedRequest` and `verifyFederatedRequest`, plus stable
  `FEDERATION_HEADERS` and `FEDERATION_VERSION`. Store the shared edge secret in
  each application's server-side vault; never put it in config or browser data.
  Verification defaults to five minutes of clock skew and can enforce a sender
  allowlist. Non-idempotent routes should supply `consumeNonce` backed by a
  durable replay gate; idempotent routes may rely on their natural mutation
  identity.
- `OdlaError` — stable `code`, optional HTTP `status`/`detail`/`requestId`, and
  `retryable`; realtime clients surface connection errors through `onError`.
- `i`, `OdlaSchema`, `Attr` — schema builder.
- All wire types: `Op`, `EntityRef`, `Lookup`, `InstaQLQuery`, `QueryResult`,
  `Entity`, `Value`, `Json`, `SerializedSchema`, …; realtime health types:
  `ConnectionHealth`, `ClientTelemetryEvent`, and `FreshnessMode`.

### 0.6 migration notes

- Queries that omitted `limit` now return at most 100 rows; paginate explicitly
  if you previously relied on full-namespace reads. The maximum limit is 1,000.
- Oversized query shapes and scans fail with stable `query_*` error codes.
- `$like` now accepts only case-sensitive exact or trailing-`%` prefix
  patterns; replace substring patterns with full-text `search`.
- Realtime sockets cap active subscriptions at 32 and combined hibernation
  state at 16 KiB. Diff retention exhaustion falls back to full snapshots.
- JSON wire bodies over 1.1 MB and encoded query results over 1 MB are rejected.
- HTTP and WebSocket peers negotiate protocol v1; declared mismatches fail with
  `protocol_version` instead of being decoded as an unknown response.
- SDK failures are `OdlaError` instances. Existing message matching should move
  to `error.code`; server `detail` is preserved when it is JSON-safe.
- Device authorization uses `handshake_denied`, `handshake_expired`,
  `handshake_timeout`, `aborted`, `network_error`, and `protocol_version`; these
  are stable codes, not strings to recover from the message.

## Live totals for paginated views

A query node can ask for the count of rows that match it, ignoring its own
`limit`/`offset` — the number a pager needs:

```ts
const { data, totals } = useQuery({
  pm_bug: { $: { where: { appId: "silver-salt" }, limit: 50, count: true } },
});
// data.pm_bug  → the 50-row page
// totals.pm_bug → how many match in total, live
```

Unlike the one-shot `aggregate`, this is **reactive**: the count is recomputed
with the rows and delivered on the same frames, including diff frames. A row
added past the last page moves the total without moving the page, and that frame
is still sent — a pager that showed "of 12" becomes "of 13" on its own.

Counts are taken **after** view-rule filtering, so they never reveal the size of a
result set you are not allowed to read. That is also why `count` is opt-in rather
than automatic: it evaluates rules across every match rather than just the
returned page, so the cost scales with matches, not with `limit`. Ask for it on
the views that paginate.
