@metaobjectsdev/migrate-ts 0.8.1 → 0.9.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 (115) hide show
  1. package/README.md +1 -3
  2. package/dist/apply/apply.d.ts +61 -0
  3. package/dist/apply/apply.d.ts.map +1 -0
  4. package/dist/apply/apply.js +241 -0
  5. package/dist/apply/apply.js.map +1 -0
  6. package/dist/apply/ledger.d.ts +78 -0
  7. package/dist/apply/ledger.d.ts.map +1 -0
  8. package/dist/apply/ledger.js +146 -0
  9. package/dist/apply/ledger.js.map +1 -0
  10. package/dist/check-expr-compare.d.ts +13 -0
  11. package/dist/check-expr-compare.d.ts.map +1 -0
  12. package/dist/check-expr-compare.js +48 -0
  13. package/dist/check-expr-compare.js.map +1 -0
  14. package/dist/diff/index.d.ts +3 -1
  15. package/dist/diff/index.d.ts.map +1 -1
  16. package/dist/diff/index.js +57 -14
  17. package/dist/diff/index.js.map +1 -1
  18. package/dist/diff/status.js +3 -0
  19. package/dist/diff/status.js.map +1 -1
  20. package/dist/drift/classify.d.ts +16 -0
  21. package/dist/drift/classify.d.ts.map +1 -0
  22. package/dist/drift/classify.js +44 -0
  23. package/dist/drift/classify.js.map +1 -0
  24. package/dist/drift/drift.d.ts +32 -0
  25. package/dist/drift/drift.d.ts.map +1 -0
  26. package/dist/drift/drift.js +36 -0
  27. package/dist/drift/drift.js.map +1 -0
  28. package/dist/emit/d1-safety-pass.d.ts.map +1 -1
  29. package/dist/emit/d1-safety-pass.js +15 -45
  30. package/dist/emit/d1-safety-pass.js.map +1 -1
  31. package/dist/emit/postgres.d.ts.map +1 -1
  32. package/dist/emit/postgres.js +47 -4
  33. package/dist/emit/postgres.js.map +1 -1
  34. package/dist/emit/sqlite.d.ts.map +1 -1
  35. package/dist/emit/sqlite.js +22 -0
  36. package/dist/emit/sqlite.js.map +1 -1
  37. package/dist/errors.d.ts.map +1 -1
  38. package/dist/errors.js +4 -0
  39. package/dist/errors.js.map +1 -1
  40. package/dist/expected-schema.d.ts.map +1 -1
  41. package/dist/expected-schema.js +114 -5
  42. package/dist/expected-schema.js.map +1 -1
  43. package/dist/index.d.ts +13 -0
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +13 -0
  46. package/dist/index.js.map +1 -1
  47. package/dist/introspect/d1.d.ts.map +1 -1
  48. package/dist/introspect/d1.js +1 -0
  49. package/dist/introspect/d1.js.map +1 -1
  50. package/dist/introspect/postgres.d.ts.map +1 -1
  51. package/dist/introspect/postgres.js +38 -2
  52. package/dist/introspect/postgres.js.map +1 -1
  53. package/dist/introspect/sqlite.d.ts.map +1 -1
  54. package/dist/introspect/sqlite.js +13 -2
  55. package/dist/introspect/sqlite.js.map +1 -1
  56. package/dist/snapshot/checksum.d.ts +10 -0
  57. package/dist/snapshot/checksum.d.ts.map +1 -0
  58. package/dist/snapshot/checksum.js +14 -0
  59. package/dist/snapshot/checksum.js.map +1 -0
  60. package/dist/snapshot/plan.d.ts +25 -0
  61. package/dist/snapshot/plan.d.ts.map +1 -0
  62. package/dist/snapshot/plan.js +30 -0
  63. package/dist/snapshot/plan.js.map +1 -0
  64. package/dist/snapshot/serialize.d.ts +10 -0
  65. package/dist/snapshot/serialize.d.ts.map +1 -0
  66. package/dist/snapshot/serialize.js +63 -0
  67. package/dist/snapshot/serialize.js.map +1 -0
  68. package/dist/snapshot/store.d.ts +12 -0
  69. package/dist/snapshot/store.d.ts.map +1 -0
  70. package/dist/snapshot/store.js +32 -0
  71. package/dist/snapshot/store.js.map +1 -0
  72. package/dist/sql/split-statements.d.ts +12 -0
  73. package/dist/sql/split-statements.d.ts.map +1 -0
  74. package/dist/sql/split-statements.js +112 -0
  75. package/dist/sql/split-statements.js.map +1 -0
  76. package/dist/sql-type.d.ts +2 -0
  77. package/dist/sql-type.d.ts.map +1 -1
  78. package/dist/sql-type.js +2 -0
  79. package/dist/sql-type.js.map +1 -1
  80. package/dist/types.d.ts +36 -5
  81. package/dist/types.d.ts.map +1 -1
  82. package/dist/verify/replay.d.ts +25 -0
  83. package/dist/verify/replay.d.ts.map +1 -0
  84. package/dist/verify/replay.js +25 -0
  85. package/dist/verify/replay.js.map +1 -0
  86. package/dist/view-sql-compare.d.ts +8 -0
  87. package/dist/view-sql-compare.d.ts.map +1 -0
  88. package/dist/view-sql-compare.js +44 -0
  89. package/dist/view-sql-compare.js.map +1 -0
  90. package/package.json +2 -2
  91. package/src/apply/apply.ts +340 -0
  92. package/src/apply/ledger.ts +241 -0
  93. package/src/check-expr-compare.ts +49 -0
  94. package/src/diff/index.ts +59 -15
  95. package/src/diff/status.ts +3 -0
  96. package/src/drift/classify.ts +56 -0
  97. package/src/drift/drift.ts +66 -0
  98. package/src/emit/d1-safety-pass.ts +16 -45
  99. package/src/emit/postgres.ts +47 -4
  100. package/src/emit/sqlite.ts +22 -0
  101. package/src/errors.ts +4 -0
  102. package/src/expected-schema.ts +124 -4
  103. package/src/index.ts +44 -0
  104. package/src/introspect/d1.ts +1 -0
  105. package/src/introspect/postgres.ts +38 -4
  106. package/src/introspect/sqlite.ts +13 -3
  107. package/src/snapshot/checksum.ts +15 -0
  108. package/src/snapshot/plan.ts +53 -0
  109. package/src/snapshot/serialize.ts +81 -0
  110. package/src/snapshot/store.ts +33 -0
  111. package/src/sql/split-statements.ts +115 -0
  112. package/src/sql-type.ts +3 -0
  113. package/src/types.ts +26 -9
  114. package/src/verify/replay.ts +43 -0
  115. package/src/view-sql-compare.ts +46 -0
@@ -16,6 +16,7 @@ const STAGE_ORDER: Record<Change["kind"], number> = {
16
16
  "rename-column": 3, "rename-table": 3,
17
17
  "add-index": 4, "drop-index": 4,
18
18
  "add-fk": 5, "drop-fk": 5,
19
+ "add-check": 5, "drop-check": 5,
19
20
  "drop-table": 6,
20
21
  "create-view": 7, "replace-view": 7,
21
22
  };
@@ -61,6 +62,12 @@ function renderUp(c: Change): string {
61
62
  case "drop-index": return `DROP INDEX ${quoteIndexQualified(c.index, c.schema)};`;
62
63
  case "add-fk": return renderAddFk(c.table, c.schema, c.fk);
63
64
  case "drop-fk": return `ALTER TABLE ${quoteQualified(c.table, c.schema)} DROP CONSTRAINT ${quote(c.fk)};`;
65
+ // add-check / drop-check are declared but NOT yet produced by the diff —
66
+ // checks are create-time-only (inlined in CREATE TABLE via renderCreateTable).
67
+ // These arms exist for future existing-table CHECK evolution support, mirroring
68
+ // the create-view/drop-view "declared, not yet produced" pattern.
69
+ case "add-check": return `ALTER TABLE ${quoteQualified(c.table, c.schema)} ADD CONSTRAINT ${quote(c.check.name)} CHECK (${c.check.expression});`;
70
+ case "drop-check": return `ALTER TABLE ${quoteQualified(c.table, c.schema)} DROP CONSTRAINT ${quote(c.check)};`;
64
71
  case "create-view": return renderCreateView(c.view, c.schema, /* orReplace */ false);
65
72
  case "drop-view": return `DROP VIEW ${quoteQualifiedView(c.view, c.schema)};`;
66
73
  case "replace-view": return renderCreateView(c.view, c.schema, /* orReplace */ true);
@@ -70,10 +77,29 @@ function renderUp(c: Change): string {
70
77
  function renderDown(c: Change): string {
71
78
  switch (c.kind) {
72
79
  case "create-table": return `DROP TABLE ${quoteQualified(c.table.name, c.table.schema)};`;
73
- case "drop-table": return `-- WARNING: down migration cannot restore data\n-- TODO: restore table "${c.table}" structure manually`;
80
+ case "drop-table": {
81
+ if (!c.restore) {
82
+ return `-- WARNING: down migration cannot restore data\n-- TODO: restore table "${c.table}" structure manually`;
83
+ }
84
+ // renderCreateTable emits only columns + PK + checks. Indexes and FKs ride
85
+ // as separate add-index/add-fk changes on the up side, so re-create them
86
+ // explicitly here or the down silently loses them.
87
+ const parts = [renderCreateTable(c.restore)];
88
+ for (const index of c.restore.indexes) {
89
+ parts.push(renderCreateIndex(c.restore.name, c.restore.schema, index));
90
+ }
91
+ for (const fk of c.restore.foreignKeys) {
92
+ parts.push(renderAddFk(c.restore.name, c.restore.schema, fk));
93
+ }
94
+ parts.push("-- NOTE: table data is not restored by this down migration.");
95
+ return parts.join("\n");
96
+ }
74
97
  case "rename-table": return `ALTER TABLE ${quoteQualified(c.to, c.schema)} RENAME TO ${quote(c.from)};`;
75
98
  case "add-column": return `ALTER TABLE ${quoteQualified(c.table, c.schema)} DROP COLUMN ${quote(c.column.name)};`;
76
- case "drop-column": return `-- WARNING: down migration cannot restore data\n-- TODO: re-add dropped column "${c.column}" manually with original type/nullable/default`;
99
+ case "drop-column":
100
+ return c.restore
101
+ ? `ALTER TABLE ${quoteQualified(c.table, c.schema)} ADD COLUMN ${renderColumn(c.restore)};\n-- NOTE: column data is not restored by this down migration.`
102
+ : `-- WARNING: down migration cannot restore data\n-- TODO: re-add dropped column "${c.column}" manually with original type/nullable/default`;
77
103
  case "rename-column": return `ALTER TABLE ${quoteQualified(c.table, c.schema)} RENAME COLUMN ${quote(c.to)} TO ${quote(c.from)};`;
78
104
  case "change-column-type": return `ALTER TABLE ${quoteQualified(c.table, c.schema)} ALTER COLUMN ${quote(c.column)} TYPE ${pgType(c.from)};`;
79
105
  case "change-column-nullable":
@@ -85,9 +111,22 @@ function renderDown(c: Change): string {
85
111
  ? `ALTER TABLE ${quoteQualified(c.table, c.schema)} ALTER COLUMN ${quote(c.column)} SET DEFAULT ${renderDefault(c.from)};`
86
112
  : `ALTER TABLE ${quoteQualified(c.table, c.schema)} ALTER COLUMN ${quote(c.column)} DROP DEFAULT;`;
87
113
  case "add-index": return `DROP INDEX ${quoteIndexQualified(c.index.name, c.schema)};`;
88
- case "drop-index": return `-- WARNING: down migration cannot restore the original index definition`;
114
+ case "drop-index":
115
+ return c.restore
116
+ ? renderCreateIndex(c.table, c.schema, c.restore)
117
+ : `-- WARNING: down migration cannot restore the original index definition`;
89
118
  case "add-fk": return `ALTER TABLE ${quoteQualified(c.table, c.schema)} DROP CONSTRAINT ${quote(c.fk.name)};`;
90
- case "drop-fk": return `-- WARNING: down migration cannot restore the original FK definition`;
119
+ case "drop-fk":
120
+ return c.restore
121
+ ? renderAddFk(c.table, c.schema, c.restore)
122
+ : `-- WARNING: down migration cannot restore the original FK definition`;
123
+ // add-check / drop-check down arms: declared but not yet produced by the diff
124
+ // (checks are create-time-only; see renderUp note).
125
+ case "add-check": return `ALTER TABLE ${quoteQualified(c.table, c.schema)} DROP CONSTRAINT ${quote(c.check.name)};`;
126
+ case "drop-check":
127
+ return c.restore
128
+ ? `ALTER TABLE ${quoteQualified(c.table, c.schema)} ADD CONSTRAINT ${quote(c.restore.name)} CHECK (${c.restore.expression});`
129
+ : `-- WARNING: down migration cannot restore the original CHECK definition`;
91
130
  case "create-view": return `DROP VIEW ${quoteQualifiedView(c.view.name, c.schema)};`;
92
131
  case "drop-view": return `-- WARNING: down migration cannot restore the original view definition`;
93
132
  case "replace-view": return `-- WARNING: down migration cannot restore the original view definition`;
@@ -99,6 +138,9 @@ function renderCreateTable(t: TableDescriptor): string {
99
138
  if (t.primaryKey.length > 0) {
100
139
  colDefs.push(` CONSTRAINT ${quote(t.name + "_pkey")} PRIMARY KEY (${t.primaryKey.map(quote).join(", ")})`);
101
140
  }
141
+ for (const chk of t.checks ?? []) {
142
+ colDefs.push(` CONSTRAINT ${quote(chk.name)} CHECK (${chk.expression})`);
143
+ }
102
144
  const create = `CREATE TABLE ${quoteQualified(t.name, t.schema)} (\n${colDefs.join(",\n")}\n);`;
103
145
  const comments = renderTableComments(t);
104
146
  return comments.length === 0 ? create : `${create}\n${comments.join("\n")}`;
@@ -156,6 +198,7 @@ function pgType(t: SqlType): string {
156
198
  case "boolean": return "BOOLEAN";
157
199
  case "timestamp": return t.withTimezone ? "TIMESTAMPTZ" : "TIMESTAMP";
158
200
  case "date": return "DATE";
201
+ case "time": return "TIME";
159
202
  case "json": return "JSONB";
160
203
  case "blob": return "BYTEA";
161
204
  case "uuid": return "UUID";
@@ -13,6 +13,7 @@ const STAGE_ORDER: Record<Change["kind"], number> = {
13
13
  "rename-column": 3, "rename-table": 3,
14
14
  "add-index": 4, "drop-index": 4,
15
15
  "add-fk": 5, "drop-fk": 5,
16
+ "add-check": 5, "drop-check": 5,
16
17
  "drop-table": 6,
17
18
  "create-view": 99, "drop-view": 99, "replace-view": 99,
18
19
  };
@@ -110,6 +111,8 @@ function changeTable(c: Change): string | undefined {
110
111
  case "drop-index":
111
112
  case "add-fk":
112
113
  case "drop-fk":
114
+ case "add-check":
115
+ case "drop-check":
113
116
  return c.table;
114
117
  default:
115
118
  return undefined;
@@ -188,6 +191,12 @@ function renderUpNative(c: Change): string {
188
191
  case "rename-column": return `ALTER TABLE ${quote(c.table)} RENAME COLUMN ${quote(c.from)} TO ${quote(c.to)};`;
189
192
  case "add-index": return renderCreateIndex(c.table, c.index);
190
193
  case "drop-index": return `DROP INDEX ${quote(c.index)};`;
194
+ case "add-check":
195
+ case "drop-check":
196
+ // Declared for future existing-table support; the diff does not yet produce
197
+ // these (checks are create-time-only, inlined in CREATE TABLE). Unreachable
198
+ // today — throw rather than silently mis-emit if one ever arrives here.
199
+ throw new Error("CHECK migration not implemented for sqlite (recreate path pending)");
191
200
  case "change-column-type":
192
201
  case "change-column-nullable":
193
202
  case "change-column-default":
@@ -212,6 +221,12 @@ function renderDownNative(c: Change): string {
212
221
  case "rename-column": return `ALTER TABLE ${quote(c.table)} RENAME COLUMN ${quote(c.to)} TO ${quote(c.from)};`;
213
222
  case "add-index": return `DROP INDEX ${quote(c.index.name)};`;
214
223
  case "drop-index": return `-- WARNING: down migration cannot restore the original index definition`;
224
+ case "add-check":
225
+ case "drop-check":
226
+ // Declared for future existing-table support; the diff does not yet produce
227
+ // these (checks are create-time-only, inlined in CREATE TABLE). Unreachable
228
+ // today — throw rather than silently mis-emit if one ever arrives here.
229
+ throw new Error("CHECK migration not implemented for sqlite (recreate path pending)");
215
230
  case "change-column-type":
216
231
  case "change-column-nullable":
217
232
  case "change-column-default":
@@ -243,6 +258,12 @@ function renderCreateTable(t: TableDescriptor): string {
243
258
  if (fk.onUpdate) clause += ` ON UPDATE ${renderFkAction(fk.onUpdate)}`;
244
259
  colDefs.push(clause);
245
260
  }
261
+ // CHECK constraints are inlined into the CREATE TABLE DDL (SQLite supports
262
+ // inline named CHECK). Checks are create-time-only; the diff never produces
263
+ // add-check / drop-check, so this is the sole place SQLite emits a CHECK.
264
+ for (const chk of t.checks ?? []) {
265
+ colDefs.push(` CONSTRAINT ${quote(chk.name)} CHECK (${chk.expression})`);
266
+ }
246
267
  return `CREATE TABLE ${quote(t.name)} (\n${colDefs.join(",\n")}\n);`;
247
268
  }
248
269
 
@@ -286,6 +307,7 @@ function sqliteType(t: SqlType, identity: ColumnDescriptor["identity"]): string
286
307
  case "boolean": return "BOOLEAN"; // SQLite stores as 0/1 but preserves declared type for round-trip
287
308
  case "timestamp": return "TIMESTAMP";
288
309
  case "date": return "DATE";
310
+ case "time": return "TIME";
289
311
  case "json": return "TEXT"; // SQLite has JSON1 but stores as text
290
312
  case "blob": return "BLOB";
291
313
  case "uuid": return "TEXT";
package/src/errors.ts CHANGED
@@ -65,6 +65,10 @@ function changeLocator(c: Change): string {
65
65
  return `${c.table}.${c.fk.name}`;
66
66
  case "drop-fk":
67
67
  return `${c.table}.${c.fk}`;
68
+ case "add-check":
69
+ return `${c.table}.${c.check.name}`;
70
+ case "drop-check":
71
+ return `${c.table}.${c.check}`;
68
72
  case "create-view":
69
73
  case "replace-view":
70
74
  return c.view.name;
@@ -1,11 +1,15 @@
1
- import type { ColumnNamingStrategy, MetaData, MetaObject, MetaRoot } from "@metaobjectsdev/metadata";
1
+ import type { ColumnNamingStrategy, MetaData, MetaObject, MetaRoot, MetaValidator } from "@metaobjectsdev/metadata";
2
2
  import {
3
+ VALIDATOR_SUBTYPE_NUMERIC, VALIDATOR_SUBTYPE_LENGTH, VALIDATOR_SUBTYPE_REGEX,
4
+ VALIDATOR_ATTR_PATTERN,
3
5
  TYPE_OBJECT,
4
6
  MetaSource,
5
7
  IDENTITY_ATTR_GENERATION,
6
8
  IDENTITY_ATTR_UNIQUE,
7
9
  FIELD_ATTR_DEFAULT,
8
10
  FIELD_ATTR_MAX_LENGTH,
11
+ FIELD_ATTR_PRECISION,
12
+ FIELD_ATTR_SCALE,
9
13
  FIELD_ATTR_UNIQUE,
10
14
  FIELD_SUBTYPE_STRING,
11
15
  FIELD_SUBTYPE_INT,
@@ -22,8 +26,15 @@ import {
22
26
  FIELD_SUBTYPE_TIMESTAMP,
23
27
  FIELD_SUBTYPE_OBJECT,
24
28
  FIELD_SUBTYPE_CLASS,
29
+ FIELD_SUBTYPE_UUID,
30
+ FIELD_SUBTYPE_ENUM,
31
+ FIELD_ATTR_VALUES,
25
32
  FIELD_ATTR_OBJECT_REF,
26
33
  FIELD_ATTR_STORAGE,
34
+ FIELD_ATTR_DB_COLUMN_TYPE,
35
+ DB_COLUMN_TYPE_UUID,
36
+ DB_COLUMN_TYPE_JSONB,
37
+ DB_COLUMN_TYPE_TIMESTAMP_WITH_TZ,
27
38
  STORAGE_FLATTENED,
28
39
  DOC_ATTR_DESCRIPTION,
29
40
  applyColumnNamingStrategy, DEFAULT_COLUMN_NAMING_STRATEGY,
@@ -32,6 +43,7 @@ import {
32
43
  import type { SqlType } from "./sql-type.js";
33
44
  import type {
34
45
  Dialect, SchemaSnapshot, TableDescriptor, ColumnDescriptor, IndexDescriptor, FkDescriptor,
46
+ CheckDescriptor,
35
47
  } from "./types.js";
36
48
  import { buildExpectedViews } from "./expected-views.js";
37
49
  import {
@@ -97,7 +109,7 @@ export function buildExpectedSchema(
97
109
  // Schema is resolved here (not stored in Pass 1) to avoid exactOptionalPropertyTypes
98
110
  // issues with `string | undefined` vs `schema?: string`.
99
111
  const tables: TableDescriptor[] = entities.map(({ entity, tableName }) => {
100
- const t = buildTable(entity, tableName, resolveTargetTable, root as MetaRoot, strategy);
112
+ const t = buildTable(entity, tableName, resolveTargetTable, root as MetaRoot, strategy, dialect);
101
113
  const schema = resolveTableSchema(entity);
102
114
  if (schema !== undefined) t.schema = schema;
103
115
  return t;
@@ -142,6 +154,7 @@ function normalizeForSqlite(sqlType: SqlType): SqlType {
142
154
  return { kind: "integer", bits: 64 };
143
155
  case "timestamp":
144
156
  case "date":
157
+ case "time":
145
158
  return { kind: "text" };
146
159
  case "integer":
147
160
  // SQLite stores every INTEGER as a 64-bit value and Drizzle's int() emits
@@ -155,6 +168,12 @@ function normalizeForSqlite(sqlType: SqlType): SqlType {
155
168
  // what the SQLite introspector produces, preventing a phantom
156
169
  // change-column-type diff on every field.float column.
157
170
  return { kind: "real" };
171
+ case "uuid":
172
+ // SQLite has no native uuid type; uuid values are stored as TEXT (the
173
+ // conformance corpus is Postgres-only, but TS supports a sqlite dialect).
174
+ // Collapse uuid → text so the expected snapshot matches what the SQLite
175
+ // introspector produces, preventing a phantom change-column-type diff.
176
+ return { kind: "text" };
158
177
  default:
159
178
  return sqlType;
160
179
  }
@@ -166,6 +185,7 @@ function buildTable(
166
185
  resolveTargetTable: (entityName: string) => string | undefined,
167
186
  root: MetaRoot,
168
187
  strategy: ColumnNamingStrategy,
188
+ dialect: Dialect | undefined,
169
189
  ): TableDescriptor {
170
190
  // Use effective accessors so inherited fields/identities (from `extends:` /
171
191
  // abstract bases like BaseEntity) are included.
@@ -201,6 +221,7 @@ function buildTable(
201
221
  columns,
202
222
  indexes: buildSecondaryIndexes(entity, tableName, strategy),
203
223
  foreignKeys: buildForeignKeys(entity, tableName, resolveTargetTable, root, strategy),
224
+ checks: buildChecks(entity, tableName, strategy, dialect),
204
225
  primaryKey,
205
226
  };
206
227
  const entityDesc = readDescription(entity);
@@ -258,6 +279,80 @@ function buildSecondaryIndexes(
258
279
  return indexes;
259
280
  }
260
281
 
282
+ /**
283
+ * Derive a CHECK constraint per `field.enum` field: `CHECK (<col> IN ('A', 'B'))`,
284
+ * constraining the column to the declared `@values` members. The constraint name
285
+ * is `<table>_<column>_chk`, mirroring the FK/index naming conventions.
286
+ *
287
+ * `@values` is read effective (`field.attr`) so a concrete field that extends an
288
+ * abstract `field.enum` super inherits its members. The loader rejects a
289
+ * `field.enum` without `@values` (ERR_MISSING_REQUIRED_ATTR), so a present enum
290
+ * field always yields a non-empty member set; a defensive guard skips any edge
291
+ * case where the array is absent rather than emitting `IN ()`.
292
+ */
293
+ /**
294
+ * Map a single declared validator to a DB CHECK descriptor, or null when it has
295
+ * no SQL-expressible form on this dialect. The constraint name is
296
+ * `<table>_<col>_<validator>_chk`. The expression references the resolved physical
297
+ * column name verbatim (matching the enum-check convention).
298
+ */
299
+ function validatorCheck(
300
+ v: MetaValidator, col: string, tableName: string, dialect: Dialect | undefined,
301
+ ): CheckDescriptor | null {
302
+ switch (v.subType) {
303
+ case VALIDATOR_SUBTYPE_NUMERIC: {
304
+ const parts: string[] = [];
305
+ if (v.min !== undefined) parts.push(`${col} >= ${v.min}`);
306
+ if (v.max !== undefined) parts.push(`${col} <= ${v.max}`);
307
+ if (parts.length === 0) return null;
308
+ return { name: `${tableName}_${col}_numeric_chk`, expression: parts.join(" AND ") };
309
+ }
310
+ case VALIDATOR_SUBTYPE_LENGTH: {
311
+ const parts: string[] = [];
312
+ if (v.min !== undefined) parts.push(`length(${col}) >= ${v.min}`);
313
+ if (v.max !== undefined) parts.push(`length(${col}) <= ${v.max}`);
314
+ if (parts.length === 0) return null;
315
+ return { name: `${tableName}_${col}_length_chk`, expression: parts.join(" AND ") };
316
+ }
317
+ case VALIDATOR_SUBTYPE_REGEX: {
318
+ // Postgres-only: SQLite has no native regex operator.
319
+ if (dialect === "sqlite" || dialect === "d1") return null;
320
+ const pattern = v.ownAttr(VALIDATOR_ATTR_PATTERN);
321
+ if (typeof pattern !== "string" || pattern.length === 0) return null;
322
+ return {
323
+ name: `${tableName}_${col}_regex_chk`,
324
+ expression: `${col} ~ '${pattern.replace(/'/g, "''")}'`,
325
+ };
326
+ }
327
+ default:
328
+ return null;
329
+ }
330
+ }
331
+
332
+ function buildChecks(
333
+ entity: MetaObject, tableName: string, strategy: ColumnNamingStrategy, dialect: Dialect | undefined,
334
+ ): CheckDescriptor[] {
335
+ const checks: CheckDescriptor[] = [];
336
+ for (const field of entity.fields()) {
337
+ const col = resolveColumnName(field, strategy);
338
+ // Enum membership check (unchanged).
339
+ if (field.subType === FIELD_SUBTYPE_ENUM) {
340
+ const raw = field.attr(FIELD_ATTR_VALUES);
341
+ if (Array.isArray(raw) && raw.length > 0) {
342
+ const values = raw.map((v) => String(v));
343
+ const expression = `${col} IN (${values.map((v) => `'${v.replace(/'/g, "''")}'`).join(", ")})`;
344
+ checks.push({ name: `${tableName}_${col}_chk`, expression });
345
+ }
346
+ }
347
+ // Validator-derived checks.
348
+ for (const v of field.validators()) {
349
+ const check = validatorCheck(v, col, tableName, dialect);
350
+ if (check) checks.push(check);
351
+ }
352
+ }
353
+ return checks;
354
+ }
355
+
261
356
  function buildForeignKeys(
262
357
  entity: MetaObject,
263
358
  tableName: string,
@@ -375,6 +470,18 @@ function buildColumn(
375
470
  }
376
471
 
377
472
  function subtypeToSqlType(field: MetaData): SqlType {
473
+ // R6 Plan 2b: a physical @dbColumnType override selects the DB column type
474
+ // instead of the subtype default (the loader has already validated the
475
+ // (subtype × value) pairing, so an unrecognized value never reaches here).
476
+ const dbColumnType = field.ownAttr(FIELD_ATTR_DB_COLUMN_TYPE);
477
+ if (typeof dbColumnType === "string") {
478
+ switch (dbColumnType) {
479
+ case DB_COLUMN_TYPE_UUID: return { kind: "uuid" };
480
+ case DB_COLUMN_TYPE_JSONB: return { kind: "json" };
481
+ case DB_COLUMN_TYPE_TIMESTAMP_WITH_TZ: return { kind: "timestamp", withTimezone: true };
482
+ }
483
+ }
484
+
378
485
  const subType = field.subType;
379
486
  switch (subType) {
380
487
  case FIELD_SUBTYPE_STRING: {
@@ -389,13 +496,26 @@ function subtypeToSqlType(field: MetaData): SqlType {
389
496
  case FIELD_SUBTYPE_CURRENCY: return { kind: "integer", bits: 64 };
390
497
  case FIELD_SUBTYPE_DOUBLE: return { kind: "real" };
391
498
  case FIELD_SUBTYPE_FLOAT: return { kind: "real4" };
392
- case FIELD_SUBTYPE_DECIMAL: return { kind: "numeric" };
499
+ case FIELD_SUBTYPE_DECIMAL: {
500
+ // @precision/@scale are declared as ATTR_SUBTYPE_INT so the loader coerces them
501
+ // to numbers. Both present → NUMERIC(p,s); absent → bare NUMERIC (back-compat).
502
+ const precision = field.ownAttr(FIELD_ATTR_PRECISION);
503
+ const scale = field.ownAttr(FIELD_ATTR_SCALE);
504
+ if (typeof precision === "number" && typeof scale === "number") {
505
+ return { kind: "numeric", precision, scale };
506
+ }
507
+ if (typeof precision === "number") {
508
+ return { kind: "numeric", precision };
509
+ }
510
+ return { kind: "numeric" };
511
+ }
393
512
  case FIELD_SUBTYPE_BOOLEAN: return { kind: "boolean" };
394
513
  case FIELD_SUBTYPE_DATE: return { kind: "date" };
395
- case FIELD_SUBTYPE_TIME: return { kind: "text" }; // SQL TIME rare; coerce to text
514
+ case FIELD_SUBTYPE_TIME: return { kind: "time" }; // Postgres native TIME (whole-second wire form)
396
515
  case FIELD_SUBTYPE_TIMESTAMP: return { kind: "timestamp", withTimezone: false };
397
516
  case FIELD_SUBTYPE_OBJECT:
398
517
  case FIELD_SUBTYPE_CLASS: return { kind: "json" };
518
+ case FIELD_SUBTYPE_UUID: return { kind: "uuid" }; // R6 Plan 2a — Postgres native uuid
399
519
  default: return { kind: "text" }; // unknown → text fallback
400
520
  }
401
521
  }
package/src/index.ts CHANGED
@@ -11,10 +11,24 @@
11
11
  export { buildExpectedSchema } from "./expected-schema.js";
12
12
  export { introspect, introspectPostgres, introspectSqlite } from "./introspect/index.js";
13
13
  export { diff } from "./diff/index.js";
14
+ export { computeDrift, type ComputeDriftOptions } from "./drift/drift.js";
15
+ export { classifyDrift, driftAgainstSnapshot } from "./drift/classify.js";
16
+ export type { DriftClassification } from "./drift/classify.js";
14
17
  export { emit } from "./emit/index.js";
15
18
  export { writeMigration } from "./write-migration.js";
16
19
  export { writeMigrationD1 } from "./write-migration-d1.js";
17
20
 
21
+ // Reference-snapshot generation (offline, deterministic).
22
+ export {
23
+ serializeSnapshot,
24
+ parseSnapshot,
25
+ SNAPSHOT_FORMAT_VERSION,
26
+ } from "./snapshot/serialize.js";
27
+ export { snapshotChecksum } from "./snapshot/checksum.js";
28
+ export { snapshotPath, readSnapshot, writeSnapshot } from "./snapshot/store.js";
29
+ export { planOffline, baselineFromMetadata } from "./snapshot/plan.js";
30
+ export type { PlanOfflineArgs, PlanOfflineResult } from "./snapshot/plan.js";
31
+
18
32
  // Errors
19
33
  export { BlockedChangesError, SetNullNotNullableError } from "./errors.js";
20
34
 
@@ -37,6 +51,7 @@ export type { WriteMigrationOptions, WriteMigrationResult } from "./write-migrat
37
51
  export type { WriteMigrationD1Options, WriteMigrationD1Result } from "./write-migration-d1.js";
38
52
 
39
53
  // View diff + dialect emitters
54
+ export { normalizeViewSql, viewSqlEquals } from "./view-sql-compare.js";
40
55
  export { classifyViewDiff } from "./view-diff.js";
41
56
  export type { ViewShape, ViewDiffClass, ViewMigrationOpts } from "./view-diff.js";
42
57
  export { emitPostgresViewMigration } from "./view-ddl-postgres.js";
@@ -60,6 +75,35 @@ export {
60
75
  // D1 introspection
61
76
  export { introspectD1, type D1Runner, type IntrospectD1Options } from "./introspect/d1.js";
62
77
 
78
+ // Migration-history ledger + ordered transactional apply (postgres/sqlite)
79
+ export {
80
+ ensureLedger,
81
+ recordApplied,
82
+ deleteApplied,
83
+ appliedNames,
84
+ appliedRecords,
85
+ recordBaseline,
86
+ baselineRecord,
87
+ BASELINE_NAME,
88
+ MIGRATIONS_TABLE,
89
+ DEFAULT_LEDGER_SCHEMA,
90
+ type LedgerRow,
91
+ type LedgerOptions,
92
+ type LedgerDialect,
93
+ } from "./apply/ledger.js";
94
+ export {
95
+ applyPending,
96
+ rollbackTo,
97
+ type ApplyPendingOptions,
98
+ type ApplyPendingResult,
99
+ type RollbackToOptions,
100
+ type RollbackToResult,
101
+ } from "./apply/apply.js";
102
+
103
+ // Snapshot-integrity: replay migrations and assert == committed snapshot.
104
+ export { verifyReplay } from "./verify/replay.js";
105
+ export type { VerifyReplayArgs, VerifyReplayResult } from "./verify/replay.js";
106
+
63
107
  // Wrangler config helpers
64
108
  export {
65
109
  findWranglerConfig,
@@ -71,6 +71,7 @@ export async function introspectD1(opts: IntrospectD1Options): Promise<SchemaSna
71
71
  columns: cols,
72
72
  indexes: await readIndexes(exec, name),
73
73
  foreignKeys: await readForeignKeys(exec, name),
74
+ checks: [], // CHECK introspection is out of scope; expected-side derives them
74
75
  primaryKey: pk,
75
76
  });
76
77
  }
@@ -28,8 +28,10 @@
28
28
  */
29
29
  import type { Kysely } from "kysely";
30
30
  import { sql } from "kysely";
31
- import type { SchemaSnapshot, TableDescriptor, ColumnDescriptor, ColumnDefault, IndexDescriptor, FkDescriptor, FkAction, ViewDescriptor } from "../types.js";
31
+ import type { SchemaSnapshot, TableDescriptor, ColumnDescriptor, ColumnDefault, IndexDescriptor, FkDescriptor, FkAction, ViewDescriptor, CheckDescriptor } from "../types.js";
32
32
  import type { SqlType } from "../sql-type.js";
33
+ import { MIGRATIONS_TABLE } from "../apply/ledger.js";
34
+ import { stripCheckWrapper } from "../check-expr-compare.js";
33
35
 
34
36
  // ---------------------------------------------------------------------------
35
37
  // Public API
@@ -51,6 +53,7 @@ export async function introspectPostgres(db: Kysely<Record<string, unknown>>): P
51
53
  columns,
52
54
  indexes: await readPgIndexes(k, schema, name),
53
55
  foreignKeys: await readPgForeignKeys(k, schema, name),
56
+ checks: await readPgChecks(k, schema, name),
54
57
  primaryKey,
55
58
  });
56
59
  }
@@ -121,6 +124,7 @@ export function pgTypeToSqlType(dataType: string, maxLength?: number | null): Sq
121
124
 
122
125
  // Date + time
123
126
  if (dt === "date") return { kind: "date" };
127
+ if (dt === "time" || dt === "time without time zone") return { kind: "time" };
124
128
  if (dt === "timestamp" || dt === "timestamp without time zone") {
125
129
  return { kind: "timestamp", withTimezone: false };
126
130
  }
@@ -207,6 +211,7 @@ async function readTableNames(k: Kysely<any>): Promise<SchemaTableRef[]> {
207
211
  WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
208
212
  AND table_schema NOT LIKE 'pg_%'
209
213
  AND table_type = 'BASE TABLE'
214
+ AND table_name <> ${MIGRATIONS_TABLE}
210
215
  ORDER BY table_schema, table_name
211
216
  `.execute(k);
212
217
  return rows.rows.map((r) => ({ schema: r.table_schema, name: r.table_name }));
@@ -217,13 +222,20 @@ async function readPgViews(k: RawKysely): Promise<ViewDescriptor[]> {
217
222
  // "relation views does not exist". We catch and return [] so other tests
218
223
  // still pass on pg-mem. Real PG (Postgres 16) handles this correctly.
219
224
  try {
220
- const rows = await sql<{ table_name: string; table_schema: string }>`
221
- SELECT table_name, table_schema FROM information_schema.views
225
+ // information_schema.views.view_definition is the SELECT body (not the
226
+ // full CREATE VIEW statement). We carry it through on the descriptor so the
227
+ // diff can detect view-body drift (not just name presence).
228
+ const rows = await sql<{ table_name: string; table_schema: string; view_definition: string | null }>`
229
+ SELECT table_name, table_schema, view_definition FROM information_schema.views
222
230
  WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
223
231
  AND table_schema NOT LIKE 'pg_%'
224
232
  ORDER BY table_schema, table_name
225
233
  `.execute(k);
226
- return rows.rows.map((r) => ({ name: r.table_name, schema: r.table_schema }));
234
+ return rows.rows.map((r) => {
235
+ const view: ViewDescriptor = { name: r.table_name, schema: r.table_schema };
236
+ if (r.view_definition) view.sql = r.view_definition;
237
+ return view;
238
+ });
227
239
  } catch {
228
240
  // pg-mem: information_schema.views not supported — return empty view list.
229
241
  return [];
@@ -426,3 +438,25 @@ function pgRuleToAction(rule: string): FkAction {
426
438
  if (r === "RESTRICT") return "restrict";
427
439
  return "no-action";
428
440
  }
441
+
442
+ /**
443
+ * Read CHECK constraints for a table from pg_constraint. pg-mem does not support
444
+ * pg_constraint, so this catches and returns [] there (same accepted gap as
445
+ * readPgForeignKeys/readPgIndexes); real-DB coverage is the MIGRATE_TS_PG_URL-gated
446
+ * integration test. `pg_get_constraintdef` returns `CHECK (<expr>)`; the wrapper is
447
+ * stripped to the expression, compared via normalizeCheckExpr at diff time.
448
+ */
449
+ async function readPgChecks(k: RawKysely, schema: string, table: string): Promise<CheckDescriptor[]> {
450
+ try {
451
+ const rows = await sql<{ name: string; def: string }>`
452
+ SELECT con.conname AS name, pg_get_constraintdef(con.oid) AS def
453
+ FROM pg_constraint con
454
+ JOIN pg_class rel ON rel.oid = con.conrelid
455
+ JOIN pg_namespace ns ON ns.oid = rel.relnamespace
456
+ WHERE con.contype = 'c' AND rel.relname = ${table} AND ns.nspname = ${schema}
457
+ `.execute(k);
458
+ return rows.rows.map((r) => ({ name: r.name, expression: stripCheckWrapper(r.def) }));
459
+ } catch {
460
+ return []; // pg-mem: pg_constraint unsupported
461
+ }
462
+ }
@@ -5,6 +5,7 @@ import type {
5
5
  IndexDescriptor, FkDescriptor, FkAction, ViewDescriptor,
6
6
  } from "../types.js";
7
7
  import { parseSqliteDefault, sqliteTypeToSqlType, sqliteRuleToAction } from "./sqlite-shared.js";
8
+ import { MIGRATIONS_TABLE } from "../apply/ledger.js";
8
9
 
9
10
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
10
11
  type RawKysely = Kysely<any>;
@@ -18,6 +19,7 @@ export async function introspectSqlite(db: Kysely<Record<string, unknown>>): Pro
18
19
  const tableNamesRows = await sql<{ name: string; sql: string | null }>`
19
20
  SELECT name, sql FROM sqlite_master
20
21
  WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '__new_%'
22
+ AND name <> ${MIGRATIONS_TABLE}
21
23
  ORDER BY name
22
24
  `.execute(k);
23
25
 
@@ -36,6 +38,7 @@ export async function introspectSqlite(db: Kysely<Record<string, unknown>>): Pro
36
38
  columns: cols,
37
39
  indexes: await readSqliteIndexes(k, t.name),
38
40
  foreignKeys: await readSqliteForeignKeys(k, t.name),
41
+ checks: [], // CHECK introspection is out of scope; expected-side derives them
39
42
  primaryKey: pk,
40
43
  });
41
44
  }
@@ -45,11 +48,18 @@ export async function introspectSqlite(db: Kysely<Record<string, unknown>>): Pro
45
48
  }
46
49
 
47
50
  async function readSqliteViews(k: RawKysely): Promise<ViewDescriptor[]> {
48
- const rows = await sql<{ name: string }>`
49
- SELECT name FROM sqlite_master WHERE type='view' AND name NOT LIKE 'sqlite_%'
51
+ // sqlite_master.sql holds the full `CREATE VIEW <name> AS <body>` statement.
52
+ // We carry it through on the descriptor so the diff can detect view-body
53
+ // drift (not just name presence).
54
+ const rows = await sql<{ name: string; sql: string | null }>`
55
+ SELECT name, sql FROM sqlite_master WHERE type='view' AND name NOT LIKE 'sqlite_%'
50
56
  ORDER BY name
51
57
  `.execute(k);
52
- return rows.rows.map((r) => ({ name: r.name }));
58
+ return rows.rows.map((r) => {
59
+ const view: ViewDescriptor = { name: r.name };
60
+ if (r.sql) view.sql = r.sql;
61
+ return view;
62
+ });
53
63
  }
54
64
 
55
65
  async function readSqliteColumns(k: RawKysely, table: string): Promise<ColumnDescriptor[]> {
@@ -0,0 +1,15 @@
1
+ // src/snapshot/checksum.ts
2
+ import { createHash } from "node:crypto";
3
+ import type { SchemaSnapshot } from "../types.js";
4
+ import { serializeSnapshot } from "./serialize.js";
5
+
6
+ /**
7
+ * Deterministic sha256 of a schema snapshot. Reuses the canonical
8
+ * (order-stable, byte-identical) serializer, so the hash is independent of
9
+ * table/column ordering and depends only on the schema's content. Used to make
10
+ * the committed snapshot tamper-evident (record the hash; a later hand-edit
11
+ * changes it) and as the baseline marker's payload.
12
+ */
13
+ export function snapshotChecksum(snapshot: SchemaSnapshot): string {
14
+ return createHash("sha256").update(serializeSnapshot(snapshot), "utf8").digest("hex");
15
+ }
@@ -0,0 +1,53 @@
1
+ // src/snapshot/plan.ts
2
+ import type { ColumnNamingStrategy, MetaData } from "@metaobjectsdev/metadata";
3
+ import { buildExpectedSchema } from "../expected-schema.js";
4
+ import { diff, type DiffArgs } from "../diff/index.js";
5
+ import type { Dialect, DiffResult, SchemaSnapshot } from "../types.js";
6
+
7
+ export interface PlanOfflineArgs extends Pick<DiffArgs, "allow" | "onAmbiguous" | "ignoreTables"> {
8
+ metadata: MetaData;
9
+ dialect: Dialect;
10
+ /** The stored reference snapshot (the "from" side). Use `{ tables: [], views: [] }` for a fresh project. */
11
+ snapshot: SchemaSnapshot;
12
+ columnNamingStrategy?: ColumnNamingStrategy;
13
+ }
14
+
15
+ export interface PlanOfflineResult {
16
+ /** The change set to emit, from diffing metadata-expected against the snapshot. */
17
+ diff: DiffResult;
18
+ /** The schema the migration brings us to — write this back as the new snapshot on accept. */
19
+ nextSnapshot: SchemaSnapshot;
20
+ }
21
+
22
+ /**
23
+ * Plan a migration offline: build the expected schema from metadata and diff it
24
+ * against the stored snapshot. No database. The caller emits `diff` and, on
25
+ * accept, persists `nextSnapshot` via writeSnapshot.
26
+ */
27
+ export async function planOffline(args: PlanOfflineArgs): Promise<PlanOfflineResult> {
28
+ const nextSnapshot = buildExpectedSchema(args.metadata, {
29
+ dialect: args.dialect,
30
+ ...(args.columnNamingStrategy ? { columnNamingStrategy: args.columnNamingStrategy } : {}),
31
+ });
32
+ const result = await diff({
33
+ expected: nextSnapshot,
34
+ actual: args.snapshot,
35
+ dialect: args.dialect,
36
+ ...(args.allow ? { allow: args.allow } : {}),
37
+ ...(args.onAmbiguous ? { onAmbiguous: args.onAmbiguous } : {}),
38
+ ...(args.ignoreTables ? { ignoreTables: args.ignoreTables } : {}),
39
+ });
40
+ return { diff: result, nextSnapshot };
41
+ }
42
+
43
+ /** Seed an initial reference snapshot from metadata (greenfield baseline). */
44
+ export function baselineFromMetadata(
45
+ metadata: MetaData,
46
+ dialect: Dialect,
47
+ columnNamingStrategy?: ColumnNamingStrategy,
48
+ ): SchemaSnapshot {
49
+ return buildExpectedSchema(metadata, {
50
+ dialect,
51
+ ...(columnNamingStrategy ? { columnNamingStrategy } : {}),
52
+ });
53
+ }