@metaobjectsdev/migrate-ts 0.5.0 → 0.6.0-rc.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 (73) hide show
  1. package/README.md +34 -0
  2. package/dist/emit/d1-safety-pass.d.ts +15 -0
  3. package/dist/emit/d1-safety-pass.d.ts.map +1 -0
  4. package/dist/emit/d1-safety-pass.js +80 -0
  5. package/dist/emit/d1-safety-pass.js.map +1 -0
  6. package/dist/emit/d1.d.ts +3 -0
  7. package/dist/emit/d1.d.ts.map +1 -0
  8. package/dist/emit/d1.js +11 -0
  9. package/dist/emit/d1.js.map +1 -0
  10. package/dist/emit/index.d.ts.map +1 -1
  11. package/dist/emit/index.js +2 -0
  12. package/dist/emit/index.js.map +1 -1
  13. package/dist/emit/postgres.js +28 -3
  14. package/dist/emit/postgres.js.map +1 -1
  15. package/dist/emit/sqlite.d.ts +1 -1
  16. package/dist/emit/sqlite.d.ts.map +1 -1
  17. package/dist/emit/sqlite.js.map +1 -1
  18. package/dist/errors.d.ts +17 -0
  19. package/dist/errors.d.ts.map +1 -1
  20. package/dist/errors.js +28 -0
  21. package/dist/errors.js.map +1 -1
  22. package/dist/expected-schema.d.ts +7 -7
  23. package/dist/expected-schema.d.ts.map +1 -1
  24. package/dist/expected-schema.js +78 -35
  25. package/dist/expected-schema.js.map +1 -1
  26. package/dist/index.d.ts +7 -1
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +12 -1
  29. package/dist/index.js.map +1 -1
  30. package/dist/introspect/d1.d.ts +21 -0
  31. package/dist/introspect/d1.d.ts.map +1 -0
  32. package/dist/introspect/d1.js +149 -0
  33. package/dist/introspect/d1.js.map +1 -0
  34. package/dist/introspect/index.d.ts.map +1 -1
  35. package/dist/introspect/index.js +1 -0
  36. package/dist/introspect/index.js.map +1 -1
  37. package/dist/introspect/sqlite-shared.d.ts +14 -0
  38. package/dist/introspect/sqlite-shared.d.ts.map +1 -0
  39. package/dist/introspect/sqlite-shared.js +74 -0
  40. package/dist/introspect/sqlite-shared.js.map +1 -0
  41. package/dist/introspect/sqlite.js +1 -73
  42. package/dist/introspect/sqlite.js.map +1 -1
  43. package/dist/referential-actions.d.ts +49 -0
  44. package/dist/referential-actions.d.ts.map +1 -0
  45. package/dist/referential-actions.js +110 -0
  46. package/dist/referential-actions.js.map +1 -0
  47. package/dist/types.d.ts +15 -1
  48. package/dist/types.d.ts.map +1 -1
  49. package/dist/wrangler-config.d.ts +25 -0
  50. package/dist/wrangler-config.d.ts.map +1 -0
  51. package/dist/wrangler-config.js +91 -0
  52. package/dist/wrangler-config.js.map +1 -0
  53. package/dist/write-migration-d1.d.ts +17 -0
  54. package/dist/write-migration-d1.d.ts.map +1 -0
  55. package/dist/write-migration-d1.js +50 -0
  56. package/dist/write-migration-d1.js.map +1 -0
  57. package/package.json +28 -27
  58. package/src/emit/d1-safety-pass.ts +94 -0
  59. package/src/emit/d1.ts +16 -0
  60. package/src/emit/index.ts +2 -0
  61. package/src/emit/postgres.ts +35 -3
  62. package/src/emit/sqlite.ts +1 -1
  63. package/src/errors.ts +33 -0
  64. package/src/expected-schema.ts +100 -52
  65. package/src/index.ts +22 -1
  66. package/src/introspect/d1.ts +185 -0
  67. package/src/introspect/index.ts +1 -0
  68. package/src/introspect/sqlite-shared.ts +77 -0
  69. package/src/introspect/sqlite.ts +2 -69
  70. package/src/referential-actions.ts +134 -0
  71. package/src/types.ts +15 -1
  72. package/src/wrangler-config.ts +103 -0
  73. package/src/write-migration-d1.ts +74 -0
@@ -0,0 +1,185 @@
1
+ import type {
2
+ SchemaSnapshot, TableDescriptor, ColumnDescriptor, SnapshotMeta,
3
+ IndexDescriptor, FkDescriptor, FkAction, ViewDescriptor,
4
+ } from "../types.js";
5
+ import { parseSqliteDefault, sqliteTypeToSqlType, sqliteRuleToAction } from "./sqlite-shared.js";
6
+
7
+ /**
8
+ * Runner contract: takes a SQL command string and returns wrangler's raw
9
+ * JSON envelope stdout. The CLI wires this to a real exec; tests pass a mock.
10
+ * The runner is responsible for ALL transport concerns (local vs remote,
11
+ * config path, error mapping). introspectD1 only knows about SQL queries.
12
+ */
13
+ export type D1Runner = (sql: string) => Promise<string>;
14
+
15
+ /** Private shorthand for the exec helper used throughout this module. */
16
+ type Exec = (sql: string) => Promise<Record<string, unknown>[]>;
17
+
18
+ export interface IntrospectD1Options {
19
+ runner: D1Runner;
20
+ /**
21
+ * Documented passthrough — the CLI wiring uses binding/remote/configPath to
22
+ * construct the runner; introspectD1 itself only dispatches SQL via opts.runner.
23
+ * They live on the options so the wiring contract is self-documenting at the call site.
24
+ */
25
+ binding: string;
26
+ remote: boolean;
27
+ configPath: string | undefined;
28
+ }
29
+
30
+ export async function introspectD1(opts: IntrospectD1Options): Promise<SchemaSnapshot> {
31
+ const exec: Exec = async (sql: string) => {
32
+ const stdout = await opts.runner(sql);
33
+ return parseEnvelope(stdout);
34
+ };
35
+
36
+ const versionRows = await exec("SELECT sqlite_version() AS v");
37
+ const meta: SnapshotMeta = {
38
+ sqliteVersion: String(versionRows[0]?.v ?? "0.0.0"),
39
+ };
40
+
41
+ const tableRows = await exec(
42
+ "SELECT name, sql FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '__new_%' ORDER BY name",
43
+ );
44
+
45
+ const tables: TableDescriptor[] = [];
46
+ for (const t of tableRows) {
47
+ const name = String(t.name);
48
+ const createSql = String(t.sql ?? "");
49
+ // Issue pragma_table_info ONCE per table; extractColumns + extractPrimaryKey
50
+ // consume the same rows without re-querying (each query is a wrangler round-trip).
51
+ const tableInfoRows = await readTableInfo(exec, name);
52
+ const cols = extractColumns(tableInfoRows);
53
+ const pk = extractPrimaryKey(tableInfoRows);
54
+ const hasAutoincrement = createSql.toUpperCase().includes("AUTOINCREMENT");
55
+ if (hasAutoincrement && pk.length === 1) {
56
+ const pkCol = cols.find((c) => c.name === pk[0]);
57
+ if (pkCol) pkCol.identity = "increment";
58
+ }
59
+ tables.push({
60
+ name,
61
+ columns: cols,
62
+ indexes: await readIndexes(exec, name),
63
+ foreignKeys: await readForeignKeys(exec, name),
64
+ primaryKey: pk,
65
+ });
66
+ }
67
+
68
+ const views = await readViews(exec);
69
+ return { tables, views, meta };
70
+ }
71
+
72
+ function parseEnvelope(stdout: string): Record<string, unknown>[] {
73
+ let parsed: unknown;
74
+ try {
75
+ parsed = JSON.parse(stdout);
76
+ } catch (err) {
77
+ throw new Error(`failed to parse wrangler JSON output: ${(err as Error).message}`);
78
+ }
79
+ if (!Array.isArray(parsed) || parsed.length === 0) {
80
+ throw new Error(`unexpected wrangler output shape (expected non-empty array envelope): ${stdout.slice(0, 200)}`);
81
+ }
82
+ const envelope = parsed[0];
83
+ if (envelope === null || typeof envelope !== "object") {
84
+ throw new Error(`unexpected wrangler output shape (envelope is not an object): ${stdout.slice(0, 200)}`);
85
+ }
86
+ const env = envelope as { success?: boolean; error?: string; results?: unknown };
87
+ if (env.success === false) {
88
+ throw new Error(`wrangler d1 execute failed: ${env.error ?? "(no error message)"}`);
89
+ }
90
+ const results = env.results;
91
+ if (!Array.isArray(results)) return [];
92
+ return results as Record<string, unknown>[];
93
+ }
94
+
95
+ async function readTableInfo(exec: Exec, table: string): Promise<Record<string, unknown>[]> {
96
+ return exec(`SELECT * FROM pragma_table_info(${sqliteIdent(table)}) ORDER BY cid`);
97
+ }
98
+
99
+ function extractColumns(rows: Record<string, unknown>[]): ColumnDescriptor[] {
100
+ return rows.map((r) => {
101
+ const col: ColumnDescriptor = {
102
+ name: String(r.name),
103
+ sqlType: sqliteTypeToSqlType(String(r.type)),
104
+ nullable: Number(r.notnull) === 0 && Number(r.pk) === 0,
105
+ };
106
+ const def = parseSqliteDefault(r.dflt_value === null ? null : String(r.dflt_value));
107
+ if (def) col.default = def;
108
+ return col;
109
+ });
110
+ }
111
+
112
+ function extractPrimaryKey(rows: Record<string, unknown>[]): string[] {
113
+ return rows
114
+ .filter((r) => Number(r.pk) > 0)
115
+ .sort((a, b) => Number(a.pk) - Number(b.pk))
116
+ .map((r) => String(r.name));
117
+ }
118
+
119
+ async function readIndexes(exec: Exec, table: string): Promise<IndexDescriptor[]> {
120
+ const list = await exec(`SELECT * FROM pragma_index_list(${sqliteIdent(table)})`);
121
+ const indexes: IndexDescriptor[] = [];
122
+ for (const ix of list) {
123
+ if (String(ix.origin) === "pk") continue;
124
+ if (Number(ix.partial) === 1) continue;
125
+ const ixName = String(ix.name);
126
+ const cols = await exec(`SELECT seqno, cid, name FROM pragma_index_info(${sqliteIdent(ixName)}) ORDER BY seqno`);
127
+ indexes.push({
128
+ name: ixName,
129
+ columns: cols.map((c) => String(c.name)),
130
+ unique: Number(ix.unique) === 1,
131
+ });
132
+ }
133
+ return indexes;
134
+ }
135
+
136
+ async function readForeignKeys(exec: Exec, table: string): Promise<FkDescriptor[]> {
137
+ const rows = await exec(`SELECT * FROM pragma_foreign_key_list(${sqliteIdent(table)}) ORDER BY id, seq`);
138
+ const byId = new Map<number, { refTable: string; cols: string[]; refCols: string[]; onDelete: FkAction; onUpdate: FkAction; }>();
139
+ for (const r of rows) {
140
+ const id = Number(r.id);
141
+ let entry = byId.get(id);
142
+ if (!entry) {
143
+ entry = {
144
+ refTable: String(r.table),
145
+ cols: [],
146
+ refCols: [],
147
+ onDelete: sqliteRuleToAction(String(r.on_delete)),
148
+ onUpdate: sqliteRuleToAction(String(r.on_update)),
149
+ };
150
+ byId.set(id, entry);
151
+ }
152
+ entry.cols.push(String(r.from));
153
+ entry.refCols.push(String(r.to));
154
+ }
155
+ return Array.from(byId.entries()).map(([_id, v]) => {
156
+ const fk: FkDescriptor = {
157
+ name: `${table}_${v.cols.join("_")}_fk`,
158
+ columns: v.cols,
159
+ refTable: v.refTable,
160
+ refColumns: v.refCols,
161
+ };
162
+ if (v.onDelete !== "no-action") fk.onDelete = v.onDelete;
163
+ if (v.onUpdate !== "no-action") fk.onUpdate = v.onUpdate;
164
+ return fk;
165
+ });
166
+ }
167
+
168
+ async function readViews(exec: Exec): Promise<ViewDescriptor[]> {
169
+ const rows = await exec(
170
+ "SELECT name FROM sqlite_master WHERE type='view' AND name NOT LIKE 'sqlite_%' ORDER BY name",
171
+ );
172
+ return rows.map((r) => ({ name: String(r.name) }));
173
+ }
174
+
175
+ /**
176
+ * Quote a SQLite identifier with double-quotes, escaping any embedded
177
+ * double-quotes (SQLite identifier escape: "" → literal ").
178
+ * Used for pragma_* calls where bind params aren't available (wrangler
179
+ * --command takes a complete SQL string). The introspectSqlite path uses
180
+ * Kysely tagged templates and doesn't need this.
181
+ */
182
+ function sqliteIdent(name: string): string {
183
+ return `"${name.replace(/"/g, '""')}"`;
184
+ }
185
+
@@ -10,5 +10,6 @@ export async function introspect(db: Kysely<Record<string, unknown>>, dialect: D
10
10
  switch (dialect) {
11
11
  case "postgres": return introspectPostgres(db);
12
12
  case "sqlite": return introspectSqlite(db);
13
+ case "d1": throw new Error("d1 introspect goes through introspectD1, not introspect() — wrangler does not use a Kysely driver");
13
14
  }
14
15
  }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Shared SQLite catalog helpers used by both the Kysely-based introspector
3
+ * (sqlite.ts) and the wrangler-based D1 introspector (d1.ts).
4
+ *
5
+ * All three routines are pure mappings of SQLite's declared type / pragma
6
+ * values to canonical migrate-ts types and carry no I/O dependencies.
7
+ */
8
+ import type { ColumnDefault, FkAction } from "../types.js";
9
+ import type { SqlType } from "../sql-type.js";
10
+
11
+ export const SQLITE_EXPR_DEFAULT_PATTERNS = [
12
+ /^current_timestamp$/i,
13
+ /^current_date$/i,
14
+ /^current_time$/i,
15
+ /\(.*\)/,
16
+ ] as const;
17
+
18
+ export function parseSqliteDefault(raw: string | null): ColumnDefault | undefined {
19
+ if (raw === null || raw === undefined || raw === "") return undefined;
20
+ const isExpr = SQLITE_EXPR_DEFAULT_PATTERNS.some((re) => re.test(raw));
21
+ if (isExpr) return { kind: "expr", value: raw };
22
+ // SQLite stores literal string defaults with surrounding quotes.
23
+ const cleaned = raw.replace(/^'(.*)'$/, "$1");
24
+ return { kind: "literal", value: cleaned };
25
+ }
26
+
27
+ export function sqliteTypeToSqlType(declaredType: string): SqlType {
28
+ const t = declaredType.trim().toUpperCase();
29
+
30
+ // SQLite's type affinity is loose; we honor the declared type literally for round-trip stability.
31
+ // Affinity rules per sqlite.org/datatype3.html — adapted to canonical SqlType.
32
+
33
+ // text affinity
34
+ const varcharMatch = /^(?:VARCHAR|CHAR|CHARACTER|TEXT)\((\d+)\)$/.exec(t);
35
+ if (varcharMatch) return { kind: "text", maxLength: parseInt(varcharMatch[1] ?? "0", 10) };
36
+ if (/TEXT|CLOB|VARCHAR|CHAR/.test(t)) return { kind: "text" };
37
+
38
+ // numeric affinity
39
+ const numMatch = /^(?:NUMERIC|DECIMAL)\((\d+)(?:,\s*(\d+))?\)$/.exec(t);
40
+ if (numMatch) {
41
+ const out: SqlType = { kind: "numeric" };
42
+ if (numMatch[1]) out.precision = parseInt(numMatch[1], 10);
43
+ if (numMatch[2]) out.scale = parseInt(numMatch[2], 10);
44
+ return out;
45
+ }
46
+ if (t === "BOOLEAN" || t === "BOOL") return { kind: "boolean" };
47
+ if (t === "DATE") return { kind: "date" };
48
+ if (t === "DATETIME" || t === "TIMESTAMP") return { kind: "timestamp", withTimezone: false };
49
+
50
+ // integer affinity (SQLite stores all INTEGER as 64-bit internally).
51
+ // Distinguish INT (32-bit) from INTEGER/BIGINT (64-bit) for round-trip fidelity:
52
+ // the emitter uses "INT" for integer{32} and "INTEGER" for integer{64}.
53
+ if (t === "INT" || t === "SMALLINT" || t === "TINYINT") return { kind: "integer", bits: 32 };
54
+ if (/INT/.test(t)) return { kind: "integer", bits: 64 };
55
+
56
+ // real affinity
57
+ if (/REAL|FLOA|DOUB/.test(t)) return { kind: "real" };
58
+
59
+ // blob affinity
60
+ if (t === "BLOB" || t === "") return { kind: "blob" };
61
+
62
+ // numeric affinity fallback
63
+ if (/NUMERIC|DECIMAL/.test(t)) return { kind: "numeric" };
64
+
65
+ // json (libsql/sqlite have JSON1)
66
+ if (t === "JSON") return { kind: "json" };
67
+
68
+ return { kind: "text" };
69
+ }
70
+
71
+ export function sqliteRuleToAction(rule: string): FkAction {
72
+ const r = rule.toUpperCase();
73
+ if (r === "CASCADE") return "cascade";
74
+ if (r === "SET NULL") return "set-null";
75
+ if (r === "RESTRICT") return "restrict";
76
+ return "no-action";
77
+ }
@@ -1,10 +1,10 @@
1
1
  import type { Kysely } from "kysely";
2
2
  import { sql } from "kysely";
3
3
  import type {
4
- SchemaSnapshot, TableDescriptor, ColumnDescriptor, ColumnDefault, SnapshotMeta,
4
+ SchemaSnapshot, TableDescriptor, ColumnDescriptor, SnapshotMeta,
5
5
  IndexDescriptor, FkDescriptor, FkAction, ViewDescriptor,
6
6
  } from "../types.js";
7
- import type { SqlType } from "../sql-type.js";
7
+ import { parseSqliteDefault, sqliteTypeToSqlType, sqliteRuleToAction } from "./sqlite-shared.js";
8
8
 
9
9
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
10
10
  type RawKysely = Kysely<any>;
@@ -83,66 +83,6 @@ async function readSqlitePrimaryKey(k: RawKysely, table: string): Promise<string
83
83
  return rows.rows.map((r) => r.name);
84
84
  }
85
85
 
86
- function sqliteTypeToSqlType(declaredType: string): SqlType {
87
- const t = declaredType.trim().toUpperCase();
88
-
89
- // SQLite's type affinity is loose; we honor the declared type literally for round-trip stability.
90
- // Affinity rules per sqlite.org/datatype3.html — adapted to canonical SqlType.
91
-
92
- // text affinity
93
- const varcharMatch = /^(?:VARCHAR|CHAR|CHARACTER|TEXT)\((\d+)\)$/.exec(t);
94
- if (varcharMatch) return { kind: "text", maxLength: parseInt(varcharMatch[1] ?? "0", 10) };
95
- if (/TEXT|CLOB|VARCHAR|CHAR/.test(t)) return { kind: "text" };
96
-
97
- // numeric affinity
98
- const numMatch = /^(?:NUMERIC|DECIMAL)\((\d+)(?:,\s*(\d+))?\)$/.exec(t);
99
- if (numMatch) {
100
- const out: SqlType = { kind: "numeric" };
101
- if (numMatch[1]) out.precision = parseInt(numMatch[1], 10);
102
- if (numMatch[2]) out.scale = parseInt(numMatch[2], 10);
103
- return out;
104
- }
105
- if (t === "BOOLEAN" || t === "BOOL") return { kind: "boolean" };
106
- if (t === "DATE") return { kind: "date" };
107
- if (t === "DATETIME" || t === "TIMESTAMP") return { kind: "timestamp", withTimezone: false };
108
-
109
- // integer affinity (SQLite stores all INTEGER as 64-bit internally).
110
- // Distinguish INT (32-bit) from INTEGER/BIGINT (64-bit) for round-trip fidelity:
111
- // the emitter uses "INT" for integer{32} and "INTEGER" for integer{64}.
112
- if (t === "INT" || t === "SMALLINT" || t === "TINYINT") return { kind: "integer", bits: 32 };
113
- if (/INT/.test(t)) return { kind: "integer", bits: 64 };
114
-
115
- // real affinity
116
- if (/REAL|FLOA|DOUB/.test(t)) return { kind: "real" };
117
-
118
- // blob affinity
119
- if (t === "BLOB" || t === "") return { kind: "blob" };
120
-
121
- // numeric affinity fallback
122
- if (/NUMERIC|DECIMAL/.test(t)) return { kind: "numeric" };
123
-
124
- // json (libsql/sqlite have JSON1)
125
- if (t === "JSON") return { kind: "json" };
126
-
127
- return { kind: "text" };
128
- }
129
-
130
- const SQLITE_EXPR_DEFAULT_PATTERNS = [
131
- /^current_timestamp$/i,
132
- /^current_date$/i,
133
- /^current_time$/i,
134
- /\(.*\)/, // anything function-like
135
- ];
136
-
137
- function parseSqliteDefault(raw: string | null): ColumnDefault | undefined {
138
- if (raw === null || raw === undefined || raw === "") return undefined;
139
- const isExpr = SQLITE_EXPR_DEFAULT_PATTERNS.some((re) => re.test(raw));
140
- if (isExpr) return { kind: "expr", value: raw };
141
- // SQLite stores literal string defaults with surrounding quotes.
142
- const cleaned = raw.replace(/^'(.*)'$/, "$1");
143
- return { kind: "literal", value: cleaned };
144
- }
145
-
146
86
  async function readSqliteIndexes(k: RawKysely, table: string): Promise<IndexDescriptor[]> {
147
87
  // SELECT * avoids "unique" being treated as a reserved keyword by libsql.
148
88
  const listRows = await sql<{ seq: number; name: string; unique: number; origin: string; partial: number }>`
@@ -207,10 +147,3 @@ async function readSqliteForeignKeys(k: RawKysely, table: string): Promise<FkDes
207
147
  });
208
148
  }
209
149
 
210
- function sqliteRuleToAction(rule: string): FkAction {
211
- const r = rule.toUpperCase();
212
- if (r === "CASCADE") return "cascade";
213
- if (r === "SET NULL") return "set-null";
214
- if (r === "RESTRICT") return "restrict";
215
- return "no-action";
216
- }
@@ -0,0 +1,134 @@
1
+ import {
2
+ ON_DELETE_DEFAULT_BY_SUBTYPE,
3
+ ON_UPDATE_DEFAULT,
4
+ FIELD_ATTR_REQUIRED,
5
+ IDENTITY_ATTR_FIELDS,
6
+ TYPE_VALIDATOR,
7
+ VALIDATOR_SUBTYPE_REQUIRED,
8
+ type MetaObject,
9
+ type MetaReferenceIdentity,
10
+ type MetaData,
11
+ } from "@metaobjectsdev/metadata";
12
+ import type { FkAction } from "./types.js";
13
+ import { SetNullNotNullableError } from "./errors.js";
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Shared field helpers — exported for use by expected-schema.ts
17
+ // ---------------------------------------------------------------------------
18
+
19
+ export function readIdentityFields(identity: MetaData): string[] {
20
+ const raw = identity.ownAttr(IDENTITY_ATTR_FIELDS);
21
+ if (Array.isArray(raw)) return raw.map(String).filter((s) => s.length > 0);
22
+ // Fallback: comma-separated string form (defensive; canonical form is array)
23
+ if (typeof raw === "string") return raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
24
+ return [];
25
+ }
26
+
27
+ export function findField(entity: MetaObject, name: string): MetaData | undefined {
28
+ for (const field of entity.fields()) {
29
+ if (field.name === name) return field;
30
+ }
31
+ return undefined;
32
+ }
33
+
34
+ export function isRequired(field: MetaData): boolean {
35
+ const attr = field.ownAttr(FIELD_ATTR_REQUIRED);
36
+ if (attr === true || attr === "true") return true;
37
+ return field.ownChildren().some(
38
+ (c) => c.type === TYPE_VALIDATOR && c.subType === VALIDATOR_SUBTYPE_REQUIRED,
39
+ );
40
+ }
41
+
42
+ /**
43
+ * Resolve the referential actions for a foreign key inferred from an
44
+ * identity.reference, by correlating it with a sibling relationship on the
45
+ * same entity (matched on target-entity name).
46
+ *
47
+ * - No correlated relationship → both undefined (no ON DELETE / ON UPDATE clause).
48
+ * - With a relationship: onDelete defaults from the relationship subtype
49
+ * (composition→cascade, aggregation→set-null, association→restrict);
50
+ * onUpdate defaults to "cascade". Explicit @onDelete / @onUpdate override.
51
+ * - Resolved "no-action" → undefined: introspection in introspect/{postgres,sqlite}.ts
52
+ * omits actions when the DB value is "no-action", so the expected side does the same
53
+ * to keep round-trip diffs clean.
54
+ *
55
+ * If multiple relationships target the same entity (rare), the first one is used.
56
+ *
57
+ * The single `as FkAction` cast in normalize() is safe because REFERENTIAL_ACTIONS
58
+ * (metadata package) and FkAction (migrate-ts/src/types.ts) are the same four-value
59
+ * set: "cascade" | "set-null" | "restrict" | "no-action". The invariant is
60
+ * documented in relationship-constants.ts and enforced by both the type system
61
+ * (FkAction is the union literal) and a runtime-set-equality test in
62
+ * referential-actions.test.ts.
63
+ */
64
+ export function resolveReferentialActions(
65
+ entity: MetaObject,
66
+ ref: MetaReferenceIdentity,
67
+ ): { onDelete: FkAction | undefined; onUpdate: FkAction | undefined } {
68
+ const target = ref.targetEntity;
69
+ if (target === undefined) return { onDelete: undefined, onUpdate: undefined };
70
+
71
+ // Correlation is by exact-string match. Every fixture in the corpus uses
72
+ // bare entity names for @objectRef and @references (no `::`-FQN form), so
73
+ // bare-vs-bare matching is sufficient today. If a future author writes an
74
+ // FQN value on either side, this find returns undefined and both actions
75
+ // resolve to undefined (no clause emitted) — surfacing the mismatch as a
76
+ // silent loss of intent rather than a wrong action. Cross-language ports
77
+ // should match the same correlation rule.
78
+ const rel = entity.relationships().find((r) => r.objectRef === target);
79
+ if (rel === undefined) return { onDelete: undefined, onUpdate: undefined };
80
+
81
+ const onDeleteRaw = rel.onDelete ?? ON_DELETE_DEFAULT_BY_SUBTYPE[rel.subType];
82
+ const onUpdateRaw = rel.onUpdate ?? ON_UPDATE_DEFAULT;
83
+ return {
84
+ onDelete: normalize(onDeleteRaw),
85
+ onUpdate: normalize(onUpdateRaw),
86
+ };
87
+ }
88
+
89
+ function normalize(a: string | undefined): FkAction | undefined {
90
+ if (a === undefined || a === "no-action") return undefined;
91
+ return a as FkAction;
92
+ }
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // Set-null / NOT NULL guard
96
+ // ---------------------------------------------------------------------------
97
+
98
+ /**
99
+ * Validate that a FK whose resolved ON DELETE action is "set-null" does not
100
+ * contain any NOT NULL column.
101
+ *
102
+ * ON DELETE SET NULL requires all FK columns to be nullable. Postgres and
103
+ * SQLite both reject the combination at DDL execution time.
104
+ *
105
+ * Call this from buildExpectedSchema AFTER resolving the referential action
106
+ * (i.e. after resolveReferentialActions) so that explicit overrides such as
107
+ * @onDelete: "restrict" are already applied before the check.
108
+ *
109
+ * @param entity The owning entity.
110
+ * @param ref The identity.reference node being processed.
111
+ * @param onDelete The resolved onDelete action (undefined = no-action).
112
+ * @param constraintName The FK constraint name as it will appear in the DDL.
113
+ */
114
+ export function validateSetNullNullability(
115
+ entity: MetaObject,
116
+ ref: MetaReferenceIdentity,
117
+ onDelete: FkAction | undefined,
118
+ constraintName: string,
119
+ ): void {
120
+ if (onDelete !== "set-null") return;
121
+
122
+ const fkFieldJsNames = readIdentityFields(ref);
123
+ const offending: string[] = [];
124
+ for (const jsName of fkFieldJsNames) {
125
+ const field = findField(entity, jsName);
126
+ if (field !== undefined && isRequired(field)) {
127
+ offending.push(jsName);
128
+ }
129
+ }
130
+
131
+ if (offending.length > 0) {
132
+ throw new SetNullNotNullableError(entity.name, constraintName, offending);
133
+ }
134
+ }
package/src/types.ts CHANGED
@@ -32,6 +32,13 @@ export interface TableDescriptor {
32
32
  indexes: IndexDescriptor[];
33
33
  foreignKeys: FkDescriptor[];
34
34
  primaryKey: string[]; // column names; [] if none
35
+ /**
36
+ * Human-readable description threaded from entity `@description`.
37
+ * Postgres: emitted as `COMMENT ON TABLE … IS '…';` after CREATE TABLE.
38
+ * SQLite: silently ignored (no native COMMENT support).
39
+ * Introspect side: not read back in v1 (no change-description variant yet).
40
+ */
41
+ description?: string;
35
42
  }
36
43
 
37
44
  export interface ColumnDescriptor {
@@ -40,6 +47,13 @@ export interface ColumnDescriptor {
40
47
  nullable: boolean;
41
48
  default?: ColumnDefault;
42
49
  identity?: "increment" | "uuid";
50
+ /**
51
+ * Human-readable description threaded from field `@description`.
52
+ * Postgres: emitted as `COMMENT ON COLUMN … IS '…';` after CREATE TABLE or ADD COLUMN.
53
+ * SQLite: silently ignored (no native COMMENT support).
54
+ * Introspect side: not read back in v1 (no change-description variant yet).
55
+ */
56
+ description?: string;
43
57
  }
44
58
 
45
59
  export interface ColumnDefault {
@@ -171,4 +185,4 @@ export interface EmitResult {
171
185
  recreatedTables: ReadonlySet<string>;
172
186
  }
173
187
 
174
- export type Dialect = "postgres" | "sqlite";
188
+ export type Dialect = "postgres" | "sqlite" | "d1";
@@ -0,0 +1,103 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { dirname, join, isAbsolute, resolve } from "node:path";
3
+ import TOML from "@iarna/toml";
4
+
5
+ export interface D1Binding {
6
+ binding: string;
7
+ database_name: string;
8
+ database_id: string;
9
+ migrations_dir: string | undefined;
10
+ }
11
+
12
+ export interface WranglerConfig {
13
+ d1Bindings: D1Binding[];
14
+ }
15
+
16
+ /**
17
+ * Walk from `startDir` upward looking for wrangler.toml, wrangler.jsonc, or wrangler.json.
18
+ * Returns the first match or undefined. Probe order: toml > jsonc > json at each level,
19
+ * matching wrangler's own resolution order.
20
+ */
21
+ export function findWranglerConfig(startDir: string): string | undefined {
22
+ let dir = resolve(startDir);
23
+ while (true) {
24
+ const toml = join(dir, "wrangler.toml");
25
+ if (existsSync(toml)) return toml;
26
+ const jsonc = join(dir, "wrangler.jsonc");
27
+ if (existsSync(jsonc)) return jsonc;
28
+ const json = join(dir, "wrangler.json");
29
+ if (existsSync(json)) return json;
30
+ const parent = dirname(dir);
31
+ if (parent === dir) return undefined;
32
+ dir = parent;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Parse a wrangler.toml or wrangler.jsonc file. Returns extracted D1 bindings.
38
+ */
39
+ export function parseWranglerConfig(path: string): WranglerConfig {
40
+ if (!isAbsolute(path)) path = resolve(path);
41
+ const raw = readFileSync(path, "utf8");
42
+ const isJsonc = path.endsWith(".jsonc") || path.endsWith(".json");
43
+ const obj = isJsonc ? parseJsoncLoose(raw) : (TOML.parse(raw) as Record<string, unknown>);
44
+ const rawArr = obj.d1_databases;
45
+ if (rawArr !== undefined && !Array.isArray(rawArr)) {
46
+ throw new Error(`${path}: d1_databases must be an array (got ${typeof rawArr})`);
47
+ }
48
+ const rawBindings: unknown[] = rawArr ?? [];
49
+ const d1Bindings: D1Binding[] = rawBindings.map((b, i) => {
50
+ if (b === null || typeof b !== "object") {
51
+ throw new Error(`${path}: d1_databases[${i}] must be an object`);
52
+ }
53
+ const r = b as Record<string, unknown>;
54
+ if (typeof r.binding !== "string" || r.binding.length === 0) {
55
+ throw new Error(`${path}: d1_databases[${i}] is missing required 'binding' field`);
56
+ }
57
+ return {
58
+ binding: r.binding,
59
+ database_name: typeof r.database_name === "string" ? r.database_name : "",
60
+ database_id: typeof r.database_id === "string" ? r.database_id : "",
61
+ migrations_dir: typeof r.migrations_dir === "string" ? r.migrations_dir : undefined,
62
+ };
63
+ });
64
+ return { d1Bindings };
65
+ }
66
+
67
+ /**
68
+ * Pick a D1 binding by name. If `name` is undefined and there's exactly one
69
+ * binding, return it. Otherwise throw a helpful error.
70
+ */
71
+ export function resolveD1Binding(bindings: readonly D1Binding[], name: string | undefined): D1Binding {
72
+ if (bindings.length === 0) {
73
+ throw new Error("no d1 bindings found in wrangler config");
74
+ }
75
+ if (name === undefined) {
76
+ if (bindings.length === 1) return bindings[0]!;
77
+ throw new Error(
78
+ `multiple d1 bindings in wrangler config; pass --d1 <binding>. Available: ${bindings.map((b) => b.binding).join(", ")}`,
79
+ );
80
+ }
81
+ const found = bindings.find((b) => b.binding === name);
82
+ if (!found) {
83
+ throw new Error(
84
+ `d1 binding '${name}' not found in wrangler config. Available: ${bindings.map((b) => b.binding).join(", ")}`,
85
+ );
86
+ }
87
+ return found;
88
+ }
89
+
90
+ /**
91
+ * Minimal JSONC parser: strips `//` line comments and block comments (`/`+`* ... *`+`/`),
92
+ * then JSON.parse. Wrangler's jsonc is small; this is sufficient.
93
+ *
94
+ * Limitation: a `//` sequence inside a JSON string value would be incorrectly
95
+ * stripped. Wrangler's config vocabulary (UUIDs, identifiers, paths) does not
96
+ * use such substrings, so this is acceptable in scope.
97
+ */
98
+ function parseJsoncLoose(raw: string): Record<string, unknown> {
99
+ const stripped = raw
100
+ .replace(/\/\*[\s\S]*?\*\//g, "")
101
+ .replace(/(^|[^:])\/\/.*$/gm, "$1");
102
+ return JSON.parse(stripped) as Record<string, unknown>;
103
+ }