@metaobjectsdev/migrate-ts 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -0
- package/dist/emit/d1-safety-pass.d.ts +15 -0
- package/dist/emit/d1-safety-pass.d.ts.map +1 -0
- package/dist/emit/d1-safety-pass.js +80 -0
- package/dist/emit/d1-safety-pass.js.map +1 -0
- package/dist/emit/d1.d.ts +3 -0
- package/dist/emit/d1.d.ts.map +1 -0
- package/dist/emit/d1.js +11 -0
- package/dist/emit/d1.js.map +1 -0
- package/dist/emit/index.d.ts.map +1 -1
- package/dist/emit/index.js +2 -0
- package/dist/emit/index.js.map +1 -1
- package/dist/emit/postgres.js +28 -3
- package/dist/emit/postgres.js.map +1 -1
- package/dist/emit/sqlite.d.ts +1 -1
- package/dist/emit/sqlite.d.ts.map +1 -1
- package/dist/emit/sqlite.js.map +1 -1
- package/dist/errors.d.ts +17 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +28 -0
- package/dist/errors.js.map +1 -1
- package/dist/expected-schema.d.ts +7 -7
- package/dist/expected-schema.d.ts.map +1 -1
- package/dist/expected-schema.js +78 -35
- package/dist/expected-schema.js.map +1 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -1
- package/dist/index.js.map +1 -1
- package/dist/introspect/d1.d.ts +21 -0
- package/dist/introspect/d1.d.ts.map +1 -0
- package/dist/introspect/d1.js +149 -0
- package/dist/introspect/d1.js.map +1 -0
- package/dist/introspect/index.d.ts.map +1 -1
- package/dist/introspect/index.js +1 -0
- package/dist/introspect/index.js.map +1 -1
- package/dist/introspect/sqlite-shared.d.ts +14 -0
- package/dist/introspect/sqlite-shared.d.ts.map +1 -0
- package/dist/introspect/sqlite-shared.js +74 -0
- package/dist/introspect/sqlite-shared.js.map +1 -0
- package/dist/introspect/sqlite.js +1 -73
- package/dist/introspect/sqlite.js.map +1 -1
- package/dist/referential-actions.d.ts +49 -0
- package/dist/referential-actions.d.ts.map +1 -0
- package/dist/referential-actions.js +110 -0
- package/dist/referential-actions.js.map +1 -0
- package/dist/types.d.ts +15 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/wrangler-config.d.ts +25 -0
- package/dist/wrangler-config.d.ts.map +1 -0
- package/dist/wrangler-config.js +91 -0
- package/dist/wrangler-config.js.map +1 -0
- package/dist/write-migration-d1.d.ts +17 -0
- package/dist/write-migration-d1.d.ts.map +1 -0
- package/dist/write-migration-d1.js +50 -0
- package/dist/write-migration-d1.js.map +1 -0
- package/package.json +28 -27
- package/src/emit/d1-safety-pass.ts +94 -0
- package/src/emit/d1.ts +16 -0
- package/src/emit/index.ts +2 -0
- package/src/emit/postgres.ts +35 -3
- package/src/emit/sqlite.ts +1 -1
- package/src/errors.ts +33 -0
- package/src/expected-schema.ts +100 -52
- package/src/index.ts +22 -1
- package/src/introspect/d1.ts +185 -0
- package/src/introspect/index.ts +1 -0
- package/src/introspect/sqlite-shared.ts +77 -0
- package/src/introspect/sqlite.ts +2 -69
- package/src/referential-actions.ts +134 -0
- package/src/types.ts +15 -1
- package/src/wrangler-config.ts +103 -0
- 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
|
}
|
package/src/emit/postgres.ts
CHANGED
|
@@ -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":
|
|
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
|
-
|
|
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
|
|
167
|
+
return `'${pgEscape(d.value)}'`;
|
|
136
168
|
}
|
|
137
169
|
|
|
138
170
|
function renderCreateIndex(table: string, schema: string | undefined, ix: IndexDescriptor): string {
|
package/src/emit/sqlite.ts
CHANGED
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",
|
package/src/expected-schema.ts
CHANGED
|
@@ -1,14 +1,9 @@
|
|
|
1
|
-
import type { MetaData, MetaObject,
|
|
1
|
+
import type { MetaData, MetaObject, MetaRoot } from "@metaobjectsdev/metadata";
|
|
2
2
|
import {
|
|
3
3
|
TYPE_OBJECT,
|
|
4
|
-
|
|
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
|
|
40
|
-
* boolean → integer{64} and
|
|
41
|
-
* has no native boolean/timestamp
|
|
42
|
-
* `integer(..., {mode:"boolean"})` / `text("ts")`
|
|
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?:
|
|
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 (
|
|
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
|
|
63
|
-
(c) => c
|
|
73
|
+
const hasReadOnlySource = child.ownChildren().some(
|
|
74
|
+
(c) => c instanceof MetaSource && c.isReadOnly(),
|
|
64
75
|
);
|
|
65
|
-
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
230
|
-
|
|
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
|
-
|
|
253
|
-
|
|
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 && !
|
|
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";
|