@schemic/core 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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +212 -0
  3. package/lib/authoring.d.ts +89 -0
  4. package/lib/authoring.js +187 -0
  5. package/lib/authoring.js.map +1 -0
  6. package/lib/chunk-C4D6JWSE.js +54 -0
  7. package/lib/chunk-C4D6JWSE.js.map +1 -0
  8. package/lib/chunk-T23RNU7G.js +304 -0
  9. package/lib/chunk-T23RNU7G.js.map +1 -0
  10. package/lib/config-TIiKDd9t.d.ts +97 -0
  11. package/lib/config.d.ts +1 -0
  12. package/lib/config.js +8 -0
  13. package/lib/config.js.map +1 -0
  14. package/lib/driver-Dh5hLKHm.d.ts +736 -0
  15. package/lib/driver.d.ts +150 -0
  16. package/lib/driver.js +47 -0
  17. package/lib/driver.js.map +1 -0
  18. package/lib/index.d.ts +84 -0
  19. package/lib/index.js +794 -0
  20. package/lib/index.js.map +1 -0
  21. package/lib/testing.d.ts +29 -0
  22. package/lib/testing.js +111 -0
  23. package/lib/testing.js.map +1 -0
  24. package/package.json +93 -0
  25. package/src/authoring.ts +304 -0
  26. package/src/cli-kit/config.ts +179 -0
  27. package/src/cli-kit/diff.ts +230 -0
  28. package/src/cli-kit/filter.ts +159 -0
  29. package/src/cli-kit/merge.ts +380 -0
  30. package/src/cli-kit/meta.ts +123 -0
  31. package/src/cli-kit/pager.ts +42 -0
  32. package/src/cli-kit/schema.ts +186 -0
  33. package/src/cli-kit/style.ts +24 -0
  34. package/src/config.ts +51 -0
  35. package/src/connection.ts +78 -0
  36. package/src/driver/driver.ts +300 -0
  37. package/src/driver/index.ts +31 -0
  38. package/src/driver/portable-ir.ts +51 -0
  39. package/src/driver/portable.ts +124 -0
  40. package/src/driver/sdk.ts +66 -0
  41. package/src/index.ts +145 -0
  42. package/src/kind/index.ts +28 -0
  43. package/src/kind/plan.ts +390 -0
  44. package/src/kind/registry.ts +225 -0
  45. package/src/testing.ts +181 -0
@@ -0,0 +1,186 @@
1
+ import { existsSync, readdirSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { Authored, AuthoredDef } from "@schemic/core";
4
+ import { makeJiti } from "./config";
5
+
6
+ /**
7
+ * The NEUTRAL view of a loaded table the engine reads — just `name` plus the `config.relation` flag
8
+ * used for ordering. A driver casts this to its own concrete table builder in `lower`. (The runtime
9
+ * object is the driver's real `TableDef`; the engine never names that type.)
10
+ */
11
+ export interface AnyTable extends Authored {
12
+ readonly config: { readonly relation?: unknown };
13
+ }
14
+
15
+ /**
16
+ * Duck-typed `TableDef` check. We avoid `instanceof` on purpose: the user's schema and the
17
+ * CLI may end up with different module instances of `@schemic/core`, so we recognize a table
18
+ * by shape instead. (Structural access into `emitStatements` works regardless.)
19
+ */
20
+ function isTableDef(v: unknown): v is AnyTable {
21
+ if (!v || typeof v !== "object") return false;
22
+ const t = v as Record<string, unknown>;
23
+ return (
24
+ typeof t.name === "string" &&
25
+ typeof t.fields === "object" &&
26
+ t.fields !== null &&
27
+ typeof t.config === "object" &&
28
+ t.config !== null &&
29
+ typeof t.record === "function"
30
+ );
31
+ }
32
+
33
+ /** Duck-typed standalone-def check (`defineEvent`/`defineFunction`) — see `isTableDef` on why not `instanceof`. */
34
+ function isStandaloneDef(v: unknown): v is AuthoredDef {
35
+ if (!v || typeof v !== "object") return false;
36
+ const d = v as Record<string, unknown>;
37
+ return (
38
+ (d.kind === "event" || d.kind === "function" || d.kind === "access") &&
39
+ typeof d.name === "string"
40
+ );
41
+ }
42
+
43
+ function tsFiles(dir: string): string[] {
44
+ const out: string[] = [];
45
+ for (const entry of readdirSync(dir).sort()) {
46
+ const p = join(dir, entry);
47
+ if (statSync(p).isDirectory()) out.push(...tsFiles(p));
48
+ else if (/\.(ts|mts|js|mjs)$/.test(entry) && !entry.endsWith(".d.ts"))
49
+ out.push(p);
50
+ }
51
+ return out;
52
+ }
53
+
54
+ /** The schema module file(s) for a path: the file itself, or every module under the directory. */
55
+ function schemaFiles(path: string): string[] {
56
+ return statSync(path).isFile() ? [path] : tsFiles(path);
57
+ }
58
+
59
+ /** Import a schema module file and yield its exported tables/relations, paired with the file. */
60
+ async function* tablesIn(
61
+ jiti: ReturnType<typeof makeJiti>,
62
+ file: string,
63
+ ): AsyncGenerator<AnyTable> {
64
+ const mod = (await jiti.import(file)) as Record<string, unknown>;
65
+ for (const value of Object.values(mod)) if (isTableDef(value)) yield value;
66
+ }
67
+
68
+ /**
69
+ * Load every schema object from `schemaPath` (a single `.ts` module, or a directory of them): the
70
+ * tables/relations (ordered normal-before-relation, then by name, for stable DDL) and the standalone
71
+ * defs (`defineEvent`/`defineFunction`). One pass over the files.
72
+ */
73
+ export async function loadDefs(schemaPath: string): Promise<{
74
+ tables: AnyTable[];
75
+ defs: AuthoredDef[];
76
+ /** Absolute source file each table/def was loaded from (for `diff`'s file annotations). */
77
+ fileOf: Map<AnyTable | AuthoredDef, string>;
78
+ }> {
79
+ if (!existsSync(schemaPath)) {
80
+ throw new Error(`Schema path not found: ${schemaPath}`);
81
+ }
82
+ const jiti = makeJiti();
83
+ const tables = new Map<string, AnyTable>();
84
+ const defs: AuthoredDef[] = [];
85
+ const fileOf = new Map<AnyTable | AuthoredDef, string>();
86
+ for (const file of schemaFiles(schemaPath)) {
87
+ const mod = (await jiti.import(file)) as Record<string, unknown>;
88
+ for (const value of Object.values(mod)) {
89
+ if (isTableDef(value)) {
90
+ tables.set(value.name, value); // last def of a name wins
91
+ fileOf.set(value, file);
92
+ } else if (isStandaloneDef(value)) {
93
+ defs.push(value);
94
+ fileOf.set(value, file);
95
+ }
96
+ }
97
+ }
98
+ const rank = (t: AnyTable) => (t.config.relation ? 1 : 0);
99
+ const sorted = [...tables.values()].sort(
100
+ (a, b) => rank(a) - rank(b) || a.name.localeCompare(b.name),
101
+ );
102
+ return { tables: sorted, defs, fileOf };
103
+ }
104
+
105
+ /** The tables/relations from `schemaPath` (standalone events excluded — see {@link loadDefs}). */
106
+ export async function loadSchemas(schemaPath: string): Promise<AnyTable[]> {
107
+ return (await loadDefs(schemaPath)).tables;
108
+ }
109
+
110
+ /** A schema file's exported entities (tables/functions/accesses) and whether it holds ONLY those. */
111
+ export interface LocalFileEntities {
112
+ /** Each schema entity by its export-const identifier + its DB name (table/function/access name). */
113
+ entities: { exportName: string; name: string; kind: "table" | "def" }[];
114
+ /** True when EVERY runtime export of the file is a schema entity (no helpers / other exports). */
115
+ pureSchema: boolean;
116
+ }
117
+
118
+ /**
119
+ * Scan each schema file for the tables/functions/accesses it exports (by export-const name), and
120
+ * whether the file is purely schema. `pull` uses this to find whole-entity local-only schema
121
+ * (entities the live DB doesn't have) and to decide whether a file is safe to delete when mirroring
122
+ * the DB. Standalone events are not whole entities (they attach to a table), so they don't count as
123
+ * entities — a file exporting one is therefore not `pureSchema` and won't be auto-deleted.
124
+ */
125
+ export async function scanLocalEntities(
126
+ schemaPath: string,
127
+ ): Promise<Map<string, LocalFileEntities>> {
128
+ if (!existsSync(schemaPath)) return new Map();
129
+ const jiti = makeJiti();
130
+ const out = new Map<string, LocalFileEntities>();
131
+ for (const file of schemaFiles(schemaPath)) {
132
+ const exports = Object.entries(
133
+ (await jiti.import(file)) as Record<string, unknown>,
134
+ );
135
+ const entities: LocalFileEntities["entities"] = [];
136
+ for (const [exportName, value] of exports) {
137
+ if (isTableDef(value))
138
+ entities.push({ exportName, name: value.name, kind: "table" });
139
+ else if (
140
+ isStandaloneDef(value) &&
141
+ (value.kind === "function" || value.kind === "access")
142
+ )
143
+ entities.push({ exportName, name: value.name, kind: "def" });
144
+ }
145
+ if (entities.length)
146
+ out.set(file, {
147
+ entities,
148
+ pureSchema: entities.length === exports.length,
149
+ });
150
+ }
151
+ return out;
152
+ }
153
+
154
+ /** Map of table name → the file that defines it (for `pull`'s duplicate-definition check). */
155
+ export async function existingTables(
156
+ schemaPath: string,
157
+ ): Promise<Map<string, string>> {
158
+ if (!existsSync(schemaPath)) return new Map();
159
+ const jiti = makeJiti();
160
+ const out = new Map<string, string>();
161
+ for (const file of schemaFiles(schemaPath)) {
162
+ for await (const t of tablesIn(jiti, file)) out.set(t.name, file);
163
+ }
164
+ return out;
165
+ }
166
+
167
+ /**
168
+ * Names defined in more than one place, mapped to the files that define them (a file repeats if it
169
+ * defines the same name twice). `loadSchemas` silently lets the last definition win, so this is how
170
+ * `doctor` surfaces the otherwise-invisible conflict.
171
+ */
172
+ export async function duplicateTables(
173
+ schemaPath: string,
174
+ ): Promise<Map<string, string[]>> {
175
+ if (!existsSync(schemaPath)) return new Map();
176
+ const jiti = makeJiti();
177
+ const seen = new Map<string, string[]>();
178
+ for (const file of schemaFiles(schemaPath)) {
179
+ for await (const t of tablesIn(jiti, file)) {
180
+ const files = seen.get(t.name);
181
+ if (files) files.push(file);
182
+ else seen.set(t.name, [file]);
183
+ }
184
+ }
185
+ return new Map([...seen].filter(([, files]) => files.length > 1));
186
+ }
@@ -0,0 +1,24 @@
1
+ // Tiny ANSI styling, gated on a TTY and honoring NO_COLOR. No dependency.
2
+ /** Whether colored output is enabled (a TTY and `NO_COLOR` unset). */
3
+ export const colorEnabled = () =>
4
+ Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
5
+ const paint = (code: number, s: string) =>
6
+ colorEnabled() ? `\x1b[${code}m${s}\x1b[0m` : s;
7
+
8
+ export const style = {
9
+ green: (s: string) => paint(32, s),
10
+ red: (s: string) => paint(31, s),
11
+ yellow: (s: string) => paint(33, s),
12
+ cyan: (s: string) => paint(36, s),
13
+ dim: (s: string) => paint(90, s),
14
+ bold: (s: string) => paint(1, s),
15
+ };
16
+
17
+ /** A green ✓ success line. */
18
+ export const ok = (s: string) => `${style.green("✓")} ${s}`;
19
+ /** A red ✗ failure line. */
20
+ export const fail = (s: string) => `${style.red("✗")} ${s}`;
21
+
22
+ /** Pluralize `n thing` / `n things`. */
23
+ export const plural = (n: number, word: string) =>
24
+ `${n} ${word}${n === 1 ? "" : "s"}`;
package/src/config.ts ADDED
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Configuration for the `schemic` CLI — author it in `schemic.config.ts`.
3
+ *
4
+ * A project declares one or more named CONNECTIONS, each built by a per-driver factory
5
+ * (`<driver>Connection(...)` exported from `@schemic/<driver>`). Connection values are EXPLICIT —
6
+ * there is no env-var magic; read env yourself where you want it (`url: process.env.MY_URL`).
7
+ * See `@schemic/core` docs/MULTI-CONNECTION.md.
8
+ *
9
+ * ```ts
10
+ * import { defineConfig } from "@schemic/core/config";
11
+ * import { surrealConnection } from "@schemic/surrealdb";
12
+ *
13
+ * export default defineConfig({
14
+ * connections: {
15
+ * default: surrealConnection({
16
+ * schema: "./database/schema",
17
+ * url: "ws://localhost:8000",
18
+ * namespace: "app",
19
+ * database: "app",
20
+ * }),
21
+ * },
22
+ * });
23
+ * ```
24
+ *
25
+ * For MULTIPLE databases (multi-tenant / heterogeneous / DB-per-user), add more named connections;
26
+ * a connection may be a resolver (incl. an array → a collection). See docs/MULTI-CONNECTION.md.
27
+ *
28
+ * NOTE: this file is dialect-NEUTRAL. Driver-specific connection shapes (SurrealDB's
29
+ * url/namespace/authLevel, its check-engine options, …) live in the driver package's
30
+ * `<driver>Connection` factory, not here.
31
+ */
32
+ import type { ConnectionEntry } from "./connection";
33
+
34
+ export interface SchemicConfig {
35
+ /** Named database connections — each produced by a per-driver `<driver>Connection(...)` factory. */
36
+ connections: Record<string, ConnectionEntry>;
37
+ /**
38
+ * With more than one connection, the connection a bare command targets (must name a single static
39
+ * connection). Absent + ambiguous → a live command errors asking for `--connection`.
40
+ */
41
+ defaultConnection?: string;
42
+ /** Table that records applied migrations (per connection). Default `_migrations`. */
43
+ migrationsTable?: string;
44
+ /** Optional seed script run by `schemic seed`. */
45
+ seed?: string;
46
+ }
47
+
48
+ /** Identity helper that types a `schemic.config.ts` default export. */
49
+ export function defineConfig(config: SchemicConfig): SchemicConfig {
50
+ return config;
51
+ }
@@ -0,0 +1,78 @@
1
+ // The neutral MULTI-CONNECTION contract (design: docs/MULTI-CONNECTION.md). A project's config maps
2
+ // names to CONNECTIONS; each is produced by a per-driver `<driver>Connection(...)` factory that wraps
3
+ // {@link connectionEntry} with its own typed connection shape. Everything here is dialect-free — the
4
+ // CLI reads only these neutral fields; driver-specific connection params ride on the driver's own
5
+ // config type. The resolution engine (lazy DAG, fan-out, addressing) lives in the CLI layer.
6
+
7
+ type MaybePromise<T> = T | Promise<T>;
8
+
9
+ /** The dialect-neutral fields the orchestration reads off every connection config. */
10
+ export interface ConnectionConfigBase {
11
+ /** Schema dir (the desired state + its migration files/snapshot). Shared dir = shared schema. */
12
+ schema: string;
13
+ /** Address within a COLLECTION (array-returning resolver) — `<name>:<key>`. Required on array entries. */
14
+ key?: string;
15
+ /** Migrations dir override; defaults relative to `schema`. */
16
+ migrations?: string;
17
+ }
18
+
19
+ /** A live, queryable handle to ANOTHER (already-resolved) connection, for use inside a resolver. */
20
+ export interface ResolvedConnectionHandle {
21
+ query<T = unknown>(sql: string, vars?: Record<string, unknown>): Promise<T[]>;
22
+ }
23
+
24
+ /**
25
+ * What a connection RESOLVER receives. `connections` is a LAZY proxy of the other connections —
26
+ * touching one resolves + connects it on demand (so the dependency graph falls out of access; cycles
27
+ * error). `args` are CLI `--arg k=v` values (so a resolver can yield a SUBSET without resolving all).
28
+ */
29
+ export interface ResolveContext {
30
+ connections: Record<string, ResolvedConnectionHandle>;
31
+ args: Record<string, string>;
32
+ env: NodeJS.ProcessEnv;
33
+ }
34
+
35
+ /**
36
+ * The opaque, branded output of a `<driver>Connection(...)` factory — the only thing `defineConfig`'s
37
+ * `connections` map accepts. Never hand-authored. `driver` is the package the CLI dynamically loads;
38
+ * `resolve` always normalizes to an ARRAY (a single connection -> one element, a collection -> many).
39
+ */
40
+ export interface ConnectionEntry {
41
+ readonly __schemic: "connection";
42
+ readonly driver: string;
43
+ resolve(ctx: ResolveContext): Promise<ConnectionConfigBase[]>;
44
+ }
45
+
46
+ /** A connection factory's input: a static config, or a resolver yielding one config or a keyed collection. */
47
+ export type ConnectionInput<C extends ConnectionConfigBase> =
48
+ | C
49
+ | ((ctx: ResolveContext) => MaybePromise<C | (C & { key: string })[]>);
50
+
51
+ /**
52
+ * Build a {@link ConnectionEntry} from a driver tag + a static config or resolver — the primitive each
53
+ * driver package wraps in its typed `<driver>Connection(...)` factory (which fixes `C` to the driver's
54
+ * own connection shape and overloads the array form to require `key`). Returns a branded entry whose
55
+ * `resolve` always yields an array.
56
+ */
57
+ export function connectionEntry<C extends ConnectionConfigBase>(
58
+ driver: string,
59
+ input: ConnectionInput<C>,
60
+ ): ConnectionEntry {
61
+ return {
62
+ __schemic: "connection",
63
+ driver,
64
+ async resolve(ctx) {
65
+ const out = typeof input === "function" ? await input(ctx) : input;
66
+ return Array.isArray(out) ? out : [out];
67
+ },
68
+ };
69
+ }
70
+
71
+ /** Type guard: is a `connections` map value a real factory output (vs a stray object)? */
72
+ export function isConnectionEntry(v: unknown): v is ConnectionEntry {
73
+ return (
74
+ typeof v === "object" &&
75
+ v !== null &&
76
+ (v as { __schemic?: unknown }).__schemic === "connection"
77
+ );
78
+ }
@@ -0,0 +1,300 @@
1
+ // The DRIVER interface — the dialect seam for multi-DB support (see docs/MULTI-DB-SPIKE.md).
2
+ //
3
+ // Everything dialect-specific lives behind a `Driver`: lowering authoring to the Struct-IR, emitting
4
+ // DDL, introspecting a live DB, normalizing to a canonical form, and executing. Everything ABOVE the
5
+ // driver (the diff algorithm, the magicast TS-merge, the migration model, the CLI shell) stays
6
+ // dialect-free and calls these ops.
7
+ //
8
+ // The connection type is a driver-private parameter `Conn`: the orchestration treats it opaquely and
9
+ // only ever hands it back to the SAME driver. So the Surreal driver is `Driver<Surreal>`, a future
10
+ // Postgres driver is `Driver<PgClient>`, and core never sees either concrete type. The AUTHORING
11
+ // types (`Tbl`/`Def`) are driver-private the same way — opaque to core beyond the neutral
12
+ // `Authored`/`AuthoredDef` bounds — so the neutral engine never names a dialect's concrete builder
13
+ // (`TableDef`/`StandaloneDef`).
14
+
15
+ import type { ResolvedConfig } from "../cli-kit/config";
16
+ import type { Diff } from "../cli-kit/diff";
17
+ import type { Filter } from "../cli-kit/filter";
18
+ import type { PullPlan } from "../cli-kit/merge";
19
+ import type { Definable, KindRegistry, PortableObject } from "../kind";
20
+
21
+ /**
22
+ * The dialect-NEUTRAL authoring contract — the only structure the orchestration reads off an
23
+ * authored object (everything else is opaque and handed straight to {@link Driver.explode}). A table
24
+ * contributes just its `name`; this is the upper bound for a driver's table-authoring type. The
25
+ * Surreal `TableDef` is a structural subtype, as is any future dialect's table builder.
26
+ */
27
+ export interface Authored {
28
+ readonly name: string;
29
+ }
30
+
31
+ /**
32
+ * The neutral contract for a standalone (non-table) authored object — an event/function/access. It
33
+ * adds a `kind` discriminant and, for objects owned by a table (e.g. an event), the owner `table`
34
+ * name (so the snapshot can file-link a child object under its parent). The Surreal `StandaloneDef`
35
+ * union is a structural subtype.
36
+ */
37
+ export interface AuthoredDef extends Authored {
38
+ readonly kind: string;
39
+ readonly table?: string;
40
+ }
41
+
42
+ /**
43
+ * A single emitted DDL statement, structured: object identity (`kind`/`name`/`table`) + the dialect
44
+ * `ddl` string, plus an optional clause map (each value an `ALTER … <set>` form) for dialects that
45
+ * diff clause-level. `kind` is a dialect-defined string the orchestration treats opaquely — the
46
+ * SurrealDB `DefineStatement` (with its fixed kind union) is a structural subtype of this.
47
+ */
48
+ export interface Statement {
49
+ kind: string;
50
+ name: string;
51
+ table?: string;
52
+ ddl: string;
53
+ clauses?: Record<string, string>;
54
+ }
55
+
56
+ /** Options for {@link Driver.emit} — mirrors the existing `DefineOptions` (e.g. IF NOT EXISTS). */
57
+ export interface EmitOptions {
58
+ ifNotExists?: boolean;
59
+ overwrite?: boolean;
60
+ }
61
+
62
+ /** Options for {@link Driver.apply}. */
63
+ export interface ApplyOptions {
64
+ /**
65
+ * Run the whole batch atomically. `migrate` wraps up/down + `_migrations` bookkeeping in one
66
+ * transaction; a driver that can't MUST surface that (the migration model degrades to best-effort).
67
+ */
68
+ transactional?: boolean;
69
+ }
70
+
71
+ /** Per-connection overrides (url/namespace/credentials) — superset across dialects. */
72
+ export interface ConnectionOverrides {
73
+ url?: string;
74
+ namespace?: string;
75
+ database?: string;
76
+ username?: string;
77
+ password?: string;
78
+ authLevel?: string;
79
+ }
80
+
81
+ /** The direction a migration is applied in. */
82
+ export type MigrationDirection = "up" | "down";
83
+
84
+ /** A migration's bookkeeping identity, recorded in the migrations-tracking table. */
85
+ export interface MigrationRecord {
86
+ tag: string;
87
+ file: string;
88
+ /** sha of the migration file at apply time (drift detection). */
89
+ checksum: string;
90
+ }
91
+
92
+ /**
93
+ * The apply-time, dialect-specific half of the migration runner. The orchestration (which
94
+ * migrations are pending, ordering, the lock-then-loop) stays driver-neutral in cli/migrate.ts;
95
+ * this capability owns the SQL: the tracking table, the applied-records, the advisory lock, and the
96
+ * atomic apply+record. A driver WITHOUT it can't run migrations (diff/gen still work). `Conn` is the
97
+ * driver's own connection type.
98
+ */
99
+ export interface MigrationStore<Conn = unknown> {
100
+ /** This dialect's migration-file extension, e.g. `".surql"` (SurrealDB) or `".sql"` (Postgres). */
101
+ readonly extension: string;
102
+ /** Render a diff as this dialect's migration-file body (e.g. SurrealQL `IF $direction` up/down). */
103
+ render(tag: string, diff: Diff): string;
104
+ /** Ensure the migrations-tracking table exists. */
105
+ ensure(conn: Conn, table: string): Promise<void>;
106
+ /** Applied migrations: tag -> checksum recorded at apply time. */
107
+ applied(conn: Conn, table: string): Promise<Map<string, string>>;
108
+ /**
109
+ * Apply one migration's `up`/`down` PROGRAM plus its bookkeeping write atomically: on `up` record
110
+ * the migration, on `down` erase it — so the record is written iff the DDL actually applied.
111
+ */
112
+ apply(
113
+ conn: Conn,
114
+ table: string,
115
+ m: {
116
+ content: string;
117
+ direction: MigrationDirection;
118
+ record: MigrationRecord;
119
+ },
120
+ ): Promise<void>;
121
+ /** Record a migration as applied WITHOUT running its DDL (baseline of an existing DB). */
122
+ record(conn: Conn, table: string, record: MigrationRecord): Promise<void>;
123
+ /** Drop all applied records (baseline-squash reconcile). */
124
+ clear(conn: Conn, table: string): Promise<void>;
125
+ /** Take an advisory lock so two runs can't race — throws if already held. */
126
+ lock(conn: Conn, table: string): Promise<void>;
127
+ /** Release the advisory lock (idempotent). */
128
+ unlock(conn: Conn, table: string): Promise<void>;
129
+ }
130
+
131
+ /**
132
+ * An OPTIONAL throwaway-instance capability for round-trip canonicalization and `sz check`'s
133
+ * migration replay. Absent -> `check`/replay-verification is degraded/unavailable (diff/apply still
134
+ * work, since a kind's `lower`/`introspectAll` already canonicalize).
135
+ */
136
+ export interface ShadowCapability<Conn> {
137
+ /** Apply `ddl` to a fresh scratch DB, introspect it back to portable objects, then drop it. */
138
+ roundTrip(
139
+ conn: Conn,
140
+ config: ResolvedConfig,
141
+ ddl: string,
142
+ ): Promise<PortableObject[]>;
143
+ /** Spin up a fully-isolated ephemeral instance (for migration replay). Caller must `stop()`. */
144
+ ephemeral?(): Promise<{ conn: Conn; stop: () => Promise<void> }>;
145
+ }
146
+
147
+ /**
148
+ * A database dialect, expressed as a SET OF KINDS (core-v2). The driver registers its object kinds on
149
+ * `registry`; core orchestrates schema ops GENERICALLY over it (`lowerSchema`/`buildKindDiff`/
150
+ * `emitKinds`/`orderObjects`) — it never names a kind. The driver owns only what isn't generic: the
151
+ * authoring -> kinded `explode`, a single-read `introspectAll`, the connection lifecycle, and the
152
+ * dialect-specific command capabilities. The field/type substrate (`PortableType`/`s.*`) stays core.
153
+ * See docs/kind-registry-flip-plan.md.
154
+ */
155
+ export interface Driver<
156
+ Conn = unknown,
157
+ Tbl extends Authored = Authored,
158
+ Def extends AuthoredDef = AuthoredDef,
159
+ > {
160
+ readonly name: string;
161
+
162
+ // --- kind registry (the schema engine) -----------------------------------------------------
163
+ /** This driver's registered KINDS. Core runs lower/diff/emit/order generically over it. */
164
+ readonly registry: KindRegistry;
165
+ /**
166
+ * Authoring (loaded `defineTable`/standalone defs) -> kinded {@link Definable}s. The driver-side
167
+ * fan-out: one inline-authored table explodes into `[table, ...index, ...event/constraint]`, each
168
+ * tagged with its `kind`. Core then lowers via `lowerSchema(registry, explode(...))` — so
169
+ * `KindEngine.lower` stays 1:1 and the contract needs no explode hook.
170
+ */
171
+ explode(tables: Tbl[], defs: Def[]): Definable[];
172
+ /**
173
+ * Live connection -> ALL portable objects, fanned across kinds from ONE read (INFO STRUCTURE /
174
+ * pg_catalog). Must canonicalize IDENTICALLY to lowering (a clean apply round-trips to a zero diff)
175
+ * and be COMPLETE (return every diffable kind, else presence-phantom-diffs). `exclude` skips tables
176
+ * by name.
177
+ */
178
+ introspectAll(conn: Conn, exclude?: Set<string>): Promise<PortableObject[]>;
179
+
180
+ // --- execution -----------------------------------------------------------------------------
181
+ connect(config: ResolvedConfig, over?: ConnectionOverrides): Promise<Conn>;
182
+ apply(conn: Conn, statements: string[], opts?: ApplyOptions): Promise<void>;
183
+ /** Tear down a connection opened by {@link connect} (the orchestration owns the lifecycle). */
184
+ close(conn: Conn): Promise<void>;
185
+
186
+ // --- optional capabilities -----------------------------------------------------------------
187
+ readonly shadow?: ShadowCapability<Conn>;
188
+ /** Apply-time migration bookkeeping. Absent -> this driver can't run migrations (diff/gen still do). */
189
+ readonly migrations?: MigrationStore<Conn>;
190
+
191
+ // --- optional COMMAND capabilities ---------------------------------------------------------
192
+ // The dialect-agnostic CLI routes each schema-syncing command through one of these. A driver that
193
+ // omits a capability makes that command unavailable on it — the CLI never hardcodes `if surreal`.
194
+
195
+ /**
196
+ * Diff the LIVE database against the loaded schema into executable up/down DDL. Owns every
197
+ * dialect-specific normalization and apply-time fixup (Surreal: a shadow-DB round-trip to cancel
198
+ * formatting noise, the redacted-access-key swap, and the implicit-wildcard OVERWRITE re-mark), so
199
+ * the result is safe to apply as-is. Backs `diff --live`, `push`, and the baseline reconcile.
200
+ */
201
+ diffLive?(conn: Conn, config: ResolvedConfig, filter: Filter): Promise<Diff>;
202
+ /** Reduce a live diff (from {@link diffLive}) to the statements `push` applies; `prune: false` keeps removals. */
203
+ syncPlan?(diff: Diff, prune?: boolean): string[];
204
+ /**
205
+ * Render portable objects to per-file source in THIS dialect's `s.*` syntax, filtered — the codegen
206
+ * behind the offline `diff --ts` and `pull`. Takes `PortableObject[]` (this driver's own portable
207
+ * shape): `diff --ts` renders the SNAPSHOT side (stored portable) and the DESIRED side
208
+ * (`lowerSchema(explode(...))`) at MATCHING fidelity so an in-sync schema yields identical files;
209
+ * `pull` renders the introspected DB. The driver re-derives its structured form from the portable
210
+ * objects (parsing its own DDL where needed — docs/kind-registry-flip-plan.md §6b). `single` (a file
211
+ * key) folds everything into one module; otherwise `fileFor` maps each object to its own file.
212
+ */
213
+ renderSchema?(
214
+ objects: PortableObject[],
215
+ filter: Filter,
216
+ fileFor: (kind: string, name: string) => string,
217
+ single?: string,
218
+ ): Map<string, string>;
219
+ /**
220
+ * The two sides of `diff --ts --live` rendered to per-file source: the live DB (`current`) and the
221
+ * declared schema (`desired`), both normalized through the dialect so an unchanged schema yields
222
+ * identical files.
223
+ */
224
+ diffTsLive?(
225
+ conn: Conn,
226
+ config: ResolvedConfig,
227
+ filter: Filter,
228
+ fileFor: (kind: string, name: string) => string,
229
+ single?: string,
230
+ ): Promise<{ current: Map<string, string>; desired: Map<string, string> }>;
231
+ /**
232
+ * Replay every migration into a throwaway engine and diff the result against the schema (`check`).
233
+ * Owns ephemeral-engine selection + setup; `log` receives progress lines. Needs a {@link shadow}-
234
+ * class capability. An empty diff means the migrations reproduce the schema.
235
+ */
236
+ checkReplay?(
237
+ config: ResolvedConfig,
238
+ over: ConnectionOverrides,
239
+ filter: Filter,
240
+ log: (msg: string) => void,
241
+ ): Promise<Diff>;
242
+ /** Introspect the live DB and plan schema-file codegen (`pull`); writing is the neutral `applyPull`. */
243
+ planPull?(
244
+ conn: Conn,
245
+ config: ResolvedConfig,
246
+ opts: { filter: Filter; keepLocal?: boolean },
247
+ ): Promise<PullPlan>;
248
+ /** A human-readable server identity for `doctor` (e.g. "SurrealDB 3.1.3"); throws if unreachable. */
249
+ serverInfo?(conn: Conn): Promise<string>;
250
+ /**
251
+ * Run a raw READ query and return rows — for connection RESOLVERS (a multi-connection resolver's
252
+ * `ctx.connections.<name>.query(...)`) and `seed`. The `sql` is this dialect's query language; the
253
+ * orchestration treats the rows opaquely. Absent -> a resolver can't read from this connection.
254
+ */
255
+ query?<T = unknown>(
256
+ conn: Conn,
257
+ sql: string,
258
+ vars?: Record<string, unknown>,
259
+ ): Promise<T[]>;
260
+ /**
261
+ * The dialect-specific files `schemic init` scaffolds, keyed by project-relative path: a
262
+ * connections-only `schemic.config.ts` (using this driver's `<driver>Connection` factory), a sample
263
+ * schema module in this dialect's `s.*`, a seed stub, a `.env.example`, … The CLI writes them
264
+ * verbatim (never overwriting) alongside the dialect-neutral migration snapshot it records itself.
265
+ * Absent -> `schemic init` can't scaffold a project for this driver.
266
+ */
267
+ initScaffold?(): Record<string, string>;
268
+ /**
269
+ * Scaffold a NEW entity file's contents — the starter `s.*` / `define*` module for an object of
270
+ * `kind` named `name` (e.g. `("table", "user")` -> a `defineTable("user", { … })` module in this
271
+ * dialect's authoring). Returns the file text; the CLI writes it under the kind's
272
+ * {@link KindRegistry.display} folder. THROW for a kind this driver can't author (the CLI surfaces
273
+ * the message). Absent -> `schemic new` is unavailable for this driver.
274
+ */
275
+ scaffoldEntity?(kind: string, name: string): string;
276
+ }
277
+
278
+ // --- Registry -----------------------------------------------------------------------------------
279
+
280
+ const REGISTRY = new Map<string, Driver<unknown>>();
281
+
282
+ /** Register a driver under its `name` (idempotent; last write wins). */
283
+ export function registerDriver(driver: Driver<unknown>): void {
284
+ REGISTRY.set(driver.name, driver);
285
+ }
286
+
287
+ /** Look up a registered driver, or throw with the list of known names. */
288
+ export function getDriver(name: string): Driver<unknown> {
289
+ const d = REGISTRY.get(name);
290
+ if (!d) {
291
+ const known = [...REGISTRY.keys()].join(", ") || "(none registered)";
292
+ throw new Error(`Unknown database driver "${name}". Registered: ${known}.`);
293
+ }
294
+ return d;
295
+ }
296
+
297
+ /** All registered driver names (for help text / config validation). */
298
+ export function driverNames(): string[] {
299
+ return [...REGISTRY.keys()];
300
+ }
@@ -0,0 +1,31 @@
1
+ // The driver layer — the multi-DB seam (see docs/MULTI-DB-SPIKE.md).
2
+
3
+ export type {
4
+ ApplyOptions,
5
+ Authored,
6
+ AuthoredDef,
7
+ ConnectionOverrides,
8
+ Driver,
9
+ EmitOptions,
10
+ MigrationDirection,
11
+ MigrationRecord,
12
+ MigrationStore,
13
+ ShadowCapability,
14
+ Statement,
15
+ } from "./driver";
16
+ export { driverNames, getDriver, registerDriver } from "./driver";
17
+ export type {
18
+ GeometryKind,
19
+ PortableType,
20
+ ScalarName,
21
+ } from "./portable";
22
+ export {
23
+ array,
24
+ literal,
25
+ nullable,
26
+ option,
27
+ record,
28
+ scalar,
29
+ union,
30
+ } from "./portable";
31
+ export type { PortableField, PortablePermissions } from "./portable-ir";