npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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's z.*.
  • Generate DEFINE TABLE / DEFINE FIELD DDL, 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 / sc CLI.

Install

bun add better-schemic

That 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 init

init 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 TypeScript

Reading & 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 deletedAt

Official 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/api
import { 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.text is the compiled SurrealQL. ORM statements are parameterized ($p0), but a $raw/$unsafe statement can contain literals — set captureQueryText: false when that matters.

  • enabled: false keeps the option in place while the executor skips every event (zero overhead).
  • It is a normal logger: option, so it composes with any custom QueryLogger; 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