@schemic/surrealdb 0.1.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "@schemic/surrealdb",
3
+ "version": "0.1.0-alpha.0",
4
+ "description": "SurrealDB driver + authoring for Schemic — s.* schema definition, SurrealQL DDL, introspection, migrations.",
5
+ "license": "MIT",
6
+ "author": "Vertio Solutions",
7
+ "homepage": "https://surreal.schemic.dev",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/schemichq/schemic.git",
11
+ "directory": "packages/surrealdb"
12
+ },
13
+ "type": "module",
14
+ "sideEffects": [
15
+ "./src/index.ts",
16
+ "./src/driver/surreal.ts",
17
+ "./lib/index.js"
18
+ ],
19
+ "module": "lib/index.js",
20
+ "types": "lib/index.d.ts",
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "files": [
25
+ "lib",
26
+ "src",
27
+ "LICENSE"
28
+ ],
29
+ "exports": {
30
+ ".": {
31
+ "bun": "./src/index.ts",
32
+ "import": {
33
+ "types": "./lib/index.d.ts",
34
+ "default": "./lib/index.js"
35
+ }
36
+ },
37
+ "./package.json": "./package.json"
38
+ },
39
+ "scripts": {
40
+ "build": "tsup",
41
+ "prepack": "bun run build",
42
+ "test": "bun test",
43
+ "typecheck": "tsc --noEmit",
44
+ "lint": "biome check ."
45
+ },
46
+ "dependencies": {
47
+ "@schemic/core": "0.1.0-alpha.0",
48
+ "surrealdb": "^2.0.3"
49
+ },
50
+ "peerDependencies": {
51
+ "@surrealdb/node": "*",
52
+ "zod": "^4.3.5"
53
+ },
54
+ "peerDependenciesMeta": {
55
+ "@surrealdb/node": {
56
+ "optional": true
57
+ }
58
+ },
59
+ "devDependencies": {
60
+ "@biomejs/biome": "^2.3.11",
61
+ "@schemic/cli": "0.1.0-alpha.0",
62
+ "@types/bun": "latest",
63
+ "surrealdb": "^2.0.3",
64
+ "tsup": "^8",
65
+ "typescript": "^5",
66
+ "zod": "^4.3.5"
67
+ }
68
+ }
@@ -0,0 +1,189 @@
1
+ import { type ChildProcess, execFileSync, spawn } from "node:child_process";
2
+ import { createServer } from "node:net";
3
+ import { escapeIdent, Surreal } from "surrealdb";
4
+ import type { SurrealZodCheckEmbedded } from "../config";
5
+
6
+ /** Pick a free localhost TCP port by binding to :0 and reading it back. */
7
+ function freePort(): Promise<number> {
8
+ return new Promise((resolve, reject) => {
9
+ const srv = createServer();
10
+ srv.once("error", reject);
11
+ srv.listen(0, "127.0.0.1", () => {
12
+ const addr = srv.address();
13
+ const port = addr && typeof addr === "object" ? addr.port : 0;
14
+ srv.close(() => (port ? resolve(port) : reject(new Error("no port"))));
15
+ });
16
+ });
17
+ }
18
+
19
+ /** Whether the local `surreal` CLI is runnable (used to pick the `auto` check engine). */
20
+ export function surrealBinaryAvailable(bin = "surreal"): boolean {
21
+ try {
22
+ execFileSync(bin, ["version"], { stdio: "ignore" });
23
+ return true;
24
+ } catch {
25
+ return false;
26
+ }
27
+ }
28
+
29
+ export interface EphemeralServer {
30
+ url: string;
31
+ username: string;
32
+ password: string;
33
+ /** Stop the spawned process (idempotent). */
34
+ stop: () => Promise<void>;
35
+ }
36
+
37
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
38
+
39
+ /** Poll a Surreal connection until it succeeds, the process dies, or we time out. */
40
+ async function waitUntilReady(
41
+ url: string,
42
+ username: string,
43
+ password: string,
44
+ child: ChildProcess,
45
+ stderr: () => string,
46
+ ): Promise<void> {
47
+ const deadline = Date.now() + 15_000;
48
+ while (Date.now() < deadline) {
49
+ if (child.exitCode !== null) {
50
+ throw new Error(
51
+ `surreal exited (${child.exitCode}): ${stderr().split("\n").filter(Boolean).pop() ?? "no output"}`,
52
+ );
53
+ }
54
+ const db = new Surreal();
55
+ try {
56
+ await db.connect(url, { reconnect: false });
57
+ await db.signin({ username, password });
58
+ await db.close();
59
+ return;
60
+ } catch {
61
+ await db.close().catch(() => {});
62
+ await sleep(100);
63
+ }
64
+ }
65
+ throw new Error("surreal did not become ready within 15s");
66
+ }
67
+
68
+ /**
69
+ * Spawn an ephemeral in-memory SurrealDB using the local `surreal` binary — for `schemic check`'s
70
+ * migration replay. It runs on the user's EXACT SurrealDB version, needs no external server, and
71
+ * never touches their data. Capabilities are fully allowed (`--allow-all`): it's a throwaway
72
+ * instance running the user's own schema, so asserts/defaults/scripted functions all work. The
73
+ * caller must `stop()` it (a `process.exit` hook is also registered as a backstop).
74
+ */
75
+ export async function spawnEphemeralServer(
76
+ bin = "surreal",
77
+ ): Promise<EphemeralServer> {
78
+ const port = await freePort();
79
+ const username = "root";
80
+ const password = "root";
81
+ const child = spawn(
82
+ bin,
83
+ [
84
+ "start",
85
+ "--bind",
86
+ `127.0.0.1:${port}`,
87
+ "--username",
88
+ username,
89
+ "--password",
90
+ password,
91
+ "--allow-all",
92
+ "memory",
93
+ ],
94
+ { stdio: ["ignore", "ignore", "pipe"] },
95
+ );
96
+ let stderr = "";
97
+ child.stderr?.on("data", (d) => {
98
+ stderr += String(d);
99
+ });
100
+
101
+ let stopped = false;
102
+ const stop = (): Promise<void> =>
103
+ new Promise((resolve) => {
104
+ if (stopped || child.exitCode !== null) {
105
+ stopped = true;
106
+ return resolve();
107
+ }
108
+ stopped = true;
109
+ child.once("exit", () => resolve());
110
+ child.kill("SIGTERM");
111
+ // Hard-kill if it doesn't exit promptly.
112
+ setTimeout(() => child.kill("SIGKILL"), 2000).unref?.();
113
+ });
114
+ // Backstop: don't leak the process if the CLI exits abnormally.
115
+ const onExit = () => {
116
+ if (!stopped) child.kill("SIGKILL");
117
+ };
118
+ process.once("exit", onExit);
119
+
120
+ const url = `ws://127.0.0.1:${port}/rpc`;
121
+ try {
122
+ await waitUntilReady(url, username, password, child, () => stderr);
123
+ } catch (e) {
124
+ await stop();
125
+ throw e;
126
+ }
127
+ return { url, username, password, stop };
128
+ }
129
+
130
+ export interface EmbeddedConnection {
131
+ db: Surreal;
132
+ /** A display label for the engine, e.g. `embedded memory`. */
133
+ url: string;
134
+ stop: () => Promise<void>;
135
+ }
136
+
137
+ /**
138
+ * Connect to an EMBEDDED in-process SurrealDB via the optional `@surrealdb/node` package, selecting
139
+ * the `cfg.backend` storage and passing `cfg` (capabilities, timeouts, …) straight to
140
+ * `createNodeEngines`. Creates/selects the given namespace/database so the replay can run. The
141
+ * package is imported lazily (a non-literal specifier) so it's never required unless this engine is
142
+ * used; a clear error tells the user to install it otherwise.
143
+ */
144
+ export async function connectEmbedded(
145
+ cfg: SurrealZodCheckEmbedded,
146
+ namespace: string,
147
+ database: string,
148
+ ): Promise<EmbeddedConnection> {
149
+ // Non-literal specifier so tsc/bundlers don't treat `@surrealdb/node` as a hard dependency.
150
+ const pkg: string = "@surrealdb/node";
151
+ let createNodeEngines: ((options?: unknown) => unknown) | undefined;
152
+ try {
153
+ const mod = (await import(pkg)) as {
154
+ createNodeEngines?: (options?: unknown) => unknown;
155
+ };
156
+ createNodeEngines = mod.createNodeEngines;
157
+ } catch {
158
+ createNodeEngines = undefined;
159
+ }
160
+ if (!createNodeEngines) {
161
+ throw new Error(
162
+ 'check.engine embedded mode needs `@surrealdb/node` — install it (e.g. `npm i -D @surrealdb/node`), or use a string engine ("auto" / "binary" / "remote").',
163
+ );
164
+ }
165
+
166
+ const backend = cfg.backend ?? "memory";
167
+ const scheme = backend === "memory" ? "mem" : backend;
168
+ const url = `${scheme}://${cfg.path ?? ""}`;
169
+ const engines = createNodeEngines({
170
+ capabilities: cfg.capabilities ?? true,
171
+ strict: cfg.strict,
172
+ query_timeout: cfg.query_timeout,
173
+ transaction_timeout: cfg.transaction_timeout,
174
+ });
175
+ // biome-ignore lint/suspicious/noExplicitAny: the SDK's `engines` option is loosely typed here.
176
+ const db = new Surreal({ engines } as any);
177
+ await db.connect(url);
178
+ await db.query(`DEFINE NAMESPACE IF NOT EXISTS ${escapeIdent(namespace)}`);
179
+ await db.use({ namespace });
180
+ await db.query(`DEFINE DATABASE IF NOT EXISTS ${escapeIdent(database)}`);
181
+ await db.use({ namespace, database });
182
+ return {
183
+ db,
184
+ url: `embedded ${backend}`,
185
+ stop: async () => {
186
+ await db.close().catch(() => {});
187
+ },
188
+ };
189
+ }
@@ -0,0 +1,275 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { Diff, ResolvedConfig } from "@schemic/core";
4
+ import {
5
+ type Filter,
6
+ listMigrations,
7
+ loadDefs,
8
+ parseFilter,
9
+ } from "@schemic/core";
10
+ import { escapeIdent, type Surreal } from "surrealdb";
11
+ import type { SurrealParams } from "../config";
12
+ import { type DefineStatement, overwriteStatement } from "../ddl";
13
+ import { normalizeDb } from "./struct";
14
+ import {
15
+ type DbStructured,
16
+ introspectStructured,
17
+ type Snapshot,
18
+ structuredSnapshot,
19
+ } from "./structure";
20
+ import { buildSnapshot, diffSnapshots } from "./surreal-diff";
21
+ import { filterSnapshot, filterStructured } from "./surreal-filter";
22
+
23
+ const SHADOW_DB = "__surreal_zod_shadow";
24
+ const SHADOW_MIG_DB = "__surreal_zod_shadow_mig";
25
+
26
+ // Apply order: tables, then fields, then indexes.
27
+ const RANK: Record<DefineStatement["kind"], number> = {
28
+ analyzer: 0, // db-level; defined first (a FULLTEXT index references its analyzer)
29
+ function: 0, // db-level; defined first (tables/events may reference fn::…)
30
+ table: 1,
31
+ field: 2,
32
+ index: 3,
33
+ event: 4,
34
+ access: 5, // db-level; defined last (SIGNUP/SIGNIN reference tables)
35
+ };
36
+ const byCreate = (a: DefineStatement, b: DefineStatement) =>
37
+ RANK[a.kind] - RANK[b.kind];
38
+
39
+ /**
40
+ * Read the live database into a snapshot of **canonical** DDL, skipping `exclude`d tables. Uses
41
+ * `INFO … STRUCTURE` and rebuilds each object's DDL deterministically (see `structuredSnapshot`),
42
+ * so equivalent schemas compare equal regardless of SurrealDB's formatting (e.g. union order).
43
+ */
44
+ export async function introspect(
45
+ db: Surreal,
46
+ exclude: Set<string> = new Set(),
47
+ ): Promise<Snapshot> {
48
+ return structuredSnapshot(await introspectStructured(db, exclude));
49
+ }
50
+
51
+ /**
52
+ * Apply `ddl` to a fresh scratch database, introspect it back to a canonical snapshot, then drop it
53
+ * (restoring the original namespace/database). Normalizes a desired schema THROUGH SurrealDB so the
54
+ * comparison is free of formatting noise. Used by both `diff --live` and `verify`.
55
+ */
56
+ async function applyToShadow(
57
+ db: Surreal,
58
+ config: ResolvedConfig,
59
+ shadowDb: string,
60
+ ddl: string,
61
+ ): Promise<Snapshot> {
62
+ const { namespace, database } = config.params as unknown as SurrealParams;
63
+ await db.query(`REMOVE DATABASE IF EXISTS ${escapeIdent(shadowDb)};`);
64
+ await db.query(`DEFINE DATABASE ${escapeIdent(shadowDb)};`);
65
+ try {
66
+ await db.use({ namespace, database: shadowDb });
67
+ if (ddl) await db.query(`BEGIN;\n${ddl}\nCOMMIT;`);
68
+ return await introspect(db);
69
+ } finally {
70
+ await db.use({ namespace, database });
71
+ await db.query(`REMOVE DATABASE IF EXISTS ${escapeIdent(shadowDb)};`);
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Apply `ddl` to a fresh scratch database and return its STRUCTURED introspection (the Struct-IR
77
+ * form, not the canonical-DDL snapshot), then drop it. Backs `diff --ts`'s desired (schema) side —
78
+ * normalizing the schema THROUGH SurrealDB so it lands in the same form INFO returns for the live DB.
79
+ */
80
+ export async function shadowStructured(
81
+ db: Surreal,
82
+ config: ResolvedConfig,
83
+ ddl: string,
84
+ ): Promise<DbStructured> {
85
+ const { namespace, database } = config.params as unknown as SurrealParams;
86
+ await db.query(`REMOVE DATABASE IF EXISTS ${escapeIdent(SHADOW_DB)};`);
87
+ await db.query(`DEFINE DATABASE ${escapeIdent(SHADOW_DB)};`);
88
+ try {
89
+ await db.use({ namespace, database: SHADOW_DB });
90
+ if (ddl) await db.query(`BEGIN;\n${ddl}\nCOMMIT;`);
91
+ return await introspectStructured(db);
92
+ } finally {
93
+ await db.use({ namespace, database });
94
+ await db.query(`REMOVE DATABASE IF EXISTS ${escapeIdent(SHADOW_DB)};`);
95
+ }
96
+ }
97
+
98
+ /**
99
+ * The two sides of `diff --ts --live` as normalized Struct-IR: the live database (`current`) and
100
+ * the declared schema (`desired`, normalized THROUGH SurrealDB via a shadow apply). Both go through
101
+ * the same normalize, so an unchanged schema yields deep-equal structs. The caller renders them.
102
+ */
103
+ export async function tsStructsAgainstDb(
104
+ db: Surreal,
105
+ config: ResolvedConfig,
106
+ filter: Filter = parseFilter({}),
107
+ ): Promise<{ current: DbStructured; desired: DbStructured }> {
108
+ const exclude = new Set([
109
+ config.migrationsTable,
110
+ `${config.migrationsTable}_lock`,
111
+ ]);
112
+ const target = await introspectStructured(db, exclude);
113
+ const { tables, defs } = await loadDefs(config.schemaPath);
114
+ const ddl = Object.values(buildSnapshot(tables, defs).statements)
115
+ .sort(byCreate)
116
+ .map((s) => s.ddl)
117
+ .join("\n");
118
+ const desired = await shadowStructured(db, config, ddl);
119
+ const norm = (d: DbStructured) => normalizeDb(filterStructured(d, filter));
120
+ return { current: norm(target), desired: norm(desired) };
121
+ }
122
+
123
+ /**
124
+ * Replay every migration (direction `up`) from zero into a fresh scratch database, introspect the
125
+ * result, then drop it. Surfaces the offending migration if one fails to apply.
126
+ */
127
+ async function replayMigrations(
128
+ db: Surreal,
129
+ config: ResolvedConfig,
130
+ shadowDb: string,
131
+ onApply?: (tag: string) => void,
132
+ ): Promise<Snapshot> {
133
+ const { namespace, database } = config.params as unknown as SurrealParams;
134
+ await db.query(`REMOVE DATABASE IF EXISTS ${escapeIdent(shadowDb)};`);
135
+ await db.query(`DEFINE DATABASE ${escapeIdent(shadowDb)};`);
136
+ try {
137
+ await db.use({ namespace, database: shadowDb });
138
+ for (const m of listMigrations(config.migrationsDir)) {
139
+ onApply?.(m.tag);
140
+ const sql = readFileSync(join(config.migrationsDir, m.file), "utf8");
141
+ try {
142
+ await db.query(sql, { direction: "up" });
143
+ } catch (e) {
144
+ throw new Error(
145
+ `migration ${m.tag} failed to replay: ${(e as Error).message.split("\n")[0]}`,
146
+ );
147
+ }
148
+ }
149
+ return await introspect(db);
150
+ } finally {
151
+ await db.use({ namespace, database });
152
+ await db.query(`REMOVE DATABASE IF EXISTS ${escapeIdent(shadowDb)};`);
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Verify that replaying every migration from zero reconstructs the declared schema — the only check
158
+ * that catches "the sum of the migrations no longer equals the schema" (a hand-edited migration, or
159
+ * a schema change someone forgot to `schemic gen`). Replays the migrations into one scratch DB and applies
160
+ * the current schema into another, then diffs the two introspected snapshots; since BOTH sides are
161
+ * normalized through SurrealDB (`INFO`), only genuine drift shows up. An empty diff means they agree.
162
+ * `up` is what the migrations are missing relative to the schema. Needs root/namespace auth.
163
+ */
164
+ export async function verifyMigrations(
165
+ db: Surreal,
166
+ config: ResolvedConfig,
167
+ filter: Filter = parseFilter({}),
168
+ onApply?: (tag: string) => void,
169
+ ): Promise<Diff> {
170
+ const migrated = await replayMigrations(db, config, SHADOW_MIG_DB, onApply);
171
+ const { tables, defs } = await loadDefs(config.schemaPath);
172
+ const ddl = Object.values(buildSnapshot(tables, defs).statements)
173
+ .sort(byCreate)
174
+ .map((s) => s.ddl)
175
+ .join("\n");
176
+ const desired = await applyToShadow(db, config, SHADOW_DB, ddl);
177
+ return diffSnapshots(
178
+ filterSnapshot(migrated, filter),
179
+ filterSnapshot(desired, filter),
180
+ );
181
+ }
182
+
183
+ /**
184
+ * Diff the current schemas against the **live database**. Both sides are normalized through
185
+ * SurrealDB — the target via `INFO`, the desired by applying the schema to a temporary shadow
186
+ * database and reading IT back — so the comparison is free of formatting noise. The shadow
187
+ * database is created and dropped in the target's namespace (needs root/namespace auth).
188
+ */
189
+ export async function diffAgainstDb(
190
+ db: Surreal,
191
+ config: ResolvedConfig,
192
+ filter: Filter = parseFilter({}),
193
+ ): Promise<Diff> {
194
+ // Exclude the CLI's own bookkeeping tables — they're not part of the schema (pull excludes
195
+ // them too); otherwise `diff --live`/`sync` always report dropping `_migrations_lock`.
196
+ const target = await introspect(
197
+ db,
198
+ new Set([config.migrationsTable, `${config.migrationsTable}_lock`]),
199
+ );
200
+
201
+ const { tables, defs } = await loadDefs(config.schemaPath);
202
+ const schema = buildSnapshot(tables, defs);
203
+ const ddl = Object.values(schema.statements)
204
+ .sort(byCreate)
205
+ .map((s) => s.ddl)
206
+ .join("\n");
207
+ const desired = await applyToShadow(db, config, SHADOW_DB, ddl);
208
+
209
+ // up = statements that would bring the live database in line with the schema. The filter drops
210
+ // both sides' excluded kinds (access is off by default) so they're neither applied nor pruned.
211
+ const diff = diffSnapshots(
212
+ filterSnapshot(target, filter),
213
+ filterSnapshot(desired, filter),
214
+ );
215
+
216
+ // Access is COMPARED via its canonical form (the signing key is redacted identically on both
217
+ // sides, so no false diff), but that form can't be APPLIED — the redacted KEY is gone. Swap in
218
+ // the schema's emit DDL (which carries the key) for any DEFINE ACCESS in the apply plan.
219
+ const accessEmit = new Map<string, string>();
220
+ for (const s of Object.values(schema.statements))
221
+ if (s.kind === "access") accessEmit.set(s.name, s.ddl);
222
+ if (accessEmit.size) {
223
+ const swap = (stmt: string): string => {
224
+ const m = /^DEFINE ACCESS (OVERWRITE )?(\S+)/.exec(stmt);
225
+ const emit = m && accessEmit.get(m[2]);
226
+ return emit ? (m[1] ? overwriteStatement(emit) : emit) : stmt;
227
+ };
228
+ diff.up = diff.up.map(swap);
229
+ }
230
+
231
+ // Implicit-wildcard fields (the `.*` element of an `array<object>`/`set<object>`, etc.) are
232
+ // auto-created when their parent field is defined, so the emitter marks them `DEFINE FIELD
233
+ // OVERWRITE`. The INFO-canonical diff form drops that OVERWRITE, so applying it to the live DB
234
+ // fails "already exists" and aborts the whole `push`/`sync` transaction. Re-mark exactly those
235
+ // fields OVERWRITE in the apply plan — the comparison form above is untouched.
236
+ const untick = (s: string) => s.replace(/`/g, "");
237
+ const overwriteFields = new Set<string>();
238
+ for (const s of Object.values(schema.statements))
239
+ if (
240
+ s.kind === "field" &&
241
+ s.table &&
242
+ /^DEFINE FIELD OVERWRITE\b/.test(s.ddl)
243
+ )
244
+ overwriteFields.add(`${s.table}${untick(s.name)}`);
245
+ if (overwriteFields.size) {
246
+ const fieldRef =
247
+ /^DEFINE FIELD (?:OVERWRITE |IF NOT EXISTS )?(`[^`]+`|\S+) ON TABLE (`[^`]+`|\S+)/;
248
+ diff.up = diff.up.map((stmt) => {
249
+ const m = fieldRef.exec(stmt);
250
+ return m && overwriteFields.has(`${untick(m[2])}${untick(m[1])}`)
251
+ ? overwriteStatement(stmt)
252
+ : stmt;
253
+ });
254
+ }
255
+ return diff;
256
+ }
257
+
258
+ /**
259
+ * The statements that would reconcile the live database with the schema (the `diff --live`
260
+ * forward changes). With `prune: false`, drops (`REMOVE`) are excluded so existing extras
261
+ * are kept. Run through `applyStatements` to apply.
262
+ */
263
+ export function syncPlan(diff: Diff, prune?: boolean): string[] {
264
+ return prune === false
265
+ ? diff.up.filter((s) => !s.startsWith("REMOVE"))
266
+ : diff.up;
267
+ }
268
+
269
+ /** Apply a set of statements to the database in a single transaction. */
270
+ export async function applyStatements(
271
+ db: Surreal,
272
+ stmts: string[],
273
+ ): Promise<void> {
274
+ if (stmts.length) await db.query(`BEGIN;\n${stmts.join("\n")}\nCOMMIT;`);
275
+ }