@metaobjectsdev/migrate-ts 0.5.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 (98) hide show
  1. package/LICENSE +189 -0
  2. package/README.md +73 -0
  3. package/dist/diff/index.d.ts +30 -0
  4. package/dist/diff/index.d.ts.map +1 -0
  5. package/dist/diff/index.js +226 -0
  6. package/dist/diff/index.js.map +1 -0
  7. package/dist/diff/rename-heuristic.d.ts +23 -0
  8. package/dist/diff/rename-heuristic.d.ts.map +1 -0
  9. package/dist/diff/rename-heuristic.js +236 -0
  10. package/dist/diff/rename-heuristic.js.map +1 -0
  11. package/dist/diff/status.d.ts +8 -0
  12. package/dist/diff/status.d.ts.map +1 -0
  13. package/dist/diff/status.js +53 -0
  14. package/dist/diff/status.js.map +1 -0
  15. package/dist/emit/index.d.ts +17 -0
  16. package/dist/emit/index.d.ts.map +1 -0
  17. package/dist/emit/index.js +18 -0
  18. package/dist/emit/index.js.map +1 -0
  19. package/dist/emit/postgres.d.ts +3 -0
  20. package/dist/emit/postgres.d.ts.map +1 -0
  21. package/dist/emit/postgres.js +181 -0
  22. package/dist/emit/postgres.js.map +1 -0
  23. package/dist/emit/sqlite.d.ts +3 -0
  24. package/dist/emit/sqlite.d.ts.map +1 -0
  25. package/dist/emit/sqlite.js +302 -0
  26. package/dist/emit/sqlite.js.map +1 -0
  27. package/dist/errors.d.ts +8 -0
  28. package/dist/errors.d.ts.map +1 -0
  29. package/dist/errors.js +54 -0
  30. package/dist/errors.js.map +1 -0
  31. package/dist/expected-schema.d.ts +15 -0
  32. package/dist/expected-schema.d.ts.map +1 -0
  33. package/dist/expected-schema.js +243 -0
  34. package/dist/expected-schema.js.map +1 -0
  35. package/dist/index.d.ts +18 -0
  36. package/dist/index.d.ts.map +1 -0
  37. package/dist/index.js +25 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/introspect/index.d.ts +6 -0
  40. package/dist/introspect/index.d.ts.map +1 -0
  41. package/dist/introspect/index.js +11 -0
  42. package/dist/introspect/index.js.map +1 -0
  43. package/dist/introspect/postgres.d.ts +57 -0
  44. package/dist/introspect/postgres.d.ts.map +1 -0
  45. package/dist/introspect/postgres.js +339 -0
  46. package/dist/introspect/postgres.js.map +1 -0
  47. package/dist/introspect/sqlite.d.ts +4 -0
  48. package/dist/introspect/sqlite.d.ts.map +1 -0
  49. package/dist/introspect/sqlite.js +192 -0
  50. package/dist/introspect/sqlite.js.map +1 -0
  51. package/dist/source-aware-diff.d.ts +20 -0
  52. package/dist/source-aware-diff.d.ts.map +1 -0
  53. package/dist/source-aware-diff.js +24 -0
  54. package/dist/source-aware-diff.js.map +1 -0
  55. package/dist/sql-type.d.ts +45 -0
  56. package/dist/sql-type.d.ts.map +1 -0
  57. package/dist/sql-type.js +76 -0
  58. package/dist/sql-type.js.map +1 -0
  59. package/dist/types.d.ts +223 -0
  60. package/dist/types.d.ts.map +1 -0
  61. package/dist/types.js +2 -0
  62. package/dist/types.js.map +1 -0
  63. package/dist/view-ddl-postgres.d.ts +4 -0
  64. package/dist/view-ddl-postgres.d.ts.map +1 -0
  65. package/dist/view-ddl-postgres.js +13 -0
  66. package/dist/view-ddl-postgres.js.map +1 -0
  67. package/dist/view-ddl-sqlite.d.ts +3 -0
  68. package/dist/view-ddl-sqlite.d.ts.map +1 -0
  69. package/dist/view-ddl-sqlite.js +7 -0
  70. package/dist/view-ddl-sqlite.js.map +1 -0
  71. package/dist/view-diff.d.ts +13 -0
  72. package/dist/view-diff.d.ts.map +1 -0
  73. package/dist/view-diff.js +42 -0
  74. package/dist/view-diff.js.map +1 -0
  75. package/dist/write-migration.d.ts +19 -0
  76. package/dist/write-migration.d.ts.map +1 -0
  77. package/dist/write-migration.js +34 -0
  78. package/dist/write-migration.js.map +1 -0
  79. package/package.json +50 -0
  80. package/src/diff/index.ts +294 -0
  81. package/src/diff/rename-heuristic.ts +265 -0
  82. package/src/diff/status.ts +55 -0
  83. package/src/emit/index.ts +38 -0
  84. package/src/emit/postgres.ts +189 -0
  85. package/src/emit/sqlite.ts +322 -0
  86. package/src/errors.ts +58 -0
  87. package/src/expected-schema.ts +326 -0
  88. package/src/index.ts +49 -0
  89. package/src/introspect/index.ts +14 -0
  90. package/src/introspect/postgres.ts +428 -0
  91. package/src/introspect/sqlite.ts +216 -0
  92. package/src/source-aware-diff.ts +49 -0
  93. package/src/sql-type.ts +91 -0
  94. package/src/types.ts +174 -0
  95. package/src/view-ddl-postgres.ts +15 -0
  96. package/src/view-ddl-sqlite.ts +7 -0
  97. package/src/view-diff.ts +55 -0
  98. package/src/write-migration.ts +64 -0
@@ -0,0 +1,326 @@
1
+ import type { MetaData, MetaObject, MetaReferenceIdentity, MetaRoot } from "@metaobjectsdev/metadata";
2
+ import {
3
+ TYPE_OBJECT,
4
+ TYPE_SOURCE,
5
+ TYPE_VALIDATOR,
6
+ SOURCE_SUBTYPE_DB_VIEW,
7
+ VALIDATOR_SUBTYPE_REQUIRED,
8
+ IDENTITY_ATTR_FIELDS,
9
+ IDENTITY_ATTR_GENERATION,
10
+ IDENTITY_ATTR_UNIQUE,
11
+ FIELD_ATTR_REQUIRED,
12
+ FIELD_ATTR_DEFAULT,
13
+ FIELD_ATTR_UNIQUE,
14
+ FIELD_SUBTYPE_STRING,
15
+ FIELD_SUBTYPE_INT,
16
+ FIELD_SUBTYPE_LONG,
17
+ FIELD_SUBTYPE_SHORT,
18
+ FIELD_SUBTYPE_BYTE,
19
+ FIELD_SUBTYPE_DOUBLE,
20
+ FIELD_SUBTYPE_FLOAT,
21
+ FIELD_SUBTYPE_DECIMAL,
22
+ FIELD_SUBTYPE_BOOLEAN,
23
+ FIELD_SUBTYPE_CURRENCY,
24
+ FIELD_SUBTYPE_DATE,
25
+ FIELD_SUBTYPE_TIME,
26
+ FIELD_SUBTYPE_TIMESTAMP,
27
+ FIELD_SUBTYPE_OBJECT,
28
+ FIELD_SUBTYPE_CLASS,
29
+ resolveTableName, resolveColumnName, resolveTableSchema,
30
+ } from "@metaobjectsdev/metadata";
31
+ import type { SqlType } from "./sql-type.js";
32
+ import type {
33
+ SchemaSnapshot, TableDescriptor, ColumnDescriptor, IndexDescriptor, FkDescriptor,
34
+ } from "./types.js";
35
+
36
+ export interface BuildExpectedSchemaOptions {
37
+ /**
38
+ * If set, normalize column SqlTypes for the target dialect so the diff
39
+ * matches what introspection will see. For sqlite this collapses
40
+ * boolean → integer{64} and timestamp/date/time → text, since sqlite
41
+ * has no native boolean/timestamp affinity and Drizzle's
42
+ * `integer(..., {mode:"boolean"})` / `text("ts")` patterns produce
43
+ * INTEGER / TEXT in the actual DB.
44
+ */
45
+ dialect?: "sqlite" | "postgres";
46
+ }
47
+
48
+ export function buildExpectedSchema(
49
+ root: MetaData,
50
+ opts?: BuildExpectedSchemaOptions,
51
+ ): SchemaSnapshot {
52
+ // Pass 1: collect entities + their resolved table names.
53
+ // Skip:
54
+ // - abstract objects (e.g., BaseEntity)
55
+ // - value objects (no table backing)
56
+ // - projections (source.dbView — handled by the view-diff pipeline, not table diff)
57
+ const entities: { entity: MetaObject; tableName: string }[] = [];
58
+ for (const child of root.ownChildren()) {
59
+ if (child.type !== TYPE_OBJECT) continue;
60
+ if (child.isAbstract) continue;
61
+ if (child.subType === "value") continue;
62
+ const hasViewSource = child.ownChildren().some(
63
+ (c) => c.type === TYPE_SOURCE && c.subType === SOURCE_SUBTYPE_DB_VIEW,
64
+ );
65
+ if (hasViewSource) continue;
66
+ entities.push({ entity: child as MetaObject, tableName: resolveTableName(child) });
67
+ }
68
+ const entityToTable = new Map(entities.map((e) => [e.entity.name, e.tableName]));
69
+ const resolveTargetTable = (entityName: string) => entityToTable.get(entityName);
70
+
71
+ // Pass 2: build full descriptors with FK resolution.
72
+ // Schema is resolved here (not stored in Pass 1) to avoid exactOptionalPropertyTypes
73
+ // issues with `string | undefined` vs `schema?: string`.
74
+ const tables: TableDescriptor[] = entities.map(({ entity, tableName }) => {
75
+ const t = buildTable(entity, tableName, resolveTargetTable, root as MetaRoot);
76
+ const schema = resolveTableSchema(entity);
77
+ if (schema !== undefined) t.schema = schema;
78
+ return t;
79
+ });
80
+
81
+ // Pass 3: dialect-specific SqlType normalization.
82
+ if (opts?.dialect === "sqlite") {
83
+ for (const table of tables) {
84
+ for (const col of table.columns) {
85
+ col.sqlType = normalizeForSqlite(col.sqlType);
86
+ }
87
+ }
88
+ }
89
+
90
+ // Dialect validation: SQLite has no schema concept; reject any non-default @schema.
91
+ if (opts?.dialect === "sqlite") {
92
+ for (const table of tables) {
93
+ if (table.schema !== undefined) {
94
+ throw new Error(
95
+ `sqlite does not support DB schemas; entity-table "${table.name}" declares @schema "${table.schema}"`,
96
+ );
97
+ }
98
+ }
99
+ }
100
+
101
+ return { tables, views: [] };
102
+ }
103
+
104
+ /**
105
+ * Normalize a canonical SqlType for what sqlite introspection will actually see.
106
+ * sqlite stores all integers (including booleans) as INTEGER, and uses TEXT for
107
+ * date/time/timestamp affinities by default.
108
+ */
109
+ function normalizeForSqlite(sqlType: SqlType): SqlType {
110
+ switch (sqlType.kind) {
111
+ case "boolean":
112
+ return { kind: "integer", bits: 64 };
113
+ case "timestamp":
114
+ case "date":
115
+ return { kind: "text" };
116
+ case "integer":
117
+ // SQLite stores every INTEGER as a 64-bit value and Drizzle's int() emits
118
+ // plain "INTEGER" regardless of source bit-width. Collapse 32 → 64 so the
119
+ // expected snapshot matches what introspection sees.
120
+ return { kind: "integer", bits: 64 };
121
+ default:
122
+ return sqlType;
123
+ }
124
+ }
125
+
126
+ function buildTable(
127
+ entity: MetaObject,
128
+ tableName: string,
129
+ resolveTargetTable: (entityName: string) => string | undefined,
130
+ root: MetaRoot,
131
+ ): TableDescriptor {
132
+ // Use effective accessors so inherited fields/identities (from `extends:` /
133
+ // abstract bases like BaseEntity) are included.
134
+ const pkIdentity = entity.primaryIdentity();
135
+
136
+ const pkJsNames = pkIdentity ? readIdentityFields(pkIdentity) : [];
137
+ const pkGeneration = pkIdentity
138
+ ? (pkIdentity.ownAttr(IDENTITY_ATTR_GENERATION) as string | undefined)
139
+ : undefined;
140
+
141
+ const primaryKey = pkJsNames.map((jsName) => {
142
+ const field = findField(entity, jsName);
143
+ return field ? resolveColumnName(field) : toSnake(jsName);
144
+ });
145
+
146
+ const columns: ColumnDescriptor[] = [];
147
+ for (const field of entity.fields()) {
148
+ const isPk = pkJsNames.includes(field.name);
149
+ columns.push(buildColumn(field, isPk, isPk ? pkGeneration : undefined));
150
+ }
151
+
152
+ return {
153
+ name: tableName,
154
+ columns,
155
+ indexes: buildSecondaryIndexes(entity, tableName),
156
+ foreignKeys: buildForeignKeys(entity, tableName, resolveTargetTable, root),
157
+ primaryKey,
158
+ };
159
+ }
160
+
161
+ function buildSecondaryIndexes(entity: MetaObject, tableName: string): IndexDescriptor[] {
162
+ const indexes: IndexDescriptor[] = [];
163
+
164
+ // (a) Implicit unique indexes from @unique fields. Drizzle auto-creates these
165
+ // on the DB side using the convention `<table>_<column>_unique` whenever a
166
+ // column has `.unique()`. We mirror them in the expected schema so the diff
167
+ // doesn't see them as drop-only on the actual side.
168
+ for (const field of entity.fields()) {
169
+ if (field.ownAttr(FIELD_ATTR_UNIQUE) !== true) continue;
170
+ const colName = resolveColumnName(field);
171
+ indexes.push({
172
+ name: `${tableName}_${colName}_unique`,
173
+ columns: [colName],
174
+ unique: true,
175
+ });
176
+ }
177
+
178
+ // (b) Explicit secondary identities — unique-by-default, opt out with @unique: false.
179
+ // Drizzle emits the index using the identity's @name attr directly (no table
180
+ // prefix), so the expected name must match.
181
+ for (const identity of entity.secondaryIdentities()) {
182
+ const fieldNames = readIdentityFields(identity);
183
+ if (fieldNames.length === 0) continue;
184
+ const cols = fieldNames.map((jsName) => {
185
+ const field = findField(entity, jsName);
186
+ return field ? resolveColumnName(field) : toSnake(jsName);
187
+ });
188
+ const uniqueAttr = identity.ownAttr(IDENTITY_ATTR_UNIQUE);
189
+ indexes.push({
190
+ name: identity.name,
191
+ columns: cols,
192
+ unique: uniqueAttr !== false,
193
+ });
194
+ }
195
+ return indexes;
196
+ }
197
+
198
+ function buildForeignKeys(
199
+ entity: MetaObject,
200
+ tableName: string,
201
+ resolveTargetTable: (entityName: string) => string | undefined,
202
+ root: MetaRoot,
203
+ ): FkDescriptor[] {
204
+ const fks: FkDescriptor[] = [];
205
+ for (const refChild of entity.referenceIdentities()) {
206
+ // @enforce: false → logical-only reference; not a physical FK constraint.
207
+ if (!refChild.enforce) continue;
208
+ const targetEntity = refChild.targetEntity;
209
+ if (targetEntity === undefined) continue;
210
+ const refTable = resolveTargetTable(targetEntity);
211
+ if (!refTable) continue;
212
+
213
+ const fkFieldJsNames = readIdentityFields(refChild);
214
+ if (fkFieldJsNames.length === 0) continue;
215
+
216
+ const fkCols = fkFieldJsNames.map((jsName) => {
217
+ const fkField = findField(entity, jsName);
218
+ return fkField ? resolveColumnName(fkField) : toSnake(jsName);
219
+ });
220
+
221
+ // Target columns: prefer explicit multi-field dotted form, else delegate
222
+ // to MetaReferenceIdentity.resolvedTargetPkField (single field → target's
223
+ // primary identity → "id" fallback).
224
+ const explicitTargetFields = refChild.targetFields;
225
+ const refColumns = explicitTargetFields.length > 1
226
+ ? explicitTargetFields.map(toSnake)
227
+ : [toSnake(refChild.resolvedTargetPkField(root) ?? "id")];
228
+
229
+ fks.push({
230
+ name: `${tableName}_${fkCols[0]}_fk`,
231
+ columns: fkCols,
232
+ refTable,
233
+ refColumns,
234
+ });
235
+ }
236
+ return fks;
237
+ }
238
+
239
+ const EXPR_DEFAULT_PATTERNS = [
240
+ /^current_timestamp$/i,
241
+ /^now\(\)$/i,
242
+ /^current_date$/i,
243
+ /^current_time$/i,
244
+ /\(\)/, // anything function-like
245
+ ];
246
+
247
+ function buildColumn(
248
+ field: MetaData,
249
+ isPk: boolean,
250
+ pkGeneration: string | undefined,
251
+ ): ColumnDescriptor {
252
+ const requiredAttr = field.ownAttr(FIELD_ATTR_REQUIRED);
253
+ // BaseEntity-style metadata expresses required via a validator.required child;
254
+ // direct attr form is the alternative. Either signals NOT NULL.
255
+ const hasRequiredValidator = field.ownChildren().some(
256
+ (c) => c.type === TYPE_VALIDATOR && c.subType === VALIDATOR_SUBTYPE_REQUIRED,
257
+ );
258
+ const isRequired = requiredAttr === true || requiredAttr === "true" || hasRequiredValidator;
259
+ const defaultRaw = field.ownAttr(FIELD_ATTR_DEFAULT);
260
+
261
+ const col: ColumnDescriptor = {
262
+ name: resolveColumnName(field),
263
+ sqlType: subtypeToSqlType(field.subType),
264
+ nullable: !isPk && !isRequired,
265
+ };
266
+
267
+ if (typeof defaultRaw === "string" && defaultRaw.length > 0) {
268
+ const isExpr = EXPR_DEFAULT_PATTERNS.some((re) => re.test(defaultRaw));
269
+ col.default = { kind: isExpr ? "expr" : "literal", value: defaultRaw };
270
+ } else if (typeof defaultRaw === "boolean" || typeof defaultRaw === "number") {
271
+ col.default = { kind: "literal", value: String(defaultRaw) };
272
+ }
273
+
274
+ if (isPk && (pkGeneration === "increment" || pkGeneration === "uuid")) {
275
+ col.identity = pkGeneration;
276
+ }
277
+
278
+ return col;
279
+ }
280
+
281
+ function subtypeToSqlType(subType: string): SqlType {
282
+ switch (subType) {
283
+ case FIELD_SUBTYPE_STRING: return { kind: "text" };
284
+ case FIELD_SUBTYPE_INT:
285
+ case FIELD_SUBTYPE_SHORT:
286
+ case FIELD_SUBTYPE_BYTE: return { kind: "integer", bits: 32 };
287
+ case FIELD_SUBTYPE_LONG:
288
+ case FIELD_SUBTYPE_CURRENCY: return { kind: "integer", bits: 64 };
289
+ case FIELD_SUBTYPE_DOUBLE:
290
+ case FIELD_SUBTYPE_FLOAT: return { kind: "real" };
291
+ case FIELD_SUBTYPE_DECIMAL: return { kind: "numeric" };
292
+ case FIELD_SUBTYPE_BOOLEAN: return { kind: "boolean" };
293
+ case FIELD_SUBTYPE_DATE: return { kind: "date" };
294
+ case FIELD_SUBTYPE_TIME: return { kind: "text" }; // SQL TIME rare; coerce to text
295
+ case FIELD_SUBTYPE_TIMESTAMP: return { kind: "timestamp", withTimezone: false };
296
+ case FIELD_SUBTYPE_OBJECT:
297
+ case FIELD_SUBTYPE_CLASS: return { kind: "json" };
298
+ default: return { kind: "text" }; // unknown → text fallback
299
+ }
300
+ }
301
+
302
+ function readIdentityFields(identity: MetaData): string[] {
303
+ const raw = identity.ownAttr(IDENTITY_ATTR_FIELDS);
304
+ if (Array.isArray(raw)) {
305
+ return raw.map(String).filter((s) => s.length > 0);
306
+ }
307
+ if (typeof raw === "string") {
308
+ // Fallback: comma-separated string form (defensive; canonical form is array)
309
+ return raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
310
+ }
311
+ return [];
312
+ }
313
+
314
+ function findField(entity: MetaObject, name: string): MetaData | undefined {
315
+ for (const field of entity.fields()) {
316
+ if (field.name === name) return field;
317
+ }
318
+ return undefined;
319
+ }
320
+
321
+ function toSnake(s: string): string {
322
+ return s
323
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2")
324
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
325
+ .toLowerCase();
326
+ }
package/src/index.ts ADDED
@@ -0,0 +1,49 @@
1
+ // Public API surface for @metaobjectsdev/migrate-ts v0.1.0
2
+ //
3
+ // Architecture: pure pipeline — buildExpectedSchema(metadata) +
4
+ // introspect(db, dialect) → SchemaSnapshot; diff(expected, actual, opts)
5
+ // → Change[]; emit(changes, opts) → { up, down }; writeMigration(...)
6
+ // writes the pair to disk.
7
+ //
8
+ // See docs/specs/2026-05-11-v0.2-sp4-migrate-ts-design.md.
9
+
10
+ // Pipeline functions
11
+ export { buildExpectedSchema } from "./expected-schema.js";
12
+ export { introspect, introspectPostgres, introspectSqlite } from "./introspect/index.js";
13
+ export { diff } from "./diff/index.js";
14
+ export { emit } from "./emit/index.js";
15
+ export { writeMigration } from "./write-migration.js";
16
+
17
+ // Errors
18
+ export { BlockedChangesError } from "./errors.js";
19
+
20
+ // SqlType helpers (rarely needed but useful for advanced consumers)
21
+ export { isWidening, sqlTypeEquals } from "./sql-type.js";
22
+
23
+ // Types
24
+ export type { SqlType } from "./sql-type.js";
25
+ export type {
26
+ SchemaSnapshot, SnapshotMeta,
27
+ TableDescriptor, ColumnDescriptor, IndexDescriptor, FkDescriptor, ColumnDefault,
28
+ ViewDescriptor, FkAction,
29
+ Change, ChangeKind, ChangeStatus,
30
+ AllowOptions, AmbiguousChange, AmbiguousResolution, AmbiguousCallback,
31
+ DiffResult, EmitResult, Dialect,
32
+ } from "./types.js";
33
+ export type { DiffArgs } from "./diff/index.js";
34
+ export type { EmitOptions } from "./emit/index.js";
35
+ export type { WriteMigrationOptions, WriteMigrationResult } from "./write-migration.js";
36
+
37
+ // View diff + dialect emitters
38
+ export { classifyViewDiff } from "./view-diff.js";
39
+ export type { ViewShape, ViewDiffClass, ViewMigrationOpts } from "./view-diff.js";
40
+ export { emitPostgresViewMigration } from "./view-ddl-postgres.js";
41
+ export { emitSqliteViewMigration } from "./view-ddl-sqlite.js";
42
+
43
+ // View migrations orchestrator
44
+ export {
45
+ computeViewMigrations,
46
+ type ViewMigrationInput,
47
+ type ViewMigrationsOpts,
48
+ type ViewMigrationsResult,
49
+ } from "./source-aware-diff.js";
@@ -0,0 +1,14 @@
1
+ import type { Kysely } from "kysely";
2
+ import type { Dialect, SchemaSnapshot } from "../types.js";
3
+ import { introspectPostgres } from "./postgres.js";
4
+ import { introspectSqlite } from "./sqlite.js";
5
+
6
+ export { introspectPostgres } from "./postgres.js";
7
+ export { introspectSqlite } from "./sqlite.js";
8
+
9
+ export async function introspect(db: Kysely<Record<string, unknown>>, dialect: Dialect): Promise<SchemaSnapshot> {
10
+ switch (dialect) {
11
+ case "postgres": return introspectPostgres(db);
12
+ case "sqlite": return introspectSqlite(db);
13
+ }
14
+ }