@cosmicdrift/kumiko-framework 0.391.0 → 0.392.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/agents/examples/apex-landing.md +1 -1
- package/agents/examples/apex-surface-auth.md +35 -35
- package/agents/examples/compliance-profiles-demo.md +4 -4
- package/agents/examples/crypto-shredding-hr.md +1 -1
- package/agents/examples/designer-demo.md +6 -6
- package/agents/examples/e2e-generator.md +18 -18
- package/agents/examples/encrypted-tenant-config.md +31 -31
- package/agents/examples/files-post-processing.md +1 -1
- package/agents/examples/inbound-mail-triage.md +1 -1
- package/agents/examples/lane-routing.md +5 -5
- package/agents/examples/legal-pages.md +36 -36
- package/agents/examples/record-detail-layout.md +41 -8
- package/agents/examples/seed-migration.md +44 -44
- package/agents/examples/user-data-rights.md +21 -21
- package/agents/examples/user-profile.md +5 -5
- package/agents/examples/wizard-form.md +1 -1
- package/agents/features.md +3 -3
- package/dist/db/entity-column-meta.d.ts +3 -0
- package/dist/db/entity-column-meta.js +113 -0
- package/dist/db/entity-table-indexes.d.ts +7 -0
- package/dist/db/entity-table-indexes.js +124 -0
- package/dist/db/entity-table-meta.js +1 -153
- package/dist/db/migrate-change-render.d.ts +40 -0
- package/dist/db/migrate-change-render.js +89 -0
- package/dist/db/migrate-generator.d.ts +5 -37
- package/dist/db/migrate-generator.js +99 -171
- package/dist/db/row-level-security-boot-check.js +4 -4
- package/dist/db/table-builder.d.ts +1 -1
- package/dist/db/table-builder.js +42 -133
- package/dist/engine/boot-validator/query-refs.js +22 -9
- package/dist/engine/boot-validator/screens-actions.js +11 -8
- package/dist/engine/boot-validator/screens-kind-projection-detail.js +8 -0
- package/dist/engine/build-app-schema.js +5 -0
- package/dist/engine/extensions/user-data.js +0 -2
- package/dist/engine/project-query-live-entities.d.ts +2 -0
- package/dist/engine/project-query-live-entities.js +11 -0
- package/dist/engine/strip-disabled-features-from-app-schema.js +1 -1
- package/dist/env/index.d.ts +1 -0
- package/dist/env/index.js +1 -0
- package/dist/env/required-env.d.ts +2 -0
- package/dist/env/required-env.js +9 -0
- package/dist/i18n/required-surface-keys.js +4 -0
- package/dist/migrations/kumiko-drift.js +4 -4
- package/dist/ui-types/app-schema.d.ts +1 -0
- package/dist/ui-types/index.d.ts +1 -1
- package/package.json +3 -3
- package/src/changes.json +42 -0
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
// `store_` table name — `read_` is reserved for managed projections (#1208/#1220).
|
|
18
18
|
import { collectPiiEncryptedFields, isRecordKeyedField } from "../crypto/index.js";
|
|
19
19
|
import { isSqlExpression } from "./dialect.js";
|
|
20
|
+
import { fieldToColumnMeta } from "./entity-column-meta.js";
|
|
20
21
|
import { READ_MODEL_PREFIX, SOFT_DELETE_LIVE_ROW_PREDICATE, toSnakeCase, toTableName, } from "./table-builder.js";
|
|
21
22
|
import { brandWritable } from "./table-introspection.js";
|
|
22
23
|
// Standard base columns for event-sourced read-model tables. Mirrors
|
|
@@ -45,159 +46,6 @@ function fullBaseColumns(idType, softDelete) {
|
|
|
45
46
|
}
|
|
46
47
|
return cols;
|
|
47
48
|
}
|
|
48
|
-
function quoteSql(literal) {
|
|
49
|
-
return `'${literal.replace(/'/g, "''")}'`;
|
|
50
|
-
}
|
|
51
|
-
function fieldDefaultLiteral(field) {
|
|
52
|
-
if (!("default" in field) || field.default === undefined)
|
|
53
|
-
return undefined;
|
|
54
|
-
const v = field.default;
|
|
55
|
-
if (typeof v === "string")
|
|
56
|
-
return quoteSql(v);
|
|
57
|
-
if (typeof v === "number")
|
|
58
|
-
return String(v);
|
|
59
|
-
if (typeof v === "boolean")
|
|
60
|
-
return v ? "true" : "false";
|
|
61
|
-
return undefined;
|
|
62
|
-
}
|
|
63
|
-
// Mirrors `fieldToColumns()` from table-builder.ts (drizzle variant).
|
|
64
|
-
// Lock-step: every field type must produce the same PG columns as today.
|
|
65
|
-
function fieldToColumnMeta(name, field, entity) {
|
|
66
|
-
const snake = toSnakeCase(name);
|
|
67
|
-
switch (field.type) {
|
|
68
|
-
case "text":
|
|
69
|
-
case "longText": {
|
|
70
|
-
const def = fieldDefaultLiteral(field);
|
|
71
|
-
const cols = [
|
|
72
|
-
{
|
|
73
|
-
name: snake,
|
|
74
|
-
pgType: "text",
|
|
75
|
-
notNull: field.required === true,
|
|
76
|
-
...(def !== undefined && { defaultSql: def }),
|
|
77
|
-
},
|
|
78
|
-
];
|
|
79
|
-
// lookupable → HMAC blind-index counterpart. Nullable is mandatory:
|
|
80
|
-
// managedChangeRequiresRecreate would diff a NOT-NULL column without
|
|
81
|
-
// a default on existing tables as DROP+rebuild.
|
|
82
|
-
if (field.type === "text" && field.lookupable === true) {
|
|
83
|
-
cols.push({ name: `${snake}_bidx`, pgType: "text", notNull: false });
|
|
84
|
-
}
|
|
85
|
-
return cols;
|
|
86
|
-
}
|
|
87
|
-
case "boolean": {
|
|
88
|
-
const def = fieldDefaultLiteral(field);
|
|
89
|
-
const hasDefault = def !== undefined;
|
|
90
|
-
return [
|
|
91
|
-
{
|
|
92
|
-
name: snake,
|
|
93
|
-
pgType: "boolean",
|
|
94
|
-
notNull: hasDefault || field.required === true,
|
|
95
|
-
...(hasDefault && { defaultSql: def }),
|
|
96
|
-
},
|
|
97
|
-
];
|
|
98
|
-
}
|
|
99
|
-
case "select": {
|
|
100
|
-
const def = fieldDefaultLiteral(field);
|
|
101
|
-
return [
|
|
102
|
-
{
|
|
103
|
-
name: snake,
|
|
104
|
-
pgType: "text",
|
|
105
|
-
notNull: field.required === true,
|
|
106
|
-
...(def !== undefined && { defaultSql: def }),
|
|
107
|
-
},
|
|
108
|
-
];
|
|
109
|
-
}
|
|
110
|
-
case "multiSelect":
|
|
111
|
-
return [{ name: snake, pgType: "jsonb", notNull: true, defaultSql: "'[]'::jsonb" }];
|
|
112
|
-
case "number": {
|
|
113
|
-
const def = fieldDefaultLiteral(field);
|
|
114
|
-
return [
|
|
115
|
-
{
|
|
116
|
-
name: snake,
|
|
117
|
-
pgType: field.integer === true ? "integer" : "double precision",
|
|
118
|
-
notNull: field.required === true,
|
|
119
|
-
...(def !== undefined && { defaultSql: def }),
|
|
120
|
-
},
|
|
121
|
-
];
|
|
122
|
-
}
|
|
123
|
-
case "bigInt": {
|
|
124
|
-
const def = fieldDefaultLiteral(field);
|
|
125
|
-
return [
|
|
126
|
-
{
|
|
127
|
-
name: snake,
|
|
128
|
-
pgType: "bigint",
|
|
129
|
-
notNull: field.required === true,
|
|
130
|
-
bigintJsMode: "number",
|
|
131
|
-
...(def !== undefined && { defaultSql: def }),
|
|
132
|
-
},
|
|
133
|
-
];
|
|
134
|
-
}
|
|
135
|
-
case "decimal": {
|
|
136
|
-
const def = fieldDefaultLiteral(field);
|
|
137
|
-
return [
|
|
138
|
-
{
|
|
139
|
-
name: snake,
|
|
140
|
-
pgType: `numeric(${field.precision},${field.scale})`,
|
|
141
|
-
notNull: field.required === true,
|
|
142
|
-
...(def !== undefined && { defaultSql: def }),
|
|
143
|
-
},
|
|
144
|
-
];
|
|
145
|
-
}
|
|
146
|
-
case "reference":
|
|
147
|
-
if (field.multiple === true) {
|
|
148
|
-
return [{ name: snake, pgType: "jsonb", notNull: true, defaultSql: "'[]'::jsonb" }];
|
|
149
|
-
}
|
|
150
|
-
return [{ name: snake, pgType: "uuid", notNull: field.required === true }];
|
|
151
|
-
case "money": {
|
|
152
|
-
const cur = entity.defaultCurrency ?? "EUR";
|
|
153
|
-
return [
|
|
154
|
-
{ name: snake, pgType: "bigint", notNull: field.required === true, bigintJsMode: "bigint" },
|
|
155
|
-
{
|
|
156
|
-
name: `${snake}_currency`,
|
|
157
|
-
pgType: "text",
|
|
158
|
-
notNull: true,
|
|
159
|
-
defaultSql: quoteSql(cur),
|
|
160
|
-
},
|
|
161
|
-
];
|
|
162
|
-
}
|
|
163
|
-
case "embedded":
|
|
164
|
-
return [
|
|
165
|
-
{
|
|
166
|
-
name: snake,
|
|
167
|
-
pgType: "jsonb",
|
|
168
|
-
notNull: true,
|
|
169
|
-
defaultSql: field.multiple === true ? "'[]'::jsonb" : "'{}'::jsonb",
|
|
170
|
-
},
|
|
171
|
-
];
|
|
172
|
-
case "jsonb":
|
|
173
|
-
return [{ name: snake, pgType: "jsonb", notNull: true, defaultSql: "'{}'::jsonb" }];
|
|
174
|
-
case "date":
|
|
175
|
-
// Real PG `date` — Temporal.PlainDate, no time-of-day/timezone
|
|
176
|
-
// component. See plainDate() in dialect.ts.
|
|
177
|
-
return [{ name: snake, pgType: "date", notNull: field.required === true }];
|
|
178
|
-
case "timestamp":
|
|
179
|
-
return [{ name: snake, pgType: "timestamptz", notNull: field.required === true }];
|
|
180
|
-
case "tz":
|
|
181
|
-
return [{ name: snake, pgType: "text", notNull: field.required === true }];
|
|
182
|
-
case "locatedTimestamp":
|
|
183
|
-
return [
|
|
184
|
-
{
|
|
185
|
-
name: `${snake}_utc`,
|
|
186
|
-
pgType: "timestamptz",
|
|
187
|
-
notNull: field.required === true,
|
|
188
|
-
},
|
|
189
|
-
{ name: `${snake}_tz`, pgType: "text", notNull: field.required === true },
|
|
190
|
-
];
|
|
191
|
-
case "file":
|
|
192
|
-
case "image":
|
|
193
|
-
return [{ name: snake, pgType: "uuid", notNull: field.required === true }];
|
|
194
|
-
case "files":
|
|
195
|
-
case "images":
|
|
196
|
-
return [{ name: snake, pgType: "jsonb", notNull: true, defaultSql: "'[]'::jsonb" }];
|
|
197
|
-
default:
|
|
198
|
-
return [];
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
49
|
export function resolveTableName(entityName, entity, featureName) {
|
|
202
50
|
const baseName = entity.table ?? toTableName(entityName);
|
|
203
51
|
if (!featureName)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ColumnMeta } from "./entity-table-meta.ts";
|
|
2
|
+
export type ColumnChange = {
|
|
3
|
+
readonly name: string;
|
|
4
|
+
readonly nullabilityChanged?: {
|
|
5
|
+
readonly from: boolean;
|
|
6
|
+
readonly to: boolean;
|
|
7
|
+
};
|
|
8
|
+
readonly defaultChanged?: {
|
|
9
|
+
readonly from: string | undefined;
|
|
10
|
+
readonly to: string | undefined;
|
|
11
|
+
};
|
|
12
|
+
readonly typeChanged?: {
|
|
13
|
+
readonly from: string;
|
|
14
|
+
readonly to: string;
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
export type IndexChange = {
|
|
18
|
+
readonly name: string;
|
|
19
|
+
readonly columnsChanged?: {
|
|
20
|
+
readonly from: readonly string[];
|
|
21
|
+
readonly to: readonly string[];
|
|
22
|
+
};
|
|
23
|
+
readonly uniqueChanged?: {
|
|
24
|
+
readonly from: boolean;
|
|
25
|
+
readonly to: boolean;
|
|
26
|
+
};
|
|
27
|
+
readonly whereSqlChanged?: {
|
|
28
|
+
readonly from: string | undefined;
|
|
29
|
+
readonly to: string | undefined;
|
|
30
|
+
};
|
|
31
|
+
readonly needsManualWhereChanged?: {
|
|
32
|
+
readonly from: boolean;
|
|
33
|
+
readonly to: boolean;
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
export declare function renderAddColumn(tableName: string, col: ColumnMeta): string;
|
|
37
|
+
export declare function renderColumnChange(tableName: string, change: ColumnChange): readonly string[];
|
|
38
|
+
export declare function toSingleLine(text: string): string;
|
|
39
|
+
export declare function whereSqlEqual(a: string | undefined, b: string | undefined): boolean;
|
|
40
|
+
export declare function describeIndexChange(change: IndexChange): string;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Pure SQL fragments of a schema diff: column/index change shapes and their
|
|
2
|
+
// reviewer-facing rendering. Kept free of the diff logic so migrate-generator
|
|
3
|
+
// can import it without a cycle.
|
|
4
|
+
import { quoteIdent } from "./sql-identifier.js";
|
|
5
|
+
function renderColumnInline(col) {
|
|
6
|
+
const parts = [quoteIdent(col.name), col.pgType];
|
|
7
|
+
if (col.defaultSql !== undefined)
|
|
8
|
+
parts.push(`DEFAULT ${col.defaultSql}`);
|
|
9
|
+
if (col.notNull)
|
|
10
|
+
parts.push("NOT NULL");
|
|
11
|
+
return parts.join(" ");
|
|
12
|
+
}
|
|
13
|
+
export function renderAddColumn(tableName, col) {
|
|
14
|
+
return `ALTER TABLE ${quoteIdent(tableName)} ADD COLUMN ${renderColumnInline(col)};`;
|
|
15
|
+
}
|
|
16
|
+
export function renderColumnChange(tableName, change) {
|
|
17
|
+
const out = [];
|
|
18
|
+
const col = quoteIdent(change.name);
|
|
19
|
+
const tbl = quoteIdent(tableName);
|
|
20
|
+
if (change.nullabilityChanged) {
|
|
21
|
+
const op = change.nullabilityChanged.to ? "SET NOT NULL" : "DROP NOT NULL";
|
|
22
|
+
out.push(`ALTER TABLE ${tbl} ALTER COLUMN ${col} ${op};`);
|
|
23
|
+
}
|
|
24
|
+
if (change.defaultChanged) {
|
|
25
|
+
if (change.defaultChanged.to !== undefined) {
|
|
26
|
+
out.push(`ALTER TABLE ${tbl} ALTER COLUMN ${col} SET DEFAULT ${change.defaultChanged.to};`);
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
out.push(`ALTER TABLE ${tbl} ALTER COLUMN ${col} DROP DEFAULT;`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (change.typeChanged) {
|
|
33
|
+
const { from, to } = change.typeChanged;
|
|
34
|
+
// timestamptz → date: PG's implicit ::date cast (what a bare ALTER ...
|
|
35
|
+
// TYPE without USING falls back to) reads the session TimeZone, so the
|
|
36
|
+
// same row migrates to a different calendar day depending on who/where
|
|
37
|
+
// runs it. Anchor explicitly at UTC instead of trusting the session.
|
|
38
|
+
if ((from === "timestamptz" || from === "timestamptz(3)") && to === "date") {
|
|
39
|
+
out.push(`-- ${from} → date: explicit UTC anchor — a bare cast would use the session TimeZone (non-deterministic).`);
|
|
40
|
+
out.push(`ALTER TABLE ${tbl} ALTER COLUMN ${col} TYPE date USING (${col} AT TIME ZONE 'UTC')::date;`);
|
|
41
|
+
}
|
|
42
|
+
else if (from === "date" && (to === "timestamptz" || to === "timestamptz(3)")) {
|
|
43
|
+
// Same non-determinism, reversed: a bare `date → timestamptz` cast
|
|
44
|
+
// interprets the calendar date at session-TimeZone midnight. Anchor
|
|
45
|
+
// explicitly at UTC midnight instead.
|
|
46
|
+
out.push(`-- date → ${to}: explicit UTC anchor — a bare cast would use the session TimeZone (non-deterministic).`);
|
|
47
|
+
out.push(`ALTER TABLE ${tbl} ALTER COLUMN ${col} TYPE ${to} USING (${col}::timestamp AT TIME ZONE 'UTC');`);
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
// pg ALTER TYPE often needs a USING clause for non-implicitly-castable
|
|
51
|
+
// type changes. Emitted as a reviewer comment + raw cast — the app
|
|
52
|
+
// author has to confirm it's intended.
|
|
53
|
+
out.push(`-- WARN: column-type-change ${from} → ${to}. Review USING-clause if needed.`);
|
|
54
|
+
out.push(`ALTER TABLE ${tbl} ALTER COLUMN ${col} TYPE ${to};`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
// Collapses whitespace so a multi-line whereSql (e.g. a two-condition
|
|
60
|
+
// drizzle sql`…` predicate) can't inject a newline into this `--` comment
|
|
61
|
+
// line and escape into the executable body of the generated migration.
|
|
62
|
+
export function toSingleLine(text) {
|
|
63
|
+
return text.replace(/\s+/g, " ").trim();
|
|
64
|
+
}
|
|
65
|
+
export function whereSqlEqual(a, b) {
|
|
66
|
+
if (a === undefined || b === undefined)
|
|
67
|
+
return a === b;
|
|
68
|
+
return toSingleLine(a) === toSingleLine(b);
|
|
69
|
+
}
|
|
70
|
+
export function describeIndexChange(change) {
|
|
71
|
+
const parts = [];
|
|
72
|
+
if (change.columnsChanged) {
|
|
73
|
+
parts.push(`columns (${change.columnsChanged.from.join(", ")}) → (${change.columnsChanged.to.join(", ")})`);
|
|
74
|
+
}
|
|
75
|
+
if (change.uniqueChanged) {
|
|
76
|
+
parts.push(`unique ${change.uniqueChanged.from} → ${change.uniqueChanged.to}`);
|
|
77
|
+
}
|
|
78
|
+
if (change.whereSqlChanged) {
|
|
79
|
+
const from = change.whereSqlChanged.from !== undefined
|
|
80
|
+
? toSingleLine(change.whereSqlChanged.from)
|
|
81
|
+
: "<none>";
|
|
82
|
+
const to = change.whereSqlChanged.to !== undefined ? toSingleLine(change.whereSqlChanged.to) : "<none>";
|
|
83
|
+
parts.push(`where ${from} → ${to}`);
|
|
84
|
+
}
|
|
85
|
+
if (change.needsManualWhereChanged) {
|
|
86
|
+
parts.push(`needs-manual-where ${change.needsManualWhereChanged.from} → ${change.needsManualWhereChanged.to}`);
|
|
87
|
+
}
|
|
88
|
+
return parts.join("; ");
|
|
89
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ColumnMeta, EntityTableMeta, IndexMeta } from "./entity-table-meta.ts";
|
|
2
|
+
import { type ColumnChange, type IndexChange } from "./migrate-change-render.ts";
|
|
2
3
|
import { type PrimaryKeyChange } from "./migrate-primary-key.ts";
|
|
3
4
|
import { type RowLevelSecurityChange } from "./migrate-rls-diff.ts";
|
|
4
5
|
import { type RowLevelSecurityMode, type TableRowLevelSecurity } from "./render-rls-ddl.ts";
|
|
@@ -10,40 +11,7 @@ export type Snapshot = {
|
|
|
10
11
|
readonly tables: readonly EntityTableMeta[];
|
|
11
12
|
readonly rowLevelSecurity?: Readonly<Record<string, TableRowLevelSecurity>>;
|
|
12
13
|
};
|
|
13
|
-
export type ColumnChange
|
|
14
|
-
readonly name: string;
|
|
15
|
-
readonly nullabilityChanged?: {
|
|
16
|
-
readonly from: boolean;
|
|
17
|
-
readonly to: boolean;
|
|
18
|
-
};
|
|
19
|
-
readonly defaultChanged?: {
|
|
20
|
-
readonly from: string | undefined;
|
|
21
|
-
readonly to: string | undefined;
|
|
22
|
-
};
|
|
23
|
-
readonly typeChanged?: {
|
|
24
|
-
readonly from: string;
|
|
25
|
-
readonly to: string;
|
|
26
|
-
};
|
|
27
|
-
};
|
|
28
|
-
export type IndexChange = {
|
|
29
|
-
readonly name: string;
|
|
30
|
-
readonly columnsChanged?: {
|
|
31
|
-
readonly from: readonly string[];
|
|
32
|
-
readonly to: readonly string[];
|
|
33
|
-
};
|
|
34
|
-
readonly uniqueChanged?: {
|
|
35
|
-
readonly from: boolean;
|
|
36
|
-
readonly to: boolean;
|
|
37
|
-
};
|
|
38
|
-
readonly whereSqlChanged?: {
|
|
39
|
-
readonly from: string | undefined;
|
|
40
|
-
readonly to: string | undefined;
|
|
41
|
-
};
|
|
42
|
-
readonly needsManualWhereChanged?: {
|
|
43
|
-
readonly from: boolean;
|
|
44
|
-
readonly to: boolean;
|
|
45
|
-
};
|
|
46
|
-
};
|
|
14
|
+
export type { ColumnChange, IndexChange };
|
|
47
15
|
export type TableDiff = {
|
|
48
16
|
readonly tableName: string;
|
|
49
17
|
readonly newColumns: readonly ColumnMeta[];
|
|
@@ -73,12 +41,13 @@ export declare function hasNoSchemaChanges(input: {
|
|
|
73
41
|
readonly retiredDrops: readonly RetiredFrameworkTable[];
|
|
74
42
|
readonly rowLevelSecurityChanges: readonly RowLevelSecurityChange[];
|
|
75
43
|
}): boolean;
|
|
76
|
-
|
|
44
|
+
type MigrationSqlOptions = {
|
|
77
45
|
readonly name: string;
|
|
78
46
|
readonly sequenceNumber: number;
|
|
79
47
|
readonly retiredDrops?: readonly RetiredFrameworkTable[];
|
|
80
48
|
readonly rowLevelSecurityChanges?: readonly RowLevelSecurityChange[];
|
|
81
|
-
}
|
|
49
|
+
};
|
|
50
|
+
export declare function renderMigrationSql(diff: SchemaDiff, options: MigrationSqlOptions): string;
|
|
82
51
|
export type GenerateMigrationInput = {
|
|
83
52
|
readonly metas: readonly EntityTableMeta[];
|
|
84
53
|
readonly prevSnapshot: Snapshot | null;
|
|
@@ -101,4 +70,3 @@ export declare const MIGRATION_NAME_RE: RegExp;
|
|
|
101
70
|
export declare function assertValidMigrationName(name: string): void;
|
|
102
71
|
export declare function assertUniqueTableNames(metas: readonly EntityTableMeta[]): void;
|
|
103
72
|
export declare function generateMigration(input: GenerateMigrationInput): GenerateMigrationOutput;
|
|
104
|
-
export {};
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
17
17
|
import { errorCodeOf, isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
18
18
|
import { compareByCodepoint } from "../utils/compare.js";
|
|
19
|
+
import { describeIndexChange, renderAddColumn, renderColumnChange, whereSqlEqual, } from "./migrate-change-render.js";
|
|
19
20
|
import { diffPrimaryKey, renderPrimaryKeyChange, } from "./migrate-primary-key.js";
|
|
20
21
|
import { diffRowLevelSecurity, renderRowLevelSecurityChanges, } from "./migrate-rls-diff.js";
|
|
21
22
|
import { renderIndex, renderTableDdl } from "./render-ddl.js";
|
|
@@ -256,91 +257,95 @@ export function hasNoSchemaChanges(input) {
|
|
|
256
257
|
input.retiredDrops.length === 0 &&
|
|
257
258
|
input.rowLevelSecurityChanges.length === 0);
|
|
258
259
|
}
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
// runs it. Anchor explicitly at UTC instead of trusting the session.
|
|
293
|
-
if ((from === "timestamptz" || from === "timestamptz(3)") && to === "date") {
|
|
294
|
-
out.push(`-- ${from} → date: explicit UTC anchor — a bare cast would use the session TimeZone (non-deterministic).`);
|
|
295
|
-
out.push(`ALTER TABLE ${tbl} ALTER COLUMN ${col} TYPE date USING (${col} AT TIME ZONE 'UTC')::date;`);
|
|
296
|
-
}
|
|
297
|
-
else if (from === "date" && (to === "timestamptz" || to === "timestamptz(3)")) {
|
|
298
|
-
// Same non-determinism, reversed: a bare `date → timestamptz` cast
|
|
299
|
-
// interprets the calendar date at session-TimeZone midnight. Anchor
|
|
300
|
-
// explicitly at UTC midnight instead.
|
|
301
|
-
out.push(`-- date → ${to}: explicit UTC anchor — a bare cast would use the session TimeZone (non-deterministic).`);
|
|
302
|
-
out.push(`ALTER TABLE ${tbl} ALTER COLUMN ${col} TYPE ${to} USING (${col}::timestamp AT TIME ZONE 'UTC');`);
|
|
303
|
-
}
|
|
304
|
-
else {
|
|
305
|
-
// pg ALTER TYPE often needs a USING clause for non-implicitly-castable
|
|
306
|
-
// type changes. Emitted as a reviewer comment + raw cast — the app
|
|
307
|
-
// author has to confirm it's intended.
|
|
308
|
-
out.push(`-- WARN: column-type-change ${from} → ${to}. Review USING-clause if needed.`);
|
|
309
|
-
out.push(`ALTER TABLE ${tbl} ALTER COLUMN ${col} TYPE ${to};`);
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
return out;
|
|
313
|
-
}
|
|
314
|
-
// Collapses whitespace so a multi-line whereSql (e.g. a two-condition
|
|
315
|
-
// drizzle sql`…` predicate) can't inject a newline into this `--` comment
|
|
316
|
-
// line and escape into the executable body of the generated migration.
|
|
317
|
-
function toSingleLine(text) {
|
|
318
|
-
return text.replace(/\s+/g, " ").trim();
|
|
319
|
-
}
|
|
320
|
-
function whereSqlEqual(a, b) {
|
|
321
|
-
if (a === undefined || b === undefined)
|
|
322
|
-
return a === b;
|
|
323
|
-
return toSingleLine(a) === toSingleLine(b);
|
|
324
|
-
}
|
|
325
|
-
function describeIndexChange(change) {
|
|
326
|
-
const parts = [];
|
|
327
|
-
if (change.columnsChanged) {
|
|
328
|
-
parts.push(`columns (${change.columnsChanged.from.join(", ")}) → (${change.columnsChanged.to.join(", ")})`);
|
|
329
|
-
}
|
|
330
|
-
if (change.uniqueChanged) {
|
|
331
|
-
parts.push(`unique ${change.uniqueChanged.from} → ${change.uniqueChanged.to}`);
|
|
332
|
-
}
|
|
333
|
-
if (change.whereSqlChanged) {
|
|
334
|
-
const from = change.whereSqlChanged.from !== undefined
|
|
335
|
-
? toSingleLine(change.whereSqlChanged.from)
|
|
336
|
-
: "<none>";
|
|
337
|
-
const to = change.whereSqlChanged.to !== undefined ? toSingleLine(change.whereSqlChanged.to) : "<none>";
|
|
338
|
-
parts.push(`where ${from} → ${to}`);
|
|
339
|
-
}
|
|
340
|
-
if (change.needsManualWhereChanged) {
|
|
341
|
-
parts.push(`needs-manual-where ${change.needsManualWhereChanged.from} → ${change.needsManualWhereChanged.to}`);
|
|
260
|
+
function renderMigrationHeader(options) {
|
|
261
|
+
const seq = options.sequenceNumber.toString().padStart(4, "0");
|
|
262
|
+
return [
|
|
263
|
+
`-- Migration ${seq}_${options.name}`,
|
|
264
|
+
`-- Generated: ${new Date().toISOString()}`,
|
|
265
|
+
"-- ",
|
|
266
|
+
"-- This file is generated by `kumiko-schema generate`. You may",
|
|
267
|
+
"-- hand-edit it before committing — add partial-indexes, BRIN-",
|
|
268
|
+
"-- variants, performance-tuning. After commit + apply, NEVER edit",
|
|
269
|
+
"-- this file again (production-DBs already ran it). Write a new",
|
|
270
|
+
"-- migration instead.",
|
|
271
|
+
"",
|
|
272
|
+
];
|
|
273
|
+
}
|
|
274
|
+
function renderNewTables(newTables) {
|
|
275
|
+
if (newTables.length === 0)
|
|
276
|
+
return [];
|
|
277
|
+
return ["-- === New tables ===", ...newTables.flatMap((tbl) => [...renderTableDdl(tbl), ""])];
|
|
278
|
+
}
|
|
279
|
+
function renderRecreatedTable(td) {
|
|
280
|
+
return [
|
|
281
|
+
"-- managed projection — recreated + rebuilt from events (see .rebuild.json)",
|
|
282
|
+
`-- WARN: destructive change (${describeRecreateReasons(td).join("; ")}) forces DROP+CREATE + full event replay.`,
|
|
283
|
+
"-- Consider an Expand/Contract split across two releases to avoid the rebuild — see docs/guides/expand-contract-managed-projections.md.",
|
|
284
|
+
`DROP TABLE IF EXISTS ${quoteIdent(td.tableName)};`,
|
|
285
|
+
...renderTableDdl(td.nextMeta),
|
|
286
|
+
"",
|
|
287
|
+
];
|
|
288
|
+
}
|
|
289
|
+
function renderChangedIndex(td, idxChange) {
|
|
290
|
+
const nextIdxMeta = td.nextMeta.indexes.find((i) => i.name === idxChange.name);
|
|
291
|
+
if (!nextIdxMeta) {
|
|
292
|
+
throw new Error(`Index "${idxChange.name}" changed but is missing from the next table meta — generator bug in diffOneTable.`);
|
|
342
293
|
}
|
|
343
|
-
return
|
|
294
|
+
return [
|
|
295
|
+
`-- index ${quoteIdent(idxChange.name)} changed (${describeIndexChange(idxChange)}) — recreated`,
|
|
296
|
+
// needsManualWhere renders CREATE as a commented WARN (renderIndex),
|
|
297
|
+
// since the generator can't express the new WHERE — the DROP has to
|
|
298
|
+
// stay commented too, or the live index gets dropped with no
|
|
299
|
+
// executable replacement.
|
|
300
|
+
nextIdxMeta.needsManualWhere === true
|
|
301
|
+
? `-- (review) DROP INDEX IF EXISTS ${quoteIdent(idxChange.name)};`
|
|
302
|
+
: `DROP INDEX IF EXISTS ${quoteIdent(idxChange.name)};`,
|
|
303
|
+
renderIndex(td.tableName, nextIdxMeta),
|
|
304
|
+
];
|
|
305
|
+
}
|
|
306
|
+
function renderInPlaceTableChanges(td) {
|
|
307
|
+
return [
|
|
308
|
+
...td.newColumns.map((col) => renderAddColumn(td.tableName, col)),
|
|
309
|
+
...td.changedColumns.flatMap((ch) => renderColumnChange(td.tableName, ch)),
|
|
310
|
+
...renderPrimaryKeyChange(td.tableName, td.primaryKeyChanged),
|
|
311
|
+
...td.newIndexes.map((idx) => renderIndex(td.tableName, idx)),
|
|
312
|
+
...td.changedIndexes.flatMap((idxChange) => renderChangedIndex(td, idxChange)),
|
|
313
|
+
...td.droppedIndexes.map((name) => `-- (review) DROP INDEX IF EXISTS ${quoteIdent(name)};`),
|
|
314
|
+
...td.droppedColumns.map((colName) => `-- DESTRUCTIVE: ALTER TABLE ${quoteIdent(td.tableName)} DROP COLUMN ${quoteIdent(colName)}; -- uncomment + ensure backup`),
|
|
315
|
+
"",
|
|
316
|
+
];
|
|
317
|
+
}
|
|
318
|
+
function renderChangedTables(changedTables) {
|
|
319
|
+
if (changedTables.length === 0)
|
|
320
|
+
return [];
|
|
321
|
+
return [
|
|
322
|
+
"-- === Changed tables ===",
|
|
323
|
+
...changedTables.flatMap((td) => [
|
|
324
|
+
`-- ${td.tableName}`,
|
|
325
|
+
...(isRecreatedManagedTable(td) ? renderRecreatedTable(td) : renderInPlaceTableChanges(td)),
|
|
326
|
+
]),
|
|
327
|
+
];
|
|
328
|
+
}
|
|
329
|
+
function renderDroppedTables(droppedTables) {
|
|
330
|
+
if (droppedTables.length === 0)
|
|
331
|
+
return [];
|
|
332
|
+
return [
|
|
333
|
+
"-- === Dropped tables ===",
|
|
334
|
+
...droppedTables.map((name) => `-- DESTRUCTIVE: DROP TABLE IF EXISTS ${quoteIdent(name)}; -- uncomment + ensure backup + run during maintenance window`),
|
|
335
|
+
"",
|
|
336
|
+
];
|
|
337
|
+
}
|
|
338
|
+
function renderRetiredDrops(retiredDrops) {
|
|
339
|
+
if (retiredDrops.length === 0)
|
|
340
|
+
return [];
|
|
341
|
+
return [
|
|
342
|
+
"-- === Retired framework tables ===",
|
|
343
|
+
...retiredDrops.flatMap((retired) => [
|
|
344
|
+
`-- ${retired.tableName}: retired since framework ${retired.sinceVersion} — ${retired.explanation}`,
|
|
345
|
+
`DROP TABLE IF EXISTS ${quoteIdent(retired.tableName)};`,
|
|
346
|
+
]),
|
|
347
|
+
"",
|
|
348
|
+
];
|
|
344
349
|
}
|
|
345
350
|
// Render the diff as a SQL-file content with header-comment + grouped
|
|
346
351
|
// statements. Destructive operations (DROP TABLE/COLUMN, DROP INDEX where
|
|
@@ -348,93 +353,16 @@ function describeIndexChange(change) {
|
|
|
348
353
|
// for explicit reviewer-action.
|
|
349
354
|
export function renderMigrationSql(diff, options) {
|
|
350
355
|
const retiredDrops = options.retiredDrops ?? [];
|
|
351
|
-
const
|
|
352
|
-
const
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
lines.push("");
|
|
362
|
-
if (diff.newTables.length > 0) {
|
|
363
|
-
lines.push("-- === New tables ===");
|
|
364
|
-
for (const tbl of diff.newTables) {
|
|
365
|
-
lines.push(...renderTableDdl(tbl));
|
|
366
|
-
lines.push("");
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
if (diff.changedTables.length > 0) {
|
|
370
|
-
lines.push("-- === Changed tables ===");
|
|
371
|
-
for (const td of diff.changedTables) {
|
|
372
|
-
lines.push(`-- ${td.tableName}`);
|
|
373
|
-
if (isRecreatedManagedTable(td)) {
|
|
374
|
-
lines.push("-- managed projection — recreated + rebuilt from events (see .rebuild.json)");
|
|
375
|
-
lines.push(`-- WARN: destructive change (${describeRecreateReasons(td).join("; ")}) forces DROP+CREATE + full event replay.`);
|
|
376
|
-
lines.push("-- Consider an Expand/Contract split across two releases to avoid the rebuild — see docs/guides/expand-contract-managed-projections.md.");
|
|
377
|
-
lines.push(`DROP TABLE IF EXISTS ${quoteIdent(td.tableName)};`);
|
|
378
|
-
lines.push(...renderTableDdl(td.nextMeta));
|
|
379
|
-
lines.push("");
|
|
380
|
-
continue;
|
|
381
|
-
}
|
|
382
|
-
for (const col of td.newColumns) {
|
|
383
|
-
lines.push(renderAddColumn(td.tableName, col));
|
|
384
|
-
}
|
|
385
|
-
for (const ch of td.changedColumns) {
|
|
386
|
-
for (const stmt of renderColumnChange(td.tableName, ch))
|
|
387
|
-
lines.push(stmt);
|
|
388
|
-
}
|
|
389
|
-
lines.push(...renderPrimaryKeyChange(td.tableName, td.primaryKeyChanged));
|
|
390
|
-
for (const idx of td.newIndexes) {
|
|
391
|
-
lines.push(renderIndex(td.tableName, idx));
|
|
392
|
-
}
|
|
393
|
-
for (const idxChange of td.changedIndexes) {
|
|
394
|
-
const nextIdxMeta = td.nextMeta.indexes.find((i) => i.name === idxChange.name);
|
|
395
|
-
if (!nextIdxMeta) {
|
|
396
|
-
throw new Error(`Index "${idxChange.name}" changed but is missing from the next table meta — generator bug in diffOneTable.`);
|
|
397
|
-
}
|
|
398
|
-
lines.push(`-- index ${quoteIdent(idxChange.name)} changed (${describeIndexChange(idxChange)}) — recreated`);
|
|
399
|
-
// needsManualWhere renders CREATE as a commented WARN (renderIndex),
|
|
400
|
-
// since the generator can't express the new WHERE — the DROP has to
|
|
401
|
-
// stay commented too, or the live index gets dropped with no
|
|
402
|
-
// executable replacement.
|
|
403
|
-
lines.push(nextIdxMeta.needsManualWhere === true
|
|
404
|
-
? `-- (review) DROP INDEX IF EXISTS ${quoteIdent(idxChange.name)};`
|
|
405
|
-
: `DROP INDEX IF EXISTS ${quoteIdent(idxChange.name)};`);
|
|
406
|
-
lines.push(renderIndex(td.tableName, nextIdxMeta));
|
|
407
|
-
}
|
|
408
|
-
for (const name of td.droppedIndexes) {
|
|
409
|
-
lines.push(`-- (review) DROP INDEX IF EXISTS ${quoteIdent(name)};`);
|
|
410
|
-
}
|
|
411
|
-
for (const colName of td.droppedColumns) {
|
|
412
|
-
lines.push(`-- DESTRUCTIVE: ALTER TABLE ${quoteIdent(td.tableName)} DROP COLUMN ${quoteIdent(colName)}; -- uncomment + ensure backup`);
|
|
413
|
-
}
|
|
414
|
-
lines.push("");
|
|
415
|
-
}
|
|
416
|
-
}
|
|
417
|
-
lines.push(...renderRowLevelSecurityChanges(options.rowLevelSecurityChanges));
|
|
418
|
-
if (diff.droppedTables.length > 0) {
|
|
419
|
-
lines.push("-- === Dropped tables ===");
|
|
420
|
-
for (const name of diff.droppedTables) {
|
|
421
|
-
lines.push(`-- DESTRUCTIVE: DROP TABLE IF EXISTS ${quoteIdent(name)}; -- uncomment + ensure backup + run during maintenance window`);
|
|
422
|
-
}
|
|
423
|
-
lines.push("");
|
|
424
|
-
}
|
|
425
|
-
if (retiredDrops.length > 0) {
|
|
426
|
-
lines.push("-- === Retired framework tables ===");
|
|
427
|
-
for (const retired of retiredDrops) {
|
|
428
|
-
lines.push(`-- ${retired.tableName}: retired since framework ${retired.sinceVersion} — ${retired.explanation}`);
|
|
429
|
-
lines.push(`DROP TABLE IF EXISTS ${quoteIdent(retired.tableName)};`);
|
|
430
|
-
}
|
|
431
|
-
lines.push("");
|
|
432
|
-
}
|
|
433
|
-
if (hasNoSchemaChanges({
|
|
434
|
-
diff,
|
|
435
|
-
retiredDrops,
|
|
436
|
-
rowLevelSecurityChanges: options.rowLevelSecurityChanges ?? [],
|
|
437
|
-
})) {
|
|
356
|
+
const rowLevelSecurityChanges = options.rowLevelSecurityChanges ?? [];
|
|
357
|
+
const lines = [
|
|
358
|
+
...renderMigrationHeader(options),
|
|
359
|
+
...renderNewTables(diff.newTables),
|
|
360
|
+
...renderChangedTables(diff.changedTables),
|
|
361
|
+
...renderRowLevelSecurityChanges(options.rowLevelSecurityChanges),
|
|
362
|
+
...renderDroppedTables(diff.droppedTables),
|
|
363
|
+
...renderRetiredDrops(retiredDrops),
|
|
364
|
+
];
|
|
365
|
+
if (hasNoSchemaChanges({ diff, retiredDrops, rowLevelSecurityChanges })) {
|
|
438
366
|
lines.push("-- No schema changes detected.");
|
|
439
367
|
}
|
|
440
368
|
return lines.join("\n");
|