better-schemic
v0.1.0-beta.5
Published
Better-schemic — schema-as-code for SurrealDB. Author your schema in TypeScript, generate SurrealQL DDL, and manage migrations.
Readme
better-schemic
Schema-as-code for SurrealDB — author your database schema in TypeScript and generate SurrealQL DDL, types, and migrations from that one definition.
- Define tables and relations with
s.*, a drop-in for Zod'sz.*. - Generate
DEFINE TABLE/DEFINE FIELDDDL, run reviewable migrations, and introspect a live database back into TypeScript. - Read and write rows as typed values via codecs (
datetime⇄Date,uuid, record links, …). - One package: the authoring surface, the DDL/diff/migration engine and the
better-schemic/scCLI.
Install
bun add better-schemicThat is the whole install. Zod and the SurrealDB SDK are dependencies of this package (not
peers), so there is exactly one copy of each — s.* extends Zod's builders, and a second Zod in
your app would break instanceof checks inside Zod. If you need raw Zod, import it from here so
you get the same copy: import { z } from "better-schemic".
@surrealdb/node is an optional peer (the embedded in-memory engine better-schemic check can
replay into — npm i -D @surrealdb/node).
Subpaths
| Import | What you get |
| --------------------------- | ---------------------------------------------------------------------------- |
| better-schemic | the authoring surface — s.*, define*, the surql tag (side-effect-free) |
| better-schemic/connection | surrealConnection — the config factory |
| better-schemic/config | defineConfig + surrealConnection — the project config |
| better-schemic/orm | the repository-style ORM client |
| better-schemic/query | fragments & procedural SurrealQL |
| better-schemic/plugins/* | the authoring plugins |
| better-schemic/logger | the query logger |
| better-schemic/otel | OpenTelemetry tracing (createOtelLogger) |
| better-schemic/driver | the engine entry (CLI/engine-only — pulls in the DDL emitter) |
Quick start
Scaffold a project (sc is the short alias for better-schemic):
sc initinit writes a better-schemic.config.ts, a sample user schema, a seed stub, and
.env.example. The sample schema:
// database/schema/tables/user.ts
import { table, s, surql } from "better-schemic";
export const User = table("user", {
name: s.string().$assert(surql`string::len($value) > 0`),
email: s.email().$unique(),
createdAt: s.datetime().$default(surql`time::now()`).$readonly(),
}).schemafull();…which generates this SurrealQL DDL (the id field is provided automatically):
DEFINE TABLE user TYPE NORMAL SCHEMAFULL;
DEFINE FIELD name ON TABLE user TYPE string ASSERT string::len($value) > 0;
DEFINE FIELD email ON TABLE user TYPE string ASSERT string::is_email($value);
DEFINE INDEX user_email_idx ON TABLE user FIELDS email UNIQUE;
DEFINE FIELD createdAt ON TABLE user TYPE datetime DEFAULT time::now() READONLY;$value computes/coerces on write and is create-optional by default (time::now(), a total
derived from other fields). An expression that reads $value declares its intent — { optional:
false } for a transform that requires client input, { optional: true } when the DB may compute
it without input. A parent mapping can mark the nested keys it fills as optional PER ITEM:
export const Order = table("order", {
products: s
.array(
s.object({
product: s.string(),
quantity: s.int(),
sellingPriceAtOrder: s.number(),
}),
)
.$value(
surql`$value.map(|$p| { RETURN { product: $p.product, quantity: $p.quantity, sellingPriceAtOrder: 1 } })`,
{ optional: true, computes: ["sellingPriceAtOrder"] },
),
});The connection lives in better-schemic.config.ts — a named connection from the
surrealConnection factory (no driver: string to keep in sync):
import { defineConfig, surrealConnection } from "better-schemic/config";
export default defineConfig({
connections: {
default: surrealConnection({
schema: "./database/schema",
url: process.env.SURREAL_URL ?? "ws://127.0.0.1:8000/rpc",
namespace: process.env.SURREAL_NAMESPACE ?? "app",
database: process.env.SURREAL_DATABASE ?? "app",
username: process.env.SURREAL_USER,
password: process.env.SURREAL_PASS,
authLevel: "root", // "root" | "namespace" | "database"
}),
},
});Then drive it from the CLI:
sc diff # preview changes vs the last snapshot (--ts for a TypeScript view)
sc gen # write a migration for the pending change
sc migrate # apply pending migrations
sc seed # run database/seed.ts against a connection
sc status # show applied vs pending migrations
sc pull # introspect a live database back into TypeScriptReading & writing rows
A table definition carries codecs that bridge your app values and the database
wire format. decode turns a returned row into typed values (a datetime
becomes a Date, a uuid a string, record links resolve); encode and
encodePartial build the payloads you write back.
The /orm client
The repository-style ORM lives at better-schemic/orm. Declare the
schema once with schema and wrap an existing (BYO) or managed connection:
import { table, s } from "better-schemic";
import { orm, createOrm, schema: schemaDef } from "better-schemic/orm";
const User = table("user", {
name: s.string(),
email: s.string(),
age: s.int(),
tags: s.array(s.string()),
}).index("user_email", ["email"], { unique: true });
// A cart whose lines are the rows the UNWIND+GROUP aggregate below walks.
const Line = table("line", { label: s.string(), quantity: s.int() });
const Cart = table("cart", {
name: s.string(),
lines: s.array(s.recordId(() => Line)),
});
// Multi-tenant: `email` is unique only WITHIN a tenant, so the index is the pair.
const Account = table("account", {
tenant: s.string(),
email: s.string(),
name: s.string(),
}).index("account_tenant_email", ["tenant", "email"], { unique: true });
export const schemaDef = schema({
users: User,
carts: Cart,
lines: Line,
accounts: Account,
audit: "audit_log",
});
const client = orm(existingSurreal, { schema: schemaDef }); // BYO: close() is a no-op
// or: const client = await createOrm({ url, namespace, database, auth, schema: schemaDef });
// Reads (M1) — one round-trip, decoded to app values (Date/RecordId/…):
const adults = await client.users.findMany({
where: { age: { gte: 18 }, tags: { containsAny: ["db", "graph"] } },
select: { id: true, name: true },
orderBy: [{ age: "desc" }],
limit: 20,
});
const user = await client.users.findUnique({ where: { email } }).throw();
const total = await client.users.count({ where: { age: { gte: 18 } } });
const byTag = await client.users.aggregate({
select: { tags: "tags", _count: true, avgAge: { avg: "age" } },
groupBy: ["tags"], // the alias OR the path — both are valid group keys
});
// `math::*` only aggregates NUMBERS and answers 0/null/{}/silently for anything else, so the
// operand is checked against the schema: a wrong path is a compile error, not a R$ 0,00 report.
// Aggregating PER ELEMENT of an array is `split` (UNWIND in a subquery + GROUP outside):
const perUser = await client.carts.aggregate({
split: "lines",
select: { name: "name", total: { sum: "lines[*].quantity" } },
groupBy: ["name"], // where filters the PARENT rows
});
const page = await client.users.paginate({ orderBy: [{ id: "asc" }], limit: 20, start: 0 });
const next = await client.users.cursor({
select: { name: true }, // keyset fields ride reserved `_keyset_<n>` aliases
orderBy: [{ age: "desc" }, { id: "asc" }], // the LAST field must be unique — a type error now
limit: 20, // (`id`, or a field with a single-field UNIQUE index)
after: { age: 30, id: "user:42" },
});
// Cursor values carry the STORED value (`s.datetime()` round-trips a `DateTime` with nanoseconds;
// `id` a `RecordId`) — never the codec-decoded app value, so `after`/`before` never lose precision.
// Writes (M2) — validated by the codec, one round-trip, decoded rows back:
const created = await client.users.create({
data: { name: "Aeon", email: "[email protected]", age: 30, tags: ["db"] },
});
const inserted = await client.users.insertMany({
data: externalUsers, // ids in the payload
onDuplicate: "update", // INSERT … ON DUPLICATE KEY UPDATE
});
const updated = await client.users
.update({ where: { id: created.id }, data: { age: 31 } }) // never creates
.throw();
const patched = await client.users.patch({
where: { id: created.id },
patches: [{ op: "replace", path: "/age", value: 32 }],
});
const upserted = await client.users.upsert({
where: { email: "[email protected]" }, // id, a UNIQUE index (single field, or the fields
data: { email: "[email protected]", age: 33 }, // of a COMPOSITE one) — or infer from `data.id`
onMissing: "create", // STRICT by default: a miss rejects ResultNotFound
});
// The multi-tenant shape: `email` is unique only WITHIN a tenant, so the `[tenant, email]` index IS
// the unique key — pass every field of the group (a partial `where` would match many rows):
const tenantUpserted = await client.accounts.upsert({
where: { tenant: "acme", email: "[email protected]" },
data: { name: "Aeon" },
});
const saved = await client.users.upsert({
data: { id: created.id, age: 33 }, // `data.id` infers the target — strict UPDATE ONLY
});
const fresh = await client.users.upsert({
data: { name: "Nova", email: "[email protected]", age: 1, tags: [] }, // no target at all → CREATE
});
const { record, created, before, delta, changed } = await client.users.upsertDelta({
where: { id: created.id }, // or a UNIQUE field, or `data.id`, or omitted to create
data: { age: 34 },
onMissing: "create", // strict by default here too
});
// ONE statement, `RETURN VALUE { before, after }` — no read-then-write race. On a create
// `created: true` and `before`/`delta` are null; on an update `delta.old`/`delta.new` carry ONLY
// the changed fields as DECODED app values (server-computed for surql expressions), and
// `changed` names them. Both upserts resolve the target the same way (`where` > `data.id` > no
// target = plain create) and are STRICT by default — a targeted miss rejects `ResultNotFound`;
// `onMissing: "create"` (above) restores create-or-update:
const strict = await client.users.upsert({
where: { id: created.id },
data: { age: 35 }, // UPDATE ONLY — never creates, never resolves null
});
void before;
void delta;
void changed;
void strict;
const removed = await client.users.delete({ where: { id: created.id } });
// Write projections + counters — every write accepts the same `select`/`omit` as reads. The
// server carries the projection in its own RETURN clause when it can (`RETURN id, age`,
// `RETURN *, upper`); BEFORE/`delete`/`upsertDelta`/`omit` rows are decoded whole and projected
// client-side (expression entries only exist server-side, so those states reject them). Types
// follow the projection exactly like reads (`{ id: true, age: true }` → `{ id, age }`).
// `{ increment }`/`{ decrement }` adjust a numeric field in ONE read-modify-write — no lost update
// between a client read and write:
const aged = await client.users.update({
where: { id: created.id },
data: { age: { increment: 1 } }, // → UPDATE user:… SET age += $p0
select: { id: true, age: true }, // → … RETURN id, age
});
const previous = await client.users
.update({
where: { id: created.id },
data: { age: { decrement: 2 } }, // → SET age -= $p0
return: "before", // → RETURN BEFORE (projected client-side)
select: { age: true },
})
.throw();
// Array fields have their own markers, each ONE server-side write (so a UNIQUE index over the
// whole array stays correct — the server is what swaps the key):
const tagged = await client.users.update({
where: { id: created.id },
data: { tags: { append: "vip" } }, // → SET tags += $p0 (append, NO dedup)
});
const deduped = await client.users.update({
where: { id: created.id },
data: { tags: { union: ["vip", "beta"] } }, // → SET tags = array::union(tags, $p0) (dedups)
});
const without = await client.users.update({
where: { id: created.id },
data: { tags: { remove: "beta" } }, // → SET tags -= $p0 (removes EVERY occurrence)
});
// Modifiers refine a marker inside the SAME one statement: `when` is a server-side guard, `else`
// is its fallback (default `"keep"` → the current value; `"throw"` aborts the whole statement, so
// `updateMany` writes NO row), and `coalesce` seeds an array that is still NONE:
const guarded = await client.users.update({
where: { id: created.id },
data: { age: { increment: 1, when: surql`age < 150`, else: "throw" } },
// → SET age = IF age < 150 THEN age + $p0 ELSE THROW s"adjustment guard failed on age" END
});
const seeded = await client.users.update({
where: { id: created.id },
data: { tags: { union: ["vip"], coalesce: [] } },
// → SET tags = array::union(tags ?? $p0, $p1)
});
void aged;
void previous;
void tagged;
void deduped;
void without;
void guarded;
void seeded;
// Created ids: every ORM create without an explicit `id` generates a ULID by default
// (`rand::ulid()` — 26 chars, time-sortable). Pick another strategy per table in authoring:
// table("user", { … }).idStrategy("uuid") // rand::uuid() — UUID v7
// table("legacy", { … }).idStrategy("rand") // rand::id() — the server default (20 chars)
// An explicit `id` always wins and singleton tables keep their fixed id. The strategy is
// ORM-only: it emits NO DDL (migrations never diff), raw SQL keeps the server default, and
// `sc pull` cannot recover it. A pinned uuid v4/v6 `id` field is explicit-only: creates without
// an `id` fail at compile time (no strategy can generate that format).
// String ids (M15) — opt a table into BARE app id strings (`01M…`); `RecordId` stays on the wire.
// The emitted DDL is UNCHANGED (`record<…>` — zero migration), and raw `db.query()`, `live` and
// `changes` still return `RecordId`:
// const Customer = table("customer", { name: s.string(), owner: s.recordId(User) })
// .stringIds(); // or per field: s.recordId(User).stringIds()
// const row = await client.customers.create({ data: { name: "A", owner: "aeon" } });
// row.owner; // "aeon" — the bare id, not a RecordId
// const byOwner = await client.customers.findMany({ where: { owner: "aeon" } });
// const page = await client.customers.cursor({
// orderBy: [{ id: "asc" }], limit: 20, after: "01M…", // bare cursor; nextCursor bare too
// });
// `stringIds()` covers the id + every record link (nested objects/arrays included); a link to
// MULTIPLE tables throws (a bare string can't name the table on encode). `User.record()` inherits
// the mode and `tenant(User)` follows the principal.
// Edges live on the relation delegate (`relation`):
// const like = await client.likes.relate({ from: "user:1", to: "post:1", data: { score: 5 } });
// await client.likes.unrelate({ from: "user:1", to: "post:1" });
// Relations & graphs (M3) — links (FETCH), edge traversal and `_count`, still ONE round-trip.
// Both examples need a `Post`/`Likes` pair in the schema; uncomment when you have one:
// const posts = await client.posts.findMany({
// include: {
// author: { select: { id: true, name: true } }, // FETCH or flat remount
// likes: { where: { score: { gte: 4 } }, select: { title: true } }, // per-parent subquery
// _count: { select: { likes: true } }, // correlated count -> _count.likes
// },
// where: { likes: { some: { score: { gte: 4 } } } }, // relational filter
// });
// Transactions (M4) — `tx` is a full client bound to the managed transaction:
const from = await client.transaction(async (tx) => {
const user = await tx.users
.update({ where: { id: "user:1" }, mode: "set", data: { age: surql`age + 1` }, return: "after" })
.throw();
tx.afterCommit(() => mailer.send(user.email)); // outside effects only after the commit
return user;
});
// Live queries (M4) — the ORM compiles the LIVE SELECT (typed where/select/fetch):
const sub = await client.users.live(
{ where: { active: true }, diff: true },
(change) => {
if (change.action === "UPDATE") cache.set(change.recordId, change.diff);
},
);
// for await (const change of sub) { … }
await sub.kill(); // or: await client.kill(sub.uuid)
// Changefeeds (M4) — DEFINE TABLE t CHANGEFEED 1d (INCLUDE ORIGINAL for before/diff):
const sets = await client.changes({ table: "users", since: 0, limit: 100 });
for (const { versionstamp, changes } of sets) {
for (const change of changes) {
if (change.action === "DELETE") cache.delete(change.recordId);
}
void versionstamp; // paginate with `versionstamp + 1`
}
// Escape hatches & admin (M5) — parameterized by default:
const rows = await client.$raw<User[]>`SELECT * FROM users WHERE email = ${email}`;
const [users, posts] = await client.$query<[User[], Post[]]>`
SELECT * FROM users LIMIT 10; SELECT * FROM posts LIMIT 10;
`;
// A curried tag carries options into the template form (e.g. under `raw.requireComment`):
// const seeded = await client.$raw({ meta: { comment: "seed" } })`CREATE …`;
const tier = await client.fn.call<string>("fn::customer_tier", [15000]);
// Typed shortcut per `defineFunction` (named args -> positional):
// const tier = await client.fn.customerTier({ total: 15000 });
const articles = await client.api.get<Article[]>("/articles", { query: { limit: 10 } });
const info = await client.info("db"); // INFO FOR DB
const dump = await client.export();
await client.import(dump);
// Multi-tenant scope (M5.3) — `USE NS … DB …;` per operation, no global state:
const tenantA = client.$withContext({ namespace: "tenant_a", database: "app" });
await tenantA.invoices.findMany({ where: { status: "open" } });
await tenantA.invoices.findMany({ context: { database: "analytics" } }); // per-call override
// `api`/`auth`/`export`/`live` are session-bound; fork a scoped session when you need them:
const scoped = await client.$withContext({ namespace: "tenant_a", database: "app", auth: token });
// Diagnostics without executing:
const plan = await client.users.findMany({ where: { age: 18 } }).explain();
// Observation hooks (M6) — logging/tracing around every operation, never mutating it:
const logged = orm(db, { schema: schemaDef,
hooks: {
beforeQuery: ({ table, operation, surql }) => logger.debug({ table, operation, surql }),
afterQuery: ({ durationMs }) => metrics.timing("db.query", durationMs),
onError: ({ error }) => logger.error({ err: error }),
},
});
// Plugins (M6) — mutate operations, add typed args, extend the client/delegates:
import { definePlugin, surql } from "better-schemic/orm";
const softDelete = definePlugin({
id: "soft-delete",
config: { column: "deletedAt" },
operationArgs: { findMany: { deleted: "without" as "with" | "without" | "only" } },
transform(op) {
if (op.kind === "delete") { op.kind = "update"; op.data[this.config.column] = surql`time::now()`; }
else if (op.kind.startsWith("find") && op.args.deleted !== "with") op.where[this.config.column] = { isNone: true };
},
extendModel({ model }) {
return { restore: (args) => model.update({ where: args.where, mode: "set", data: { [this.config.column]: null } }) };
},
});
// await client.users.findMany({ deleted: "with" });
// await client.users.delete({ where: { id } }); // soft delete
// await client.users.restore({ where: { id } });Official F1 guardrail/validation plugins (M6.3) ship as subpaths:
import { recommended } from "better-schemic/plugins/rules";
import { zod } from "better-schemic/plugins/zod";
const client = orm(db, {
schema: schemaDef,
plugins: [
recommended({ maxLimit: 100 }), // noRawUnsafe + no unfiltered writes + required/max limit
zod({ schemas: { user: z.object({ email: z.email() }) } }),
],
});Official F2 plugins (M6.4) — timestamps + a reversible soft delete:
import { timestamps } from "better-schemic/plugins/timestamps";
import { softDelete } from "better-schemic/plugins/soft-delete";
const client = orm(db, {
schema: schemaDef,
plugins: [timestamps(), softDelete({ deletedBy: "deletedBy" })],
});
// create/update get `time::now()` stamped automatically (mode "app");
// delete becomes a soft delete and reads hide deleted rows unless `deleted: "with" | "only"`:
await client.users.delete({ where: { id }, meta: { actor: currentUserId } });
const live = await client.users.findMany({}); // deleted rows hidden
const all = await client.users.findMany({ deleted: "with" }); // include them
await client.users.restoreById(id); // clear deletedAtOfficial F3 plugin (M10) — multi-tenant RLS: a schema PRESET (tenant()) plus a runtime plugin
(tenantRls()) for privileged sessions, where $auth/DDL permissions do not filter:
import { tenant, tenantRls } from "better-schemic/plugins/tenant";
// Schema: one `.use(tenant(principal, options?))` per table — tenant column + per-op permissions +
// `{table}_protect_<column>` guard event + indexes (zero-diff with the hand-written recipe).
const User = table("user", { name: s.string() });
const Customer = table("customer", {
name: s.string(),
deletedAt: s.datetime().optional(), // app-declared tombstone (softDelete: true)
}).use(tenant(User, { softDelete: true }));
const Order = table("order", { total: s.number() }).use(
tenant(User, { createOnly: true }),
); // append-only: update = NONE
// Runtime: every tenant-tagged table requires a scope BEFORE compiling (fail-closed).
const client = orm(db, {
schema: schema({ users: User, customers: Customer, orders: Order }),
plugins: [tenantRls({ tenant: () => ctx.tenantId })],
});
await client.customers.findMany({}); // ✗ TenantRequired (no scope)
await client.customers.$forTenant("user:abc").findMany({}); // ✓ scoped to user:abc
await client.customers.$forTenant("user:abc").create({ data: { name: "A" } }); // tenant injected
await client.customers
.$forTenant("user:abc")
.findUnique({ where: { id: "customer:1" } });
// ✓ reads are scoped too (id and single-field UNIQUE targets) — a cross-tenant id resolves null
await client.customers.$withoutPlugins().findMany({}); // explicit admin escape (audited)Official plugin (M14) — create-only (append-only) tables: the createOnly() schema PRESET
(update locked to NONE, optional hard event) plus the createOnlyGuard() runtime plugin
(fail-fast for privileged sessions, where the DDL permission does not gate):
import {
createOnly,
createOnlyGuard,
} from "better-schemic/plugins/create-only";
// Schema: PERMISSIONS FOR update NONE + meta.createOnly. `hard: true` also emits an
// `{table}_create_only` event — events run without permission checks, so root/$withoutPlugins/raw
// are blocked too. `timestamps()` sees the marker and never stamps `updatedAt` on this table.
const AuditLog = table("audit_log", {
action: s.string(),
createdAt: s.datetime().optional(),
}).use(createOnly({ hard: true }));
const client = orm(db, {
schema: schema({ auditLogs: AuditLog }),
plugins: [timestamps(), createOnlyGuard()],
});
await client.auditLogs.create({ data: { action: "login" } }); // createdAt only (no updatedAt)
await client.auditLogs.update({ where: { id }, data: { action: "x" } }); // ✗ CreateOnlyViolation
await client.auditLogs.$withoutPlugins().update({ where: { id }, data: {} }); // ✗ hard event THROWs
// create()/insert({ onDuplicate: "ignore" }) write; delete stays allowed (rows are immutable,
// not tombstones). `createOnlyGuard({ tables: ["audit_log"] })` covers preset-less schemas.Beautiful query logging (M9)
Enable the built-in logger with one flag — a framed, syntax-highlighted box for every round-trip,
with timing, row counts and (optionally) the EXPLAIN plan tree:
const client = orm(db, { schema: schemaDef, logger: true });
// presets: "pretty" (default) | "compact" | "json" (log shippers) | "silent"
// await client.users.findMany({ where: { age: { gte: 18 } } });
// ╭─ 🔍 findMany · user ─────────────────────────────╮
// │ SELECT * FROM user │
// │ WHERE age >= $p0 │
// │ $p0 = 18 │
// ╰─ 2 rows · 12.4ms · #1 · 14:03:22.881 ────────────╯It observes the executor (reads, writes, $raw, fn, admin, changes, live — and .explain()
plans), never changing the operation:
const client = orm(db, {
schema: schemaDef,
logger: {
slowMs: 50, // mark slow queries (also gates `level: "info"`)
explain: "slow", // auto-EXPLAIN reads >= slowMs ("all" | "analyze" available)
verbose: true, // preview the first rows
colors: "auto", // honours NO_COLOR / FORCE_COLOR / TTY
redact: true, // mask bound values (`$p0 = [redacted]`) for shared/CI logs
},
});
// .explain() / explain: true render the server plan as an operator tree:
// ╭─ 🔎 EXPLAIN findMany · user ─────────────────────────────────╮
// │ SELECT * FROM user WHERE age >= $p0 │
// │ plan │
// │ SelectProject [ctx: Db, projections: *] │
// │ └─ TableScan [ctx: Db, table: user, direction: Forward] ⚠ full scan
// ╰─ 0.11ms · #2 ───────────────────────────────────────────────╯Construct one explicitly (or use another preset) via the dedicated subpath, and enable it without
code with BETTER_SCHEMIC_LOG=1|pretty|json (BETTER_SCHEMIC_LOG_LEVEL, BETTER_SCHEMIC_LOG_SLOW_MS):
import { createQueryLogger } from "better-schemic/logger";
const client = orm(db, {
schema: schemaDef,
logger: createQueryLogger({ format: "json" }),
});OTEL tracing (better-schemic/otel)
One OpenTelemetry client span per executor round-trip — delegates, $raw/$query/$unsafe,
fn, admin, changes, live setup and .explain() plans. No plugin and no hook wiring: the OTEL
logger is a QueryLogger on the same executor seam as better-schemic/logger, so it observes
exactly what runs and nothing else.
bun add better-schemic @opentelemetry/apiimport { createOtelLogger } from "better-schemic/otel";
const client = orm(db, {
schema: schemaDef,
logger: createOtelLogger({
captureQueryText: true, // default; false omits db.query.text entirely
maxQueryTextLength: 1000, // default
attributes: { "deployment.environment.name": "production" },
}),
});Register the SDK once in the app entry. service.name is a resource attribute, not a span
attribute — the logger never sets it for you:
import { NodeSDK } from "@opentelemetry/sdk-node";
import { resourceFromAttributes } from "@opentelemetry/resources";
new NodeSDK({
resource: resourceFromAttributes({ "service.name": "my-app" }),
}).start();Spans use the stable database semantic conventions: db.system.name (surrealdb),
db.operation.name (SELECT, BATCH SELECT, EXPLAIN, …), db.collection.name,
db.namespace (namespace|database), db.query.text (opt-out), db.operation.batch.size, and
on failure error.type + db.response.status_code. error.type is the error class — the SDK
kind ("Thrown", "Validation", "Internal", ...) or the normalized code ("ParseError",
"ValidationError", ...) — never a high-cardinality message; db.response.status_code is the raw
SurrealDB code (-32000, ERR_LOCKED, ...). Binds are never captured.
PII:
db.query.textis the compiled SurrealQL. ORM statements are parameterized ($p0), but a$raw/$unsafestatement can contain literals — setcaptureQueryText: falsewhen that matters.
enabled: falsekeeps the option in place while the executor skips every event (zero overhead).- It is a normal
logger:option, so it composes with any customQueryLogger; only one logger can be attached per client. - OTEL does not auto-
EXPLAIN; an explicit.explain()is traced as its own span.
client.users.$model is the delegate metadata; client.repository("user") looks
up by schema key OR physical name; client.tables lists the keys; client.$sdk
is the raw surrealdb connection (escape hatch).
Status: the typed-query arc (M0–M7) is complete — reads (M1), writes (M2 —
create/insert/update/patch/upsert/delete/updateEach plus relate/unrelate on edge
delegates), relations/graphs (M3 — include links/edges/_count, relational where
is/isNot/some/every/none), transactions/live/changefeeds (M4 — client.transaction with
retries and afterCommit/afterRollback, live() + LiveSubscription with reconnect, changes()),
escape hatches/admin/context (M5 — $raw/$query/$unsafe, fn/api/auth/info/version/
ping/export/import, $withContext multi-tenant scoping with per-call context) and
plugins/hooks (M6 — observation hooks, definePlugin with transforms/typed operationArgs/
extendClient/extendModel, plus the official plugins plugins/rules, plugins/zod,
plugins/timestamps, plugins/soft-delete and plugins/tenant). M9 adds the built-in query logger
(logger: true / better-schemic/logger) with EXPLAIN plan rendering, and M14 adds
plugins/create-only (append-only tables: preset + runtime guard + optional hard event).
The runtime surface is mapped exhaustively in docs/ORM-COVERAGE.md, the
live-verified SurrealQL facts live in docs/orm-syntax-map.md, the
milestone plan is in ROADMAP.md, and the verified examples are in
examples/ (authoring → DDL) and examples/orm/ (delegate call →
runtime SurrealQL). Fragments & procedural SurrealQL (block()) stay at
better-schemic/query.
Docs
Full guides, concepts, and reference live at docs. Feature maps: docs/COVERAGE.md (schema/DDL) and docs/ORM-COVERAGE.md (runtime ORM surface); the live-verified SurrealQL the ORM emits is docs/orm-syntax-map.md, and the milestone plan is ROADMAP.md. This package is part of the Better-schemic toolkit.
License
MIT © NONSTANDARDCODE
