@better-schemic/core 0.1.0-alpha.1

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.
Files changed (64) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +40 -0
  3. package/lib/authoring.d.ts +114 -0
  4. package/lib/authoring.js +242 -0
  5. package/lib/authoring.js.map +1 -0
  6. package/lib/chunk-26D7WX7Q.js +31 -0
  7. package/lib/chunk-26D7WX7Q.js.map +1 -0
  8. package/lib/chunk-IUPOUD4L.js +330 -0
  9. package/lib/chunk-IUPOUD4L.js.map +1 -0
  10. package/lib/chunk-LC3VHUM2.js +56 -0
  11. package/lib/chunk-LC3VHUM2.js.map +1 -0
  12. package/lib/chunk-RSGP7GVO.js +252 -0
  13. package/lib/chunk-RSGP7GVO.js.map +1 -0
  14. package/lib/client-HZF4ZWGO.js +13 -0
  15. package/lib/client-HZF4ZWGO.js.map +1 -0
  16. package/lib/config-BYh7WA4P.d.ts +259 -0
  17. package/lib/config.d.ts +2 -0
  18. package/lib/config.js +27 -0
  19. package/lib/config.js.map +1 -0
  20. package/lib/driver-LVldBEhS.d.ts +818 -0
  21. package/lib/driver.d.ts +151 -0
  22. package/lib/driver.js +47 -0
  23. package/lib/driver.js.map +1 -0
  24. package/lib/index.d.ts +154 -0
  25. package/lib/index.js +758 -0
  26. package/lib/index.js.map +1 -0
  27. package/lib/query.d.ts +81 -0
  28. package/lib/query.js +30 -0
  29. package/lib/query.js.map +1 -0
  30. package/lib/secrets-BETi5p8g.d.ts +26 -0
  31. package/lib/testing.d.ts +99 -0
  32. package/lib/testing.js +212 -0
  33. package/lib/testing.js.map +1 -0
  34. package/package.json +102 -0
  35. package/src/authoring.ts +360 -0
  36. package/src/cli-kit/config.ts +226 -0
  37. package/src/cli-kit/diff.ts +273 -0
  38. package/src/cli-kit/filter.ts +159 -0
  39. package/src/cli-kit/merge.ts +380 -0
  40. package/src/cli-kit/meta.ts +123 -0
  41. package/src/cli-kit/pager.ts +42 -0
  42. package/src/cli-kit/schema.ts +214 -0
  43. package/src/cli-kit/style.ts +24 -0
  44. package/src/client.ts +244 -0
  45. package/src/config.ts +199 -0
  46. package/src/connection.ts +120 -0
  47. package/src/driver/driver.ts +413 -0
  48. package/src/driver/index.ts +31 -0
  49. package/src/driver/portable-ir.ts +51 -0
  50. package/src/driver/portable.ts +124 -0
  51. package/src/driver/sdk.ts +73 -0
  52. package/src/index.ts +185 -0
  53. package/src/kind/index.ts +28 -0
  54. package/src/kind/plan.ts +412 -0
  55. package/src/kind/registry.ts +270 -0
  56. package/src/query/call.ts +21 -0
  57. package/src/query/codec.ts +33 -0
  58. package/src/query/index.ts +22 -0
  59. package/src/query/project.ts +25 -0
  60. package/src/query/ref.ts +32 -0
  61. package/src/query.ts +5 -0
  62. package/src/secrets.ts +61 -0
  63. package/src/seed.ts +14 -0
  64. package/src/testing.ts +402 -0
@@ -0,0 +1,226 @@
1
+ import { existsSync, statSync } from "node:fs";
2
+ import { basename, dirname, resolve } from "node:path";
3
+ import type { BetterSchemicConfig } from "@better-schemic/core/config";
4
+ import { createJiti } from "jiti";
5
+ import type { ConnectionConfigBase, ResolveContext } from "../connection";
6
+
7
+ // `better-schemic.ts` is the scaffolded name (the config IS the app's DB module — `betterSchemic.connect()`);
8
+ // the legacy `schemic.config.*` / `schemic.ts` spellings keep working. Checked LAST + shape-guarded, so an unrelated
9
+ // `./better-schemic.ts` helper module in a project never shadows a real `better-schemic.config.ts`.
10
+ const CONFIG_NAMES = [
11
+ "better-schemic.config.ts",
12
+ "better-schemic.config.mjs",
13
+ "better-schemic.config.js",
14
+ "better-schemic.ts",
15
+ // Legacy aliases (pre-rename) — still discovered, after the canonical names.
16
+ "schemic.config.ts",
17
+ "schemic.config.mjs",
18
+ "schemic.config.js",
19
+ "schemic.ts",
20
+ ];
21
+
22
+ /**
23
+ * Is the schema path a single file (vs a directory of schema modules)? Determined by `stat` when
24
+ * it exists, else inferred from a `.ts`/`.js`-ish extension.
25
+ */
26
+ function schemaIsFilePath(path: string): boolean {
27
+ if (existsSync(path)) return statSync(path).isFile();
28
+ return /\.[mc]?[jt]s$/.test(path);
29
+ }
30
+
31
+ /**
32
+ * Load `.env(.local)` from the project root into `process.env` so the config's own explicit
33
+ * `process.env.X` reads resolve when run under node (bun loads `.env` itself). Does not override
34
+ * already-set variables, so shell env still wins; load `.env.local` first so it beats `.env`.
35
+ */
36
+ function loadDotEnv(dir: string): void {
37
+ const proc = process as typeof process & {
38
+ loadEnvFile?: (path: string) => void;
39
+ };
40
+ if (typeof proc.loadEnvFile !== "function") return;
41
+ for (const name of [".env.local", ".env"]) {
42
+ const file = resolve(dir, name);
43
+ if (!existsSync(file)) continue;
44
+ try {
45
+ proc.loadEnvFile(file);
46
+ } catch {
47
+ // ignore a malformed .env file
48
+ }
49
+ }
50
+ }
51
+
52
+ /**
53
+ * A resolved, per-CONNECTION config — the dialect-NEUTRAL shape every command operates on (one
54
+ * connection at a time). `params` are the driver-specific connection params (opaque to core; the
55
+ * driver's `connect` reads them). Built by resolving one entry of `config.connections`.
56
+ */
57
+ export interface ResolvedConfig {
58
+ /** Resolved connection name (e.g. `default`, or `tenants:abc` within a collection). */
59
+ connection: string;
60
+ /** The driver this connection uses (the package the CLI dynamically loads). */
61
+ driver: string;
62
+ /** Project root (the directory containing the config file). */
63
+ root: string;
64
+ /** Absolute schema path — a single `.ts` module, or a directory of them. */
65
+ schemaPath: string;
66
+ /** Whether `schemaPath` is a single file (vs a directory of schema modules). */
67
+ schemaIsFile: boolean;
68
+ /** Absolute migrations directory (per connection's schema). */
69
+ migrationsDir: string;
70
+ /** Absolute migration meta directory (the snapshot). */
71
+ metaDir: string;
72
+ /** Name of the table that records applied migrations. */
73
+ migrationsTable: string;
74
+ /** Driver-specific connection params (url/namespace/… or whatever the driver defines). Opaque to core. */
75
+ params: Record<string, unknown>;
76
+ /** Optional seed script (project-level). */
77
+ seed?: string;
78
+ }
79
+
80
+ /**
81
+ * A jiti instance for loading the project's TS/ESM modules. Caches are off so `--watch` re-reads
82
+ * edited schema files. (Bare deps like `@better-schemic/core` are native-imported, so registries stay shared.)
83
+ */
84
+ export function makeJiti() {
85
+ return createJiti(import.meta.url, {
86
+ interopDefault: true,
87
+ fsCache: false,
88
+ moduleCache: false,
89
+ });
90
+ }
91
+
92
+ /** Find + load `better-schemic.ts` / `better-schemic.config.ts` (legacy `schemic.*` aliases included) into the dialect-neutral {@link BetterSchemicConfig}. */
93
+ export async function loadProject(opts?: {
94
+ config?: string;
95
+ cwd?: string;
96
+ }): Promise<{ config: BetterSchemicConfig; root: string }> {
97
+ const cwd = opts?.cwd ?? process.cwd();
98
+ const candidates = opts?.config
99
+ ? [resolve(cwd, opts.config)]
100
+ : CONFIG_NAMES.map((n) => resolve(cwd, n)).filter((p) => existsSync(p));
101
+ if (!candidates.length || !existsSync(candidates[0])) {
102
+ throw new Error(
103
+ "No better-schemic.ts / better-schemic.config.ts found — run `better-schemic init` first.",
104
+ );
105
+ }
106
+ const jiti = makeJiti();
107
+ for (const path of candidates) {
108
+ const root = dirname(path);
109
+ loadDotEnv(root); // populate process.env before the config module's explicit reads
110
+ const loaded = (await jiti.import(path)) as {
111
+ default?: BetterSchemicConfig;
112
+ betterSchemic?: BetterSchemicConfig;
113
+ schemic?: BetterSchemicConfig;
114
+ } & BetterSchemicConfig;
115
+ // Accept a default export OR the named `betterSchemic` export (legacy: `schemic`) — the scaffolded
116
+ // form is the NAMED one (`export const betterSchemic = defineConfig(...)`), so app code auto-imports
117
+ // a deterministic identifier (`import { betterSchemic } from "./better-schemic.config"` ->
118
+ // `betterSchemic.connect()`). Selected by SHAPE, not presence: jiti's interopDefault makes
119
+ // `loaded.default` a truthy proxy even when the module has no real default export, so a presence
120
+ // chain would shadow the named export.
121
+ const config = [
122
+ loaded.default,
123
+ loaded.betterSchemic,
124
+ loaded.schemic,
125
+ loaded,
126
+ ].find(
127
+ (c): c is BetterSchemicConfig =>
128
+ !!c && typeof c === "object" && "connections" in c,
129
+ );
130
+ if (config?.connections && Object.keys(config.connections).length > 0) {
131
+ return { config, root };
132
+ }
133
+ // An AUTO-discovered bare `better-schemic.ts` (or legacy `schemic.ts`) without a connections map is
134
+ // an unrelated helper module, not a config — skip it (an explicitly-passed or `*.config.*` file
135
+ // still errors loudly).
136
+ if (
137
+ !opts?.config &&
138
+ (basename(path) === "better-schemic.ts" ||
139
+ basename(path) === "schemic.ts")
140
+ )
141
+ continue;
142
+ throw new Error(`Invalid config at ${path}: expected a "connections" map.`);
143
+ }
144
+ throw new Error(
145
+ 'No Better-schemic config found — ./better-schemic.ts exists but doesn\'t export a config with a "connections" map. Run `better-schemic init`, or export one via defineConfig.',
146
+ );
147
+ }
148
+
149
+ /**
150
+ * Build the {@link ResolvedConfig} for one connection of the project. `ctx` carries the lazy
151
+ * cross-connection proxy + CLI `--arg`s (the CLI provides the real one; a static connection ignores
152
+ * it). A resolver returning a COLLECTION yields one ResolvedConfig per keyed entry.
153
+ *
154
+ * NOTE (WIP — multi-connection): the full resolution engine (lazy proxy DAG, `--connection`/`--all`
155
+ * addressing, collection fan-out) lives in `@better-schemic/cli`; this builder handles a single resolved
156
+ * connection config. See docs/MULTI-CONNECTION.md.
157
+ */
158
+ export function resolveConnectionConfig(
159
+ config: BetterSchemicConfig,
160
+ connection: string,
161
+ conn: ConnectionConfigBase,
162
+ driver: string,
163
+ root: string,
164
+ ): ResolvedConfig {
165
+ const { schema, migrations, key, ...params } = conn;
166
+ const schemaPath = resolve(root, schema);
167
+ // Default migrations dir is RELATIVE TO THE SCHEMA (the documented contract): the sibling
168
+ // `migrations` dir next to the schema dir (or next to a single-file schema). For the standard
169
+ // scaffold (`schema: "./database/schema"`) that is `./database/migrations`, unchanged; a nested
170
+ // schema (`./src/database/schema`) correctly gets `./src/database/migrations` instead of a
171
+ // root-fixed default that split state across two locations.
172
+ const migrationsDir = migrations
173
+ ? resolve(root, migrations)
174
+ : resolve(schemaPath, "..", "migrations");
175
+ return {
176
+ connection: key ? `${connection}:${key}` : connection,
177
+ driver,
178
+ root,
179
+ schemaPath,
180
+ schemaIsFile: schemaIsFilePath(schemaPath),
181
+ migrationsDir,
182
+ metaDir: resolve(migrationsDir, "meta"),
183
+ migrationsTable: config.migrationsTable ?? "_migrations",
184
+ params: params as Record<string, unknown>,
185
+ seed: config.seed,
186
+ };
187
+ }
188
+
189
+ /**
190
+ * Load the project and resolve the DEFAULT connection to a {@link ResolvedConfig} — the single-
191
+ * connection convenience path. (Multi-connection addressing + resolver context are added by the CLI;
192
+ * here a static default connection is resolved with an empty context.)
193
+ */
194
+ export async function loadConfig(opts?: {
195
+ config?: string;
196
+ cwd?: string;
197
+ }): Promise<ResolvedConfig> {
198
+ const { config, root } = await loadProject(opts);
199
+ const names = Object.keys(config.connections);
200
+ const name =
201
+ config.defaultConnection ?? (names.length === 1 ? names[0] : "default");
202
+ const entry = config.connections[name];
203
+ if (!entry) {
204
+ throw new Error(
205
+ `No connection named "${name}". Set "defaultConnection" or pass --connection. Known: ${names.join(", ")}.`,
206
+ );
207
+ }
208
+ const ctx: ResolveContext = { connections: {}, env: process.env };
209
+ const resolved = await entry.resolve(ctx);
210
+ if (resolved.length !== 1) {
211
+ throw new Error(
212
+ `Connection "${name}" resolved to ${resolved.length} connections (a collection); pass --connection ${name}:<key>.`,
213
+ );
214
+ }
215
+ return resolveConnectionConfig(config, name, resolved[0], entry.driver, root);
216
+ }
217
+
218
+ /** Per-command connection flag overrides (CLI args, applied by the driver over `params`). */
219
+ export interface ConnectionOverrides {
220
+ url?: string;
221
+ namespace?: string;
222
+ database?: string;
223
+ username?: string;
224
+ password?: string;
225
+ authLevel?: string;
226
+ }
@@ -0,0 +1,273 @@
1
+ // The DIALECT-FREE diff DISPLAY + shared types. The SurrealDB statement-diff engine (buildSnapshot/
2
+ // diffSnapshots/renderMigration) lives in `./surreal-diff` and is invoked through the driver; this
3
+ // module is what the CLI shell uses to RENDER any driver's Diff (git-style file groups, word-diff,
4
+ // unified patch, kind summaries). Dialect-free: `kind` is an opaque string, not a Surreal kind union.
5
+ import type { KindRegistry } from "../kind";
6
+ import type { SecretRef } from "../secrets";
7
+ import { colorEnabled, style } from "./style";
8
+
9
+ /**
10
+ * One object's change, for display. `kind` is the object kind, `table` its owner (a table name,
11
+ * or the object's own name for db-level objects). `add` carries the new DDL; `remove` carries the
12
+ * `REMOVE` statement (`ddl`) plus the dropped object's prior DDL (`old`, for the unified patch);
13
+ * `change` pairs old↔new.
14
+ */
15
+ export type DiffItem = {
16
+ key: string;
17
+ table: string;
18
+ /** Dialect-defined object kind (e.g. "table"/"field"/"index") — opaque to the display. */
19
+ kind: string;
20
+ /** The source file this object lives in (or lived in, for a removal). Absent if unknown. */
21
+ file?: string;
22
+ } & (
23
+ | { op: "add"; ddl: string }
24
+ | { op: "remove"; ddl: string; old: string }
25
+ | { op: "change"; before: string; after: string }
26
+ );
27
+
28
+ export interface Diff {
29
+ up: string[];
30
+ down: string[];
31
+ /**
32
+ * Apply-time secret bindings: `$param` name -> a write-only {@link SecretRef} (e.g. `env("X")`).
33
+ * Populated by a driver whose DDL emits secret placeholders (SurrealDB `DEFINE ACCESS … KEY $param`).
34
+ * MODEL 1: stored in the migration so it replays without the live schema; the **value never appears
35
+ * here** — the apply layer resolves each ref through a `SecretProvider` and binds it (`db.query(ddl,
36
+ * resolved)`), so secrets stay out of the schema, snapshot, and migration files. Diff-excluded +
37
+ * snapshot-omitted, so a redacted secret never reads as drift.
38
+ */
39
+ bindings?: Record<string, SecretRef>;
40
+ /** Structured per-object changes for the human display (word-level diff). */
41
+ items?: DiffItem[];
42
+ /** Every desired statement (the `next` schema), for the `--full` context view. */
43
+ full?: { key: string; table: string; ddl: string }[];
44
+ }
45
+
46
+ /** `true` if the two snapshots define the same objects with identical DDL. */
47
+ export function isEmptyDiff(diff: Diff): boolean {
48
+ return diff.up.length === 0;
49
+ }
50
+
51
+ /**
52
+ * Inline word-level diff of two statements: shared tokens dim, removed tokens red, added tokens
53
+ * green (LCS over space-separated tokens). So a changed field shows the whole statement with only
54
+ * the changed words highlighted.
55
+ */
56
+ export function tokenDiff(before: string, after: string): string {
57
+ const a = before.split(" ");
58
+ const b = after.split(" ");
59
+ const m = a.length;
60
+ const n = b.length;
61
+ const dp: number[][] = Array.from({ length: m + 1 }, () =>
62
+ new Array(n + 1).fill(0),
63
+ );
64
+ for (let i = m - 1; i >= 0; i--) {
65
+ for (let j = n - 1; j >= 0; j--) {
66
+ dp[i][j] =
67
+ a[i] === b[j]
68
+ ? dp[i + 1][j + 1] + 1
69
+ : Math.max(dp[i + 1][j], dp[i][j + 1]);
70
+ }
71
+ }
72
+ // With color: red/green/dim. Without (pipe / CI / NO_COLOR): git `--word-diff=plain` markers
73
+ // `[-removed-]`/`{+added+}` so removed-vs-added is unambiguous and assertable.
74
+ const colored = colorEnabled();
75
+ const del = (t: string) => (colored ? style.red(t) : `[-${t}-]`);
76
+ const ins = (t: string) => (colored ? style.green(t) : `{+${t}+}`);
77
+ const eq = (t: string) => (colored ? style.dim(t) : t);
78
+ const out: string[] = [];
79
+ let i = 0;
80
+ let j = 0;
81
+ while (i < m && j < n) {
82
+ if (a[i] === b[j]) {
83
+ out.push(eq(a[i]));
84
+ i++;
85
+ j++;
86
+ } else if (dp[i + 1][j] >= dp[i][j + 1]) {
87
+ out.push(del(a[i++]));
88
+ } else {
89
+ out.push(ins(b[j++]));
90
+ }
91
+ }
92
+ while (i < m) out.push(del(a[i++]));
93
+ while (j < n) out.push(ins(b[j++]));
94
+ return out.join(" ");
95
+ }
96
+
97
+ /**
98
+ * Prefix EVERY line of `text` with the diff indicator — statements are multi-line now that drivers
99
+ * pretty-print display DDL, and a bare continuation line would read as context, not change.
100
+ */
101
+ function mark(text: string, sign: string, color: (s: string) => string): string {
102
+ return text
103
+ .split("\n")
104
+ .map((l) => color(` ${sign} ${l}`))
105
+ .join("\n");
106
+ }
107
+
108
+ /**
109
+ * Render one display item: `+`/`-` line for add/remove. A change renders as separate red `-` /
110
+ * green `+` lines by default, or as a single inline word-diff when `inline` is set (the A/B toggle).
111
+ */
112
+ function renderItem(it: DiffItem, inline = false): string {
113
+ if (it.op === "add") return mark(it.ddl, "+", style.green);
114
+ if (it.op === "remove") return mark(it.ddl, "-", style.red);
115
+ if (inline)
116
+ return ` ${tokenDiff(collapseWs(it.before), collapseWs(it.after))}`;
117
+ return `${mark(it.before, "-", style.red)}\n${mark(it.after, "+", style.green)}`;
118
+ }
119
+
120
+ /** The inline word-diff is a one-line token view — collapse pretty-printed statements onto it. */
121
+ function collapseWs(text: string): string {
122
+ return text.replace(/\s+/g, " ").trim();
123
+ }
124
+
125
+ /**
126
+ * Group diff items by their source file (git-style), so the display reads like a file diff. Items
127
+ * with no file (an older snapshot, or the live DB) fall back to a per-object group headed by the
128
+ * object's bare name. Returns groups in first-seen order, each with its header.
129
+ */
130
+ function groupByFile(
131
+ items: DiffItem[],
132
+ ): { header: string; items: DiffItem[] }[] {
133
+ const order: string[] = [];
134
+ const byKey = new Map<string, DiffItem[]>();
135
+ for (const it of items) {
136
+ const key = it.file ?? `\0${it.table}`; // \0 can't collide with a real path
137
+ let group = byKey.get(key);
138
+ if (!group) {
139
+ group = [];
140
+ byKey.set(key, group);
141
+ order.push(key);
142
+ }
143
+ group.push(it);
144
+ }
145
+ return order.map((key) => {
146
+ const group = byKey.get(key) ?? [];
147
+ return {
148
+ header: group.find((i) => i.file)?.file ?? group[0].table,
149
+ items: group,
150
+ };
151
+ });
152
+ }
153
+
154
+ /** Render display items as a git-style file diff: each group headed by its source file path. */
155
+ export function formatItems(items: DiffItem[], inline = false): string {
156
+ return groupByFile(items)
157
+ .map((g) =>
158
+ [
159
+ style.bold(g.header),
160
+ ...g.items.map((it) => renderItem(it, inline)),
161
+ ].join("\n"),
162
+ )
163
+ .join("\n\n");
164
+ }
165
+
166
+ /**
167
+ * A standard **unified diff** of the change, grouped one section per source file (git-style) — for
168
+ * piping through a diff viewer (git's pager / delta). Objects with no file fall back to a section
169
+ * headed by the object's bare name. A statement may be pretty-printed multi-line, so every LINE gets
170
+ * its own `+`/`-` and the hunk counts count lines, not statements.
171
+ */
172
+ export function formatPatch(diff: Diff): string {
173
+ const items = diff.items ?? [];
174
+ if (!items.length) return "";
175
+ const out: string[] = [];
176
+ for (const { header, items: group } of groupByFile(items)) {
177
+ const lines: string[] = [];
178
+ let dels = 0;
179
+ let adds = 0;
180
+ const push = (text: string, sign: "+" | "-") => {
181
+ for (const l of text.split("\n")) {
182
+ lines.push(`${sign}${l}`);
183
+ if (sign === "+") adds++;
184
+ else dels++;
185
+ }
186
+ };
187
+ for (const it of group) {
188
+ if (it.op === "add") push(it.ddl, "+");
189
+ else if (it.op === "remove") push(it.old, "-");
190
+ else {
191
+ push(it.before, "-");
192
+ push(it.after, "+");
193
+ }
194
+ }
195
+ out.push(
196
+ `diff --git a/${header} b/${header}`,
197
+ `--- a/${header}`,
198
+ `+++ b/${header}`,
199
+ `@@ -${dels ? 1 : 0},${dels} +${adds ? 1 : 0},${adds} @@`,
200
+ ...lines,
201
+ );
202
+ }
203
+ return `${out.join("\n")}\n`;
204
+ }
205
+
206
+ /** `--full`: the whole desired schema — unchanged dim, additions green, changes word-diffed. */
207
+ function formatFull(diff: Diff, inline = false): string {
208
+ const byKey = new Map((diff.items ?? []).map((it) => [it.key, it]));
209
+ const out: string[] = [];
210
+ let prev: string | undefined;
211
+ for (const f of diff.full ?? []) {
212
+ if (prev !== undefined && f.table !== prev) out.push("");
213
+ const it = byKey.get(f.key);
214
+ if (it?.op === "change") out.push(renderItem(it, inline));
215
+ else if (it?.op === "add") out.push(mark(f.ddl, "+", style.green));
216
+ else
217
+ out.push(
218
+ f.ddl
219
+ .split("\n")
220
+ .map((l) => style.dim(` ${l}`))
221
+ .join("\n"),
222
+ );
223
+ prev = f.table;
224
+ }
225
+ const removed = (diff.items ?? []).filter((it) => it.op === "remove");
226
+ if (removed.length) {
227
+ out.push("");
228
+ for (const it of removed) out.push(renderItem(it, inline));
229
+ }
230
+ return out.join("\n");
231
+ }
232
+
233
+ /** A human-readable view of a diff's forward (and optionally reverse) changes. */
234
+ export function formatDiff(
235
+ diff: Diff,
236
+ opts: { down?: boolean; full?: boolean; inline?: boolean } = {},
237
+ ): string {
238
+ if (!diff.up.length) return "No changes.";
239
+ let out = opts.full
240
+ ? formatFull(diff, opts.inline)
241
+ : formatItems(diff.items ?? [], opts.inline);
242
+ if (opts.down) {
243
+ const down = diff.down
244
+ .map((s) =>
245
+ s
246
+ .split("\n")
247
+ .map((l) => style.dim(` ${l}`))
248
+ .join("\n"),
249
+ )
250
+ .join("\n");
251
+ out += `\n\n${style.dim(" rollback (down):")}\n${down}`;
252
+ }
253
+ return out;
254
+ }
255
+
256
+ /**
257
+ * A per-kind breakdown of a set of changes, e.g. `1 Table, 2 Fields`. Counts each item by its
258
+ * structured `kind` and labels it from the registry's per-kind {@link KindRegistry.display} (singular
259
+ * when the count is one), so the summary is correct for every dialect — no DDL parsing.
260
+ */
261
+ export function summarizeKinds(
262
+ registry: KindRegistry,
263
+ items: readonly { kind: string }[],
264
+ ): string {
265
+ const counts = new Map<string, number>();
266
+ for (const it of items) counts.set(it.kind, (counts.get(it.kind) ?? 0) + 1);
267
+ const parts: string[] = [];
268
+ for (const [kind, n] of counts) {
269
+ const d = registry.display(kind);
270
+ parts.push(`${n} ${n === 1 ? d.label : d.plural}`);
271
+ }
272
+ return parts.join(", ");
273
+ }
@@ -0,0 +1,159 @@
1
+ import type { Command } from "commander";
2
+ import type { KindRegistry, PortableObject } from "../kind";
3
+ import { snapshotKinds, snapshotObjects } from "../kind";
4
+ import type { StoredSnapshot } from "./meta";
5
+
6
+ /**
7
+ * Per-kind object filter for `pull`/`diff`/`sync`/`generate`. Each kind is independently
8
+ * included (optionally name-restricted). `DEFINE ACCESS` is OPT-IN everywhere — excluded
9
+ * unless `--access` is given — so an introspection (which redacts access signing keys) can't
10
+ * silently rotate them. Table-scoped objects (fields/indexes/events) follow their table.
11
+ */
12
+ interface Cat {
13
+ on: boolean;
14
+ /** When set, only these names of the kind are included. */
15
+ names?: Set<string>;
16
+ }
17
+
18
+ export interface Filter {
19
+ tables: Cat;
20
+ functions: Cat;
21
+ events: Cat;
22
+ access: Cat;
23
+ }
24
+
25
+ /** Commander's parse of one `--kind [names]` / `--no-kind` flag: `undefined`/`true`/string/`false`. */
26
+ type FlagValue = string | boolean | undefined;
27
+
28
+ export interface FilterOpts {
29
+ tables?: FlagValue;
30
+ functions?: FlagValue;
31
+ events?: FlagValue;
32
+ access?: FlagValue;
33
+ }
34
+
35
+ function cat(v: FlagValue, defaultOn: boolean): Cat {
36
+ if (v === undefined) return { on: defaultOn };
37
+ if (v === false) return { on: false };
38
+ if (v === true) return { on: true };
39
+ const names = new Set(
40
+ v
41
+ .split(",")
42
+ .map((s) => s.trim())
43
+ .filter(Boolean),
44
+ );
45
+ return names.size ? { on: true, names } : { on: true };
46
+ }
47
+
48
+ /** Build a {@link Filter} from CLI flags. Access is opt-in (`--access`); the rest default to on. */
49
+ export function parseFilter(o: FilterOpts): Filter {
50
+ return {
51
+ tables: cat(o.tables, true),
52
+ functions: cat(o.functions, true),
53
+ events: cat(o.events, true),
54
+ access: cat(o.access, false), // DEFINE ACCESS is explicit everywhere — see module note.
55
+ };
56
+ }
57
+
58
+ /** Attach the per-kind `--tables/--functions/--events/--access [names]` (+ `--no-*`) options. */
59
+ export function kindFlags(cmd: Command): Command {
60
+ return cmd
61
+ .option("--tables [names]", "only these tables (comma-separated)")
62
+ .option("--no-tables", "exclude all tables")
63
+ .option("--functions [names]", "only these functions")
64
+ .option("--no-functions", "exclude all functions")
65
+ .option("--events [names]", "only these events")
66
+ .option("--no-events", "exclude all events")
67
+ .option(
68
+ "--access [names]",
69
+ "include access (off by default; key not pulled)",
70
+ )
71
+ .option("--no-access", "exclude all access (the default)");
72
+ }
73
+
74
+ /** Whether a name passes a category gate (kind on + name allowed). Shared with the surreal filters. */
75
+ export const inCat = (c: Cat, name: string): boolean =>
76
+ c.on && (!c.names || c.names.has(name));
77
+
78
+ // The SurrealDB statement/struct filters (`included`/`filterSnapshot`/`mergeSnapshot`/
79
+ // `filterStructured`) live in `./surreal-filter`. Below are the dialect-free kind-registry filters.
80
+
81
+ // --- kind-registry filters (the stored-snapshot path) -------------------------------------------
82
+
83
+ const objKey = (o: PortableObject) => `${o.kind}:${o.name}`;
84
+
85
+ /** Which Filter category gates a TOP-LEVEL kind (and whose name-set its name is matched against). */
86
+ function category(kind: string): keyof Filter {
87
+ if (kind === "function") return "functions";
88
+ if (kind === "access") return "access";
89
+ return "tables"; // a table (and any future top-level structural kind)
90
+ }
91
+
92
+ /**
93
+ * Whether a portable object passes the filter. A TOP-LEVEL object (no owner) is gated by its kind's
94
+ * category + name. An OWNED object (owner set — an index/event/constraint) FOLLOWS its owner table's
95
+ * inclusion; an `event` is ADDITIONALLY gated by the `events` category. (Fields are substrate nested
96
+ * in their table object, never standalone here.)
97
+ */
98
+ export function passesFilter(
99
+ registry: KindRegistry,
100
+ o: PortableObject,
101
+ f: Filter,
102
+ ): boolean {
103
+ const owner = registry.engine(o.kind)?.owner?.(o);
104
+ if (owner) {
105
+ if (!inCat(f.tables, owner.name)) return false;
106
+ return o.kind === "event" ? inCat(f.events, o.name) : true;
107
+ }
108
+ return inCat(f[category(o.kind)], o.name);
109
+ }
110
+
111
+ /** Keep only the portable objects that pass the filter (the {@link filterStructured} analog). */
112
+ export function filterKinds(
113
+ registry: KindRegistry,
114
+ objects: PortableObject[],
115
+ f: Filter,
116
+ ): PortableObject[] {
117
+ return objects.filter((o) => passesFilter(registry, o, f));
118
+ }
119
+
120
+ /**
121
+ * The stored snapshot to persist after a filtered `generate`: INCLUDED objects take their new state
122
+ * from `next`, EXCLUDED objects keep `prev`'s. Dedup by `kind:name` (an included `next` object wins).
123
+ * `files` overlays next on prev.
124
+ */
125
+ export function mergeStored(
126
+ registry: KindRegistry,
127
+ prev: StoredSnapshot,
128
+ next: StoredSnapshot,
129
+ f: Filter,
130
+ ): StoredSnapshot {
131
+ const merged = new Map<string, PortableObject>();
132
+ for (const o of snapshotObjects(prev.schema))
133
+ if (!passesFilter(registry, o, f)) merged.set(objKey(o), o);
134
+ for (const o of snapshotObjects(next.schema))
135
+ if (passesFilter(registry, o, f)) merged.set(objKey(o), o);
136
+ return {
137
+ version: 3,
138
+ driver: next.driver,
139
+ schema: snapshotKinds([...merged.values()], registry),
140
+ files: { ...(prev.files ?? {}), ...(next.files ?? {}) },
141
+ };
142
+ }
143
+
144
+ /**
145
+ * Restrict the `disk` objects to those that ALSO exist `live` (intersect by `kind:name`) AND pass the
146
+ * filter — for `baseline`. Hand-written schema not yet in the DB stays pending; what's really there is
147
+ * captured. Each field/index/event/constraint is its own object, so intersect-by-key handles them.
148
+ */
149
+ export function intersectKinds(
150
+ registry: KindRegistry,
151
+ disk: PortableObject[],
152
+ live: PortableObject[],
153
+ f: Filter,
154
+ ): PortableObject[] {
155
+ const liveKeys = new Set(live.map(objKey));
156
+ return disk.filter(
157
+ (o) => liveKeys.has(objKey(o)) && passesFilter(registry, o, f),
158
+ );
159
+ }