@metaobjectsdev/migrate-ts 0.5.0-rc.3 → 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,94 @@
1
+ const MAX_STATEMENT_BYTES = 1 * 1024 * 1024; // 1 MB — D1 batch API per-statement limit (path used by `wrangler d1 migrations apply --file`).
2
+
3
+ export class D1UnsupportedStatementError extends Error {
4
+ constructor(public readonly statement: string, public readonly reason: string) {
5
+ super(`D1 does not support: ${reason} — offending statement: ${statement.slice(0, 80)}`);
6
+ this.name = "D1UnsupportedStatementError";
7
+ }
8
+ }
9
+
10
+ interface PassResult {
11
+ sql: string;
12
+ warnings: string[];
13
+ }
14
+
15
+ export function applyD1SafetyPass(sql: string): string;
16
+ export function applyD1SafetyPass(sql: string, opts: { collectWarnings: true }): PassResult;
17
+ export function applyD1SafetyPass(sql: string, opts?: { collectWarnings?: boolean }): string | PassResult {
18
+ const collect = opts?.collectWarnings === true;
19
+ const warnings: string[] = [];
20
+
21
+ if (sql.length === 0) {
22
+ return collect ? { sql: "", warnings } : "";
23
+ }
24
+
25
+ const statements = splitStatements(sql);
26
+ const kept: string[] = [];
27
+
28
+ for (const stmt of statements) {
29
+ const trimmed = stmt.trim();
30
+ if (trimmed.length === 0) continue;
31
+
32
+ // Reject hard failures up front.
33
+ if (/^\s*(ATTACH|DETACH)\b/i.test(trimmed)) {
34
+ throw new D1UnsupportedStatementError(trimmed, "ATTACH/DETACH DATABASE");
35
+ }
36
+ if (/^\s*VACUUM\b/i.test(trimmed)) {
37
+ throw new D1UnsupportedStatementError(trimmed, "VACUUM");
38
+ }
39
+
40
+ // Strip explicit transaction control + savepoints.
41
+ if (/^\s*(BEGIN|COMMIT|ROLLBACK|SAVEPOINT|RELEASE)\b/i.test(trimmed)) {
42
+ continue;
43
+ }
44
+
45
+ const byteLen = byteLength(trimmed);
46
+ if (byteLen > MAX_STATEMENT_BYTES) {
47
+ warnings.push(
48
+ `statement exceeds D1's 1 MB per-statement limit (${byteLen} bytes); ` +
49
+ `may be rejected by D1 at apply time: ${trimmed.slice(0, 80)}...`,
50
+ );
51
+ }
52
+
53
+ kept.push(trimmed);
54
+ }
55
+
56
+ // Re-join: each statement on its own line, blank line between top-level
57
+ // DDL statements (matches sqlite emit's output style).
58
+ const out = kept.join("\n\n");
59
+ return collect ? { sql: out, warnings } : out;
60
+ }
61
+
62
+ /**
63
+ * Split SQL on `;` boundaries, respecting single-quoted strings (SQL uses
64
+ * '' to escape a single quote inside a literal — two consecutive quotes toggle
65
+ * inString twice, net zero, which is exactly what we want).
66
+ * Sufficient for our DDL output; we don't generate dollar-quoted blocks or
67
+ * other exotic SQLite literals.
68
+ */
69
+ function splitStatements(sql: string): string[] {
70
+ const out: string[] = [];
71
+ let buf = "";
72
+ let inString = false;
73
+ for (let i = 0; i < sql.length; i++) {
74
+ const c = sql[i]!;
75
+ if (c === "'") {
76
+ buf += c;
77
+ inString = !inString;
78
+ continue;
79
+ }
80
+ if (c === ";" && !inString) {
81
+ buf += ";";
82
+ out.push(buf);
83
+ buf = "";
84
+ continue;
85
+ }
86
+ buf += c;
87
+ }
88
+ if (buf.trim().length > 0) out.push(buf);
89
+ return out;
90
+ }
91
+
92
+ function byteLength(s: string): number {
93
+ return new TextEncoder().encode(s).length;
94
+ }
package/src/emit/d1.ts ADDED
@@ -0,0 +1,16 @@
1
+ import type { Change, EmitResult, SchemaSnapshot, SnapshotMeta } from "../types.js";
2
+ import { renderSqlite } from "./sqlite.js";
3
+ import { applyD1SafetyPass } from "./d1-safety-pass.js";
4
+
5
+ export function renderD1(
6
+ changes: readonly Change[],
7
+ expectedSchema?: SchemaSnapshot,
8
+ actualMeta?: SnapshotMeta,
9
+ ): EmitResult {
10
+ const sqliteResult = renderSqlite(changes, expectedSchema, actualMeta);
11
+ return {
12
+ up: applyD1SafetyPass(sqliteResult.up),
13
+ down: applyD1SafetyPass(sqliteResult.down),
14
+ recreatedTables: sqliteResult.recreatedTables,
15
+ };
16
+ }
package/src/emit/index.ts CHANGED
@@ -2,6 +2,7 @@ import type { Change, EmitResult, Dialect, SchemaSnapshot, SnapshotMeta } from "
2
2
  import { BlockedChangesError } from "../errors.js";
3
3
  import { renderPostgres } from "./postgres.js";
4
4
  import { renderSqlite } from "./sqlite.js";
5
+ import { renderD1 } from "./d1.js";
5
6
 
6
7
  export interface EmitOptions {
7
8
  dialect: Dialect;
@@ -34,5 +35,6 @@ export function emit(changes: Change[], opts: EmitOptions): EmitResult {
34
35
  switch (opts.dialect) {
35
36
  case "postgres": return renderPostgres(changes);
36
37
  case "sqlite": return renderSqlite(changes, opts.expectedSchema, opts.actualMeta);
38
+ case "d1": return renderD1(changes, opts.expectedSchema, opts.actualMeta);
37
39
  }
38
40
  }
@@ -38,7 +38,11 @@ function renderUp(c: Change): string {
38
38
  case "create-table": return renderCreateTable(c.table);
39
39
  case "drop-table": return `DROP TABLE ${quoteQualified(c.table, c.schema)};`;
40
40
  case "rename-table": return `ALTER TABLE ${quoteQualified(c.from, c.schema)} RENAME TO ${quote(c.to)};`;
41
- case "add-column": return `ALTER TABLE ${quoteQualified(c.table, c.schema)} ADD COLUMN ${renderColumn(c.column)};`;
41
+ case "add-column": {
42
+ const base = `ALTER TABLE ${quoteQualified(c.table, c.schema)} ADD COLUMN ${renderColumn(c.column)};`;
43
+ if (!c.column.description) return base;
44
+ return `${base}\n${columnCommentSql(c.table, c.schema, c.column.name, c.column.description)}`;
45
+ }
42
46
  case "drop-column": return `ALTER TABLE ${quoteQualified(c.table, c.schema)} DROP COLUMN ${quote(c.column)};`;
43
47
  case "rename-column": return `ALTER TABLE ${quoteQualified(c.table, c.schema)} RENAME COLUMN ${quote(c.from)} TO ${quote(c.to)};`;
44
48
  case "change-column-type": return `ALTER TABLE ${quoteQualified(c.table, c.schema)} ALTER COLUMN ${quote(c.column)} TYPE ${pgType(c.to)};`;
@@ -95,7 +99,35 @@ function renderCreateTable(t: TableDescriptor): string {
95
99
  if (t.primaryKey.length > 0) {
96
100
  colDefs.push(` CONSTRAINT ${quote(t.name + "_pkey")} PRIMARY KEY (${t.primaryKey.map(quote).join(", ")})`);
97
101
  }
98
- return `CREATE TABLE ${quoteQualified(t.name, t.schema)} (\n${colDefs.join(",\n")}\n);`;
102
+ const create = `CREATE TABLE ${quoteQualified(t.name, t.schema)} (\n${colDefs.join(",\n")}\n);`;
103
+ const comments = renderTableComments(t);
104
+ return comments.length === 0 ? create : `${create}\n${comments.join("\n")}`;
105
+ }
106
+
107
+ function renderTableComments(t: TableDescriptor): string[] {
108
+ const out: string[] = [];
109
+ if (t.description) {
110
+ out.push(`COMMENT ON TABLE ${quoteQualified(t.name, t.schema)} IS '${pgEscape(t.description)}';`);
111
+ }
112
+ for (const col of t.columns) {
113
+ if (col.description) {
114
+ out.push(columnCommentSql(t.name, t.schema, col.name, col.description));
115
+ }
116
+ }
117
+ return out;
118
+ }
119
+
120
+ function columnCommentSql(
121
+ table: string,
122
+ schema: string | undefined,
123
+ column: string,
124
+ description: string,
125
+ ): string {
126
+ return `COMMENT ON COLUMN ${quoteQualified(table, schema)}.${quote(column)} IS '${pgEscape(description)}';`;
127
+ }
128
+
129
+ function pgEscape(s: string): string {
130
+ return s.replace(/'/g, "''");
99
131
  }
100
132
 
101
133
  function renderColumn(c: ColumnDescriptor): string {
@@ -132,7 +164,7 @@ function pgType(t: SqlType): string {
132
164
  function renderDefault(d: ColumnDefault): string {
133
165
  if (d.kind === "expr") return d.value;
134
166
  // Literal: quote string-form values.
135
- return `'${d.value.replace(/'/g, "''")}'`;
167
+ return `'${pgEscape(d.value)}'`;
136
168
  }
137
169
 
138
170
  function renderCreateIndex(table: string, schema: string | undefined, ix: IndexDescriptor): string {
@@ -23,7 +23,7 @@ const RECREATE_TRIGGERING_KINDS = new Set<Change["kind"]>([
23
23
  ]);
24
24
 
25
25
  export function renderSqlite(
26
- changes: Change[],
26
+ changes: readonly Change[],
27
27
  expectedSchema?: SchemaSnapshot,
28
28
  actualMeta?: SnapshotMeta,
29
29
  ): EmitResult {
package/src/errors.ts CHANGED
@@ -1,5 +1,38 @@
1
1
  import type { Change, ChangeKind } from "./types.js";
2
2
 
3
+ // ---------------------------------------------------------------------------
4
+ // SetNullNotNullableError — surfaced by buildExpectedSchema
5
+ // ---------------------------------------------------------------------------
6
+
7
+ /**
8
+ * Thrown when a foreign key's resolved ON DELETE action is "set-null" but one
9
+ * or more of its FK columns map to a NOT NULL field (@required: true).
10
+ *
11
+ * ON DELETE SET NULL requires the FK column(s) to be nullable — Postgres and
12
+ * SQLite both reject the combination at DDL execution time.
13
+ *
14
+ * Fix: either remove @required from the FK field(s), or override the
15
+ * relationship with @onDelete: "restrict" / "no-action" to avoid set-null.
16
+ */
17
+ export class SetNullNotNullableError extends Error {
18
+ override readonly name = "SetNullNotNullableError";
19
+ readonly entityName: string;
20
+ readonly constraintName: string;
21
+ readonly offendingFields: string[];
22
+
23
+ constructor(entityName: string, constraintName: string, offendingFields: string[]) {
24
+ const fieldList = offendingFields.join(", ");
25
+ super(
26
+ `Entity "${entityName}": FK constraint "${constraintName}" uses ON DELETE SET NULL ` +
27
+ `but field(s) [${fieldList}] are NOT NULL (@required: true). ` +
28
+ `Fix: remove @required from the FK field(s), or override with @onDelete: "restrict" / "no-action" on the relationship.`,
29
+ );
30
+ this.entityName = entityName;
31
+ this.constraintName = constraintName;
32
+ this.offendingFields = offendingFields;
33
+ }
34
+ }
35
+
3
36
  const ENABLE_FLAG_BY_KIND: Partial<Record<ChangeKind, string>> = {
4
37
  "drop-column": "allow.dropColumn",
5
38
  "drop-table": "allow.dropTable",
@@ -1,14 +1,9 @@
1
- import type { MetaData, MetaObject, MetaReferenceIdentity, MetaRoot } from "@metaobjectsdev/metadata";
1
+ import type { MetaData, MetaObject, MetaRoot } from "@metaobjectsdev/metadata";
2
2
  import {
3
3
  TYPE_OBJECT,
4
- TYPE_SOURCE,
5
- TYPE_VALIDATOR,
6
- SOURCE_SUBTYPE_DB_VIEW,
7
- VALIDATOR_SUBTYPE_REQUIRED,
8
- IDENTITY_ATTR_FIELDS,
4
+ MetaSource,
9
5
  IDENTITY_ATTR_GENERATION,
10
6
  IDENTITY_ATTR_UNIQUE,
11
- FIELD_ATTR_REQUIRED,
12
7
  FIELD_ATTR_DEFAULT,
13
8
  FIELD_ATTR_UNIQUE,
14
9
  FIELD_SUBTYPE_STRING,
@@ -26,43 +21,63 @@ import {
26
21
  FIELD_SUBTYPE_TIMESTAMP,
27
22
  FIELD_SUBTYPE_OBJECT,
28
23
  FIELD_SUBTYPE_CLASS,
24
+ FIELD_ATTR_OBJECT_REF,
25
+ FIELD_ATTR_STORAGE,
26
+ STORAGE_FLATTENED,
27
+ DOC_ATTR_DESCRIPTION,
29
28
  resolveTableName, resolveColumnName, resolveTableSchema,
30
29
  } from "@metaobjectsdev/metadata";
31
30
  import type { SqlType } from "./sql-type.js";
32
31
  import type {
33
- SchemaSnapshot, TableDescriptor, ColumnDescriptor, IndexDescriptor, FkDescriptor,
32
+ Dialect, SchemaSnapshot, TableDescriptor, ColumnDescriptor, IndexDescriptor, FkDescriptor,
34
33
  } from "./types.js";
34
+ import {
35
+ resolveReferentialActions,
36
+ validateSetNullNullability,
37
+ readIdentityFields,
38
+ findField,
39
+ isRequired,
40
+ } from "./referential-actions.js";
35
41
 
36
42
  export interface BuildExpectedSchemaOptions {
37
43
  /**
38
44
  * 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.
45
+ * matches what introspection will see. For sqlite (and d1, which is SQLite
46
+ * at the SQL level) this collapses boolean → integer{64} and
47
+ * timestamp/date/time → text, since sqlite has no native boolean/timestamp
48
+ * affinity and Drizzle's `integer(..., {mode:"boolean"})` / `text("ts")`
49
+ * patterns produce INTEGER / TEXT in the actual DB.
44
50
  */
45
- dialect?: "sqlite" | "postgres";
51
+ dialect?: Dialect;
46
52
  }
47
53
 
48
54
  export function buildExpectedSchema(
49
55
  root: MetaData,
50
56
  opts?: BuildExpectedSchemaOptions,
51
57
  ): SchemaSnapshot {
58
+ // D1 is SQLite at the SQL level; normalize it so downstream dialect checks
59
+ // don't need to handle "d1" separately.
60
+ const dialect = opts?.dialect === "d1" ? "sqlite" : opts?.dialect;
61
+
52
62
  // Pass 1: collect entities + their resolved table names.
53
63
  // Skip:
54
64
  // - abstract objects (e.g., BaseEntity)
55
65
  // - value objects (no table backing)
56
- // - projections (source.dbView handled by the view-diff pipeline, not table diff)
66
+ // - projections (read-only @kind source with no writable peer handled by
67
+ // the view-diff pipeline, not the table diff)
57
68
  const entities: { entity: MetaObject; tableName: string }[] = [];
58
69
  for (const child of root.ownChildren()) {
59
70
  if (child.type !== TYPE_OBJECT) continue;
60
71
  if (child.isAbstract) continue;
61
72
  if (child.subType === "value") continue;
62
- const hasViewSource = child.ownChildren().some(
63
- (c) => c.type === TYPE_SOURCE && c.subType === SOURCE_SUBTYPE_DB_VIEW,
73
+ const hasReadOnlySource = child.ownChildren().some(
74
+ (c) => c instanceof MetaSource && c.isReadOnly(),
64
75
  );
65
- if (hasViewSource) continue;
76
+ const hasWritableSource = child.ownChildren().some(
77
+ (c) => c instanceof MetaSource && c.isWritable(),
78
+ );
79
+ // Projection: read-only and not write-through.
80
+ if (hasReadOnlySource && !hasWritableSource) continue;
66
81
  entities.push({ entity: child as MetaObject, tableName: resolveTableName(child) });
67
82
  }
68
83
  const entityToTable = new Map(entities.map((e) => [e.entity.name, e.tableName]));
@@ -79,7 +94,7 @@ export function buildExpectedSchema(
79
94
  });
80
95
 
81
96
  // Pass 3: dialect-specific SqlType normalization.
82
- if (opts?.dialect === "sqlite") {
97
+ if (dialect === "sqlite") {
83
98
  for (const table of tables) {
84
99
  for (const col of table.columns) {
85
100
  col.sqlType = normalizeForSqlite(col.sqlType);
@@ -88,7 +103,7 @@ export function buildExpectedSchema(
88
103
  }
89
104
 
90
105
  // Dialect validation: SQLite has no schema concept; reject any non-default @schema.
91
- if (opts?.dialect === "sqlite") {
106
+ if (dialect === "sqlite") {
92
107
  for (const table of tables) {
93
108
  if (table.schema !== undefined) {
94
109
  throw new Error(
@@ -146,16 +161,39 @@ function buildTable(
146
161
  const columns: ColumnDescriptor[] = [];
147
162
  for (const field of entity.fields()) {
148
163
  const isPk = pkJsNames.includes(field.name);
149
- columns.push(buildColumn(field, isPk, isPk ? pkGeneration : undefined));
164
+ if (
165
+ field.subType === FIELD_SUBTYPE_OBJECT &&
166
+ field.ownAttr(FIELD_ATTR_STORAGE) === STORAGE_FLATTENED
167
+ ) {
168
+ // Flattened storage: expand nested value-object fields as prefixed columns.
169
+ // The parent field.object itself does NOT produce its own column.
170
+ columns.push(...flattenObjectField(field, root));
171
+ } else {
172
+ columns.push(buildColumn(field, isPk, isPk ? pkGeneration : undefined));
173
+ }
150
174
  }
151
175
 
152
- return {
176
+ const descriptor: TableDescriptor = {
153
177
  name: tableName,
154
178
  columns,
155
179
  indexes: buildSecondaryIndexes(entity, tableName),
156
180
  foreignKeys: buildForeignKeys(entity, tableName, resolveTargetTable, root),
157
181
  primaryKey,
158
182
  };
183
+ const entityDesc = readDescription(entity);
184
+ if (entityDesc !== undefined) descriptor.description = entityDesc;
185
+ return descriptor;
186
+ }
187
+
188
+ /**
189
+ * Read effective `description` attr from a node. Returns the string if present
190
+ * and non-empty, undefined otherwise. Uses `.attr` (effective, not own) so a
191
+ * node that extends an abstract base picks up the base's description — required
192
+ * for both entity- and field-level COMMENT ON parity with the entity-attr contract.
193
+ */
194
+ function readDescription(node: { attr: (n: string) => unknown }): string | undefined {
195
+ const v = node.attr(DOC_ATTR_DESCRIPTION);
196
+ return typeof v === "string" && v.length > 0 ? v : undefined;
159
197
  }
160
198
 
161
199
  function buildSecondaryIndexes(entity: MetaObject, tableName: string): IndexDescriptor[] {
@@ -226,16 +264,47 @@ function buildForeignKeys(
226
264
  ? explicitTargetFields.map(toSnake)
227
265
  : [toSnake(refChild.resolvedTargetPkField(root) ?? "id")];
228
266
 
229
- fks.push({
230
- name: `${tableName}_${fkCols[0]}_fk`,
267
+ const { onDelete, onUpdate } = resolveReferentialActions(entity, refChild);
268
+ const constraintName = `${tableName}_${fkCols[0]}_fk`;
269
+
270
+ // Guard: ON DELETE SET NULL requires nullable FK columns.
271
+ validateSetNullNullability(entity, refChild, onDelete, constraintName);
272
+
273
+ const fk: FkDescriptor = {
274
+ name: constraintName,
231
275
  columns: fkCols,
232
276
  refTable,
233
277
  refColumns,
234
- });
278
+ };
279
+ if (onDelete !== undefined) fk.onDelete = onDelete;
280
+ if (onUpdate !== undefined) fk.onUpdate = onUpdate;
281
+ fks.push(fk);
235
282
  }
236
283
  return fks;
237
284
  }
238
285
 
286
+ /**
287
+ * Expand a `field.object @storage "flattened"` into one ColumnDescriptor per
288
+ * nested field of the referenced value-object, prefixed by the parent field's
289
+ * resolved column name + underscore.
290
+ *
291
+ * EF OwnsOne pattern: no JSON column for the parent itself; each nested field
292
+ * becomes `<parent_col>_<nested_col>` in the owning entity's table.
293
+ */
294
+ function flattenObjectField(field: MetaData, root: MetaRoot): ColumnDescriptor[] {
295
+ const ref = field.ownAttr(FIELD_ATTR_OBJECT_REF);
296
+ if (typeof ref !== "string" || ref.length === 0) return [];
297
+ const targetObject = root.findObject(ref);
298
+ if (targetObject === undefined) return [];
299
+ const prefix = resolveColumnName(field) + "_";
300
+ const cols: ColumnDescriptor[] = [];
301
+ for (const nested of targetObject.fields()) {
302
+ const inner = buildColumn(nested, /* isPk */ false, /* pkGeneration */ undefined);
303
+ cols.push({ ...inner, name: prefix + inner.name });
304
+ }
305
+ return cols;
306
+ }
307
+
239
308
  const EXPR_DEFAULT_PATTERNS = [
240
309
  /^current_timestamp$/i,
241
310
  /^now\(\)$/i,
@@ -249,19 +318,14 @@ function buildColumn(
249
318
  isPk: boolean,
250
319
  pkGeneration: string | undefined,
251
320
  ): 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;
321
+ // Both the @required attr and the validator.required child signal NOT NULL.
322
+ const fieldIsRequired = isRequired(field);
259
323
  const defaultRaw = field.ownAttr(FIELD_ATTR_DEFAULT);
260
324
 
261
325
  const col: ColumnDescriptor = {
262
326
  name: resolveColumnName(field),
263
327
  sqlType: subtypeToSqlType(field.subType),
264
- nullable: !isPk && !isRequired,
328
+ nullable: !isPk && !fieldIsRequired,
265
329
  };
266
330
 
267
331
  if (typeof defaultRaw === "string" && defaultRaw.length > 0) {
@@ -275,6 +339,9 @@ function buildColumn(
275
339
  col.identity = pkGeneration;
276
340
  }
277
341
 
342
+ const fieldDesc = readDescription(field);
343
+ if (fieldDesc !== undefined) col.description = fieldDesc;
344
+
278
345
  return col;
279
346
  }
280
347
 
@@ -299,25 +366,6 @@ function subtypeToSqlType(subType: string): SqlType {
299
366
  }
300
367
  }
301
368
 
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
369
  function toSnake(s: string): string {
322
370
  return s
323
371
  .replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2")
package/src/index.ts CHANGED
@@ -13,9 +13,10 @@ export { introspect, introspectPostgres, introspectSqlite } from "./introspect/i
13
13
  export { diff } from "./diff/index.js";
14
14
  export { emit } from "./emit/index.js";
15
15
  export { writeMigration } from "./write-migration.js";
16
+ export { writeMigrationD1 } from "./write-migration-d1.js";
16
17
 
17
18
  // Errors
18
- export { BlockedChangesError } from "./errors.js";
19
+ export { BlockedChangesError, SetNullNotNullableError } from "./errors.js";
19
20
 
20
21
  // SqlType helpers (rarely needed but useful for advanced consumers)
21
22
  export { isWidening, sqlTypeEquals } from "./sql-type.js";
@@ -33,6 +34,7 @@ export type {
33
34
  export type { DiffArgs } from "./diff/index.js";
34
35
  export type { EmitOptions } from "./emit/index.js";
35
36
  export type { WriteMigrationOptions, WriteMigrationResult } from "./write-migration.js";
37
+ export type { WriteMigrationD1Options, WriteMigrationD1Result } from "./write-migration-d1.js";
36
38
 
37
39
  // View diff + dialect emitters
38
40
  export { classifyViewDiff } from "./view-diff.js";
@@ -40,6 +42,13 @@ export type { ViewShape, ViewDiffClass, ViewMigrationOpts } from "./view-diff.js
40
42
  export { emitPostgresViewMigration } from "./view-ddl-postgres.js";
41
43
  export { emitSqliteViewMigration } from "./view-ddl-sqlite.js";
42
44
 
45
+ // D1 dialect emitter + safety pass.
46
+ // renderD1 is exported directly (unlike renderSqlite/renderPostgres) so
47
+ // consumers writing raw wrangler batch scripts can apply the safety pass
48
+ // independently without going through emit().
49
+ export { renderD1 } from "./emit/d1.js";
50
+ export { applyD1SafetyPass, D1UnsupportedStatementError } from "./emit/d1-safety-pass.js";
51
+
43
52
  // View migrations orchestrator
44
53
  export {
45
54
  computeViewMigrations,
@@ -47,3 +56,15 @@ export {
47
56
  type ViewMigrationsOpts,
48
57
  type ViewMigrationsResult,
49
58
  } from "./source-aware-diff.js";
59
+
60
+ // D1 introspection
61
+ export { introspectD1, type D1Runner, type IntrospectD1Options } from "./introspect/d1.js";
62
+
63
+ // Wrangler config helpers
64
+ export {
65
+ findWranglerConfig,
66
+ parseWranglerConfig,
67
+ resolveD1Binding,
68
+ type D1Binding,
69
+ type WranglerConfig,
70
+ } from "./wrangler-config.js";