@odla-ai/db
v0.6.4
Published
Official odla-db admin, realtime, and React clients with transaction and schema tooling.
Downloads
1,968
Maintainers
Readme
@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 React 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/react—OdlaProvider,useQuery, anduseTransact.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
txbuilder, and the schema builder are bundled in.Graph-native — entities are nodes, typed
links are edges, andLookuprefs 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/$updatedByfrom the verified writer identity.$-prefixed attrs are reserved: client writes to them are rejected.
Install
npm i @odla-ai/dbUse
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 browser or end-user application, use a signed-in user token. Never expose
an odla_sk_ application key:
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));import { OdlaProvider, useQuery } from "@odla-ai/db/react";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. You get back a tracked,
revocable, ~24-hour developer token scoped to that account's apps. If it ever
stops working, call requestToken again. The delegated token never inherits
the approver's platform-admin role, even when an administrator approves it.
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!,
onCode: ({ userCode, verificationUriComplete }) =>
console.log(`Review ${userCode}: ${verificationUriComplete ?? "https://odla.ai/studio"}`),
});
// `token` (odla_dev_…) can create apps, push schemas, and read/write ITS OWN apps.
const db = initAdmin({ appId: "my-app", adminToken: token, endpoint });Under the hood: POST https://odla.ai/handshake with { email, ... } → 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.
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.
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 an operator/dev token (this replaces the app's rule set):
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. When minting a key for an agent, pass
scopes so it is governed by the rules instead (auth.kind == "agent" in
CEL), optionally read-only and/or restricted to specific namespaces. 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 "*"):
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",
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 updates first, then links):
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-onceAuthor & push a schema
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 (everySELECT ... WHERE id = $1port 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 sameideither 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.Always pass
orderon list queries. Unordered results sort lexicographically by entity id: creation order foruuidv7()ids, arbitrary for anything else.Pagination is bounded. Omitted
limitmeans 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_limitorsession_state_too_largebefore registering state. The server retains at most 4 MB of prior diff snapshots per app Durable Object; subscriptions over that budget continue with fullquery-resultsnapshots 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 withunique_violation, exactly like an ON CONFLICT abort in a SQL transaction.A multi-op
transactis atomic (one Durable Object transaction), so batch a change and its audit row together. There is noSELECT ... FOR UPDATE: a check between a query and a transact can race. Put idempotency where it matters instead — a stablemutationIdgives exactly-once semantics, andINSERT ... ON CONFLICT DO NOTHINGwith a rowCount check is the returnedduplicateflag:const { duplicate } = await db.transact(ops, { mutationId: `stripe_evt:${event.id}` }); const fresh = !duplicate; // first delivery: this call did the writeAggregates are one-shot:
await db.aggregate("applications", { count: true }, { where: { status: "pending" } })→{ count }.Health probes:
GET /app/:id/schema(Bearer: the API key) is the cheap connectivity and schema-presence check — compareObject.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 for strict/schemaless behavior and the accepted migration envelope.
API surface
initAdmin(opts)(legacy alias: rootinit) → HTTP administration client; its application key bypasses rules and belongs only in trusted backends.@odla-ai/db/client→ realtimeinit, subscriptions, optimistic writes, persistence, aggregate queries, and rooms under an end-user token.@odla-ai/db/react→OdlaProvider,useDb,useQuery,useTransact.transactresolves to{ txId, duplicate }(TransactResult).tx,flattenTx,TxChain,uuidv7,PROTOCOL_VERSION.OdlaError— stablecode, optional HTTPstatus/detail/requestId, andretryable; realtime clients surface connection errors throughonError.i,OdlaSchema,Attr— schema builder.- All wire types:
Op,EntityRef,Lookup,InstaQLQuery,QueryResult,Entity,Value,Json,SerializedSchema, …
0.6 migration notes
- Queries that omitted
limitnow 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. $likenow accepts only case-sensitive exact or trailing-%prefix patterns; replace substring patterns with full-textsearch.- 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_versioninstead of being decoded as an unknown response. - SDK failures are
OdlaErrorinstances. Existing message matching should move toerror.code; serverdetailis preserved when it is JSON-safe. - Device authorization uses
handshake_denied,handshake_expired,handshake_timeout,aborted,network_error, andprotocol_version; these are stable codes, not strings to recover from the message.
