@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.
Files changed (47) hide show
  1. package/agents/examples/apex-landing.md +1 -1
  2. package/agents/examples/apex-surface-auth.md +35 -35
  3. package/agents/examples/compliance-profiles-demo.md +4 -4
  4. package/agents/examples/crypto-shredding-hr.md +1 -1
  5. package/agents/examples/designer-demo.md +6 -6
  6. package/agents/examples/e2e-generator.md +18 -18
  7. package/agents/examples/encrypted-tenant-config.md +31 -31
  8. package/agents/examples/files-post-processing.md +1 -1
  9. package/agents/examples/inbound-mail-triage.md +1 -1
  10. package/agents/examples/lane-routing.md +5 -5
  11. package/agents/examples/legal-pages.md +36 -36
  12. package/agents/examples/record-detail-layout.md +41 -8
  13. package/agents/examples/seed-migration.md +44 -44
  14. package/agents/examples/user-data-rights.md +21 -21
  15. package/agents/examples/user-profile.md +5 -5
  16. package/agents/examples/wizard-form.md +1 -1
  17. package/agents/features.md +3 -3
  18. package/dist/db/entity-column-meta.d.ts +3 -0
  19. package/dist/db/entity-column-meta.js +113 -0
  20. package/dist/db/entity-table-indexes.d.ts +7 -0
  21. package/dist/db/entity-table-indexes.js +124 -0
  22. package/dist/db/entity-table-meta.js +1 -153
  23. package/dist/db/migrate-change-render.d.ts +40 -0
  24. package/dist/db/migrate-change-render.js +89 -0
  25. package/dist/db/migrate-generator.d.ts +5 -37
  26. package/dist/db/migrate-generator.js +99 -171
  27. package/dist/db/row-level-security-boot-check.js +4 -4
  28. package/dist/db/table-builder.d.ts +1 -1
  29. package/dist/db/table-builder.js +42 -133
  30. package/dist/engine/boot-validator/query-refs.js +22 -9
  31. package/dist/engine/boot-validator/screens-actions.js +11 -8
  32. package/dist/engine/boot-validator/screens-kind-projection-detail.js +8 -0
  33. package/dist/engine/build-app-schema.js +5 -0
  34. package/dist/engine/extensions/user-data.js +0 -2
  35. package/dist/engine/project-query-live-entities.d.ts +2 -0
  36. package/dist/engine/project-query-live-entities.js +11 -0
  37. package/dist/engine/strip-disabled-features-from-app-schema.js +1 -1
  38. package/dist/env/index.d.ts +1 -0
  39. package/dist/env/index.js +1 -0
  40. package/dist/env/required-env.d.ts +2 -0
  41. package/dist/env/required-env.js +9 -0
  42. package/dist/i18n/required-surface-keys.js +4 -0
  43. package/dist/migrations/kumiko-drift.js +4 -4
  44. package/dist/ui-types/app-schema.d.ts +1 -0
  45. package/dist/ui-types/index.d.ts +1 -1
  46. package/package.json +3 -3
  47. 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
- export declare function renderMigrationSql(diff: SchemaDiff, options: {
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
- }): string;
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
- // --- SQL-Render ---------------------------------------------------------
260
- function renderColumnInline(col) {
261
- const parts = [quoteIdent(col.name), col.pgType];
262
- if (col.defaultSql !== undefined)
263
- parts.push(`DEFAULT ${col.defaultSql}`);
264
- if (col.notNull)
265
- parts.push("NOT NULL");
266
- return parts.join(" ");
267
- }
268
- function renderAddColumn(tableName, col) {
269
- return `ALTER TABLE ${quoteIdent(tableName)} ADD COLUMN ${renderColumnInline(col)};`;
270
- }
271
- function renderColumnChange(tableName, change) {
272
- const out = [];
273
- const col = quoteIdent(change.name);
274
- const tbl = quoteIdent(tableName);
275
- if (change.nullabilityChanged) {
276
- const op = change.nullabilityChanged.to ? "SET NOT NULL" : "DROP NOT NULL";
277
- out.push(`ALTER TABLE ${tbl} ALTER COLUMN ${col} ${op};`);
278
- }
279
- if (change.defaultChanged) {
280
- if (change.defaultChanged.to !== undefined) {
281
- out.push(`ALTER TABLE ${tbl} ALTER COLUMN ${col} SET DEFAULT ${change.defaultChanged.to};`);
282
- }
283
- else {
284
- out.push(`ALTER TABLE ${tbl} ALTER COLUMN ${col} DROP DEFAULT;`);
285
- }
286
- }
287
- if (change.typeChanged) {
288
- const { from, to } = change.typeChanged;
289
- // timestamptz → date: PG's implicit ::date cast (what a bare ALTER ...
290
- // TYPE without USING falls back to) reads the session TimeZone, so the
291
- // same row migrates to a different calendar day depending on who/where
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 parts.join("; ");
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 lines = [];
352
- const seq = options.sequenceNumber.toString().padStart(4, "0");
353
- lines.push(`-- Migration ${seq}_${options.name}`);
354
- lines.push(`-- Generated: ${new Date().toISOString()}`);
355
- lines.push("-- ");
356
- lines.push("-- This file is generated by `kumiko-schema generate`. You may");
357
- lines.push("-- hand-edit it before committing — add partial-indexes, BRIN-");
358
- lines.push("-- variants, performance-tuning. After commit + apply, NEVER edit");
359
- lines.push("-- this file again (production-DBs already ran it). Write a new");
360
- lines.push("-- migration instead.");
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");