@happyvertical/smrt-core 0.40.69 → 0.40.70

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 (144) hide show
  1. package/AGENTS.md +29 -4
  2. package/README.md +20 -1
  3. package/agents/query-bounds.md +45 -0
  4. package/agents/schema-paths.md +328 -0
  5. package/dist/browser.d.ts +1 -0
  6. package/dist/browser.d.ts.map +1 -1
  7. package/dist/browser.js +2 -1
  8. package/dist/class.d.ts.map +1 -1
  9. package/dist/class.js +28 -4
  10. package/dist/class.js.map +1 -1
  11. package/dist/collection.d.ts +101 -2
  12. package/dist/collection.d.ts.map +1 -1
  13. package/dist/collection.js +224 -43
  14. package/dist/collection.js.map +1 -1
  15. package/dist/database.d.ts +8 -0
  16. package/dist/database.d.ts.map +1 -1
  17. package/dist/database.js +16 -8
  18. package/dist/database.js.map +1 -1
  19. package/dist/db-errors.d.ts +105 -0
  20. package/dist/db-errors.d.ts.map +1 -0
  21. package/dist/db-errors.js +382 -0
  22. package/dist/db-errors.js.map +1 -0
  23. package/dist/decorators/index.d.ts +17 -1
  24. package/dist/decorators/index.d.ts.map +1 -1
  25. package/dist/decorators/index.js +65 -7
  26. package/dist/decorators/index.js.map +1 -1
  27. package/dist/dispatch/bus.d.ts.map +1 -1
  28. package/dist/dispatch/bus.js +4 -3
  29. package/dist/dispatch/bus.js.map +1 -1
  30. package/dist/errors.d.ts +30 -2
  31. package/dist/errors.d.ts.map +1 -1
  32. package/dist/errors.js +34 -2
  33. package/dist/errors.js.map +1 -1
  34. package/dist/generators/changes-route.d.ts.map +1 -1
  35. package/dist/generators/changes-route.js +6 -3
  36. package/dist/generators/changes-route.js.map +1 -1
  37. package/dist/generators/mcp-runtime-template.d.ts +8 -0
  38. package/dist/generators/mcp-runtime-template.d.ts.map +1 -1
  39. package/dist/generators/mcp-runtime-template.js +38 -4
  40. package/dist/generators/mcp-runtime-template.js.map +1 -1
  41. package/dist/generators/mcp.d.ts +16 -0
  42. package/dist/generators/mcp.d.ts.map +1 -1
  43. package/dist/generators/mcp.js +41 -3
  44. package/dist/generators/mcp.js.map +1 -1
  45. package/dist/generators/rest.d.ts +22 -0
  46. package/dist/generators/rest.d.ts.map +1 -1
  47. package/dist/generators/rest.js +34 -3
  48. package/dist/generators/rest.js.map +1 -1
  49. package/dist/index.d.ts +6 -1
  50. package/dist/index.d.ts.map +1 -1
  51. package/dist/index.js +8 -3
  52. package/dist/manifest/static-manifest.js +1 -1
  53. package/dist/manifest/static-manifest.js.map +1 -1
  54. package/dist/manifest/store.js +1 -1
  55. package/dist/manifest.json +1 -1
  56. package/dist/migrations/differ.d.ts +202 -8
  57. package/dist/migrations/differ.d.ts.map +1 -1
  58. package/dist/migrations/differ.js +604 -46
  59. package/dist/migrations/differ.js.map +1 -1
  60. package/dist/migrations/generator.d.ts +31 -4
  61. package/dist/migrations/generator.d.ts.map +1 -1
  62. package/dist/migrations/generator.js +49 -5
  63. package/dist/migrations/generator.js.map +1 -1
  64. package/dist/migrations/index.d.ts +3 -2
  65. package/dist/migrations/index.d.ts.map +1 -1
  66. package/dist/migrations/index.js +5 -3
  67. package/dist/migrations/orchestrate.js +35 -6
  68. package/dist/migrations/orchestrate.js.map +1 -1
  69. package/dist/migrations/sqlite-rebuild.d.ts +142 -0
  70. package/dist/migrations/sqlite-rebuild.d.ts.map +1 -0
  71. package/dist/migrations/sqlite-rebuild.js +514 -0
  72. package/dist/migrations/sqlite-rebuild.js.map +1 -0
  73. package/dist/migrations/tracker.d.ts +114 -1
  74. package/dist/migrations/tracker.d.ts.map +1 -1
  75. package/dist/migrations/tracker.js +331 -16
  76. package/dist/migrations/tracker.js.map +1 -1
  77. package/dist/migrations/types.d.ts +19 -4
  78. package/dist/migrations/types.d.ts.map +1 -1
  79. package/dist/migrations.js +5 -3
  80. package/dist/object.d.ts +45 -6
  81. package/dist/object.d.ts.map +1 -1
  82. package/dist/object.js +60 -13
  83. package/dist/object.js.map +1 -1
  84. package/dist/postgres-timeouts.d.ts +240 -0
  85. package/dist/postgres-timeouts.d.ts.map +1 -0
  86. package/dist/postgres-timeouts.js +204 -0
  87. package/dist/postgres-timeouts.js.map +1 -0
  88. package/dist/query-bounds.d.ts +97 -0
  89. package/dist/query-bounds.d.ts.map +1 -0
  90. package/dist/query-bounds.js +173 -0
  91. package/dist/query-bounds.js.map +1 -0
  92. package/dist/registry/types.d.ts +32 -1
  93. package/dist/registry/types.d.ts.map +1 -1
  94. package/dist/registry.js +1 -1
  95. package/dist/scanner/types.d.ts +5 -0
  96. package/dist/scanner/types.d.ts.map +1 -1
  97. package/dist/scanner/types.js.map +1 -1
  98. package/dist/schema/ddl/base-strategy.d.ts.map +1 -1
  99. package/dist/schema/ddl/base-strategy.js +2 -2
  100. package/dist/schema/ddl/base-strategy.js.map +1 -1
  101. package/dist/schema/generator.d.ts +70 -5
  102. package/dist/schema/generator.d.ts.map +1 -1
  103. package/dist/schema/generator.js +124 -6
  104. package/dist/schema/generator.js.map +1 -1
  105. package/dist/schema/index.d.ts +3 -0
  106. package/dist/schema/index.d.ts.map +1 -1
  107. package/dist/schema/index.js +4 -1
  108. package/dist/schema/live-parity.d.ts +90 -0
  109. package/dist/schema/live-parity.d.ts.map +1 -0
  110. package/dist/schema/live-parity.js +600 -0
  111. package/dist/schema/live-parity.js.map +1 -0
  112. package/dist/schema/manifest-schema.d.ts +121 -0
  113. package/dist/schema/manifest-schema.d.ts.map +1 -0
  114. package/dist/schema/manifest-schema.js +267 -0
  115. package/dist/schema/manifest-schema.js.map +1 -0
  116. package/dist/schema/schema-aggregator.d.ts +24 -10
  117. package/dist/schema/schema-aggregator.d.ts.map +1 -1
  118. package/dist/schema/schema-aggregator.js +35 -90
  119. package/dist/schema/schema-aggregator.js.map +1 -1
  120. package/dist/schema/system-table-shapes.d.ts +52 -0
  121. package/dist/schema/system-table-shapes.d.ts.map +1 -0
  122. package/dist/schema/system-table-shapes.js +174 -0
  123. package/dist/schema/system-table-shapes.js.map +1 -0
  124. package/dist/schema/types.d.ts +103 -4
  125. package/dist/schema/types.d.ts.map +1 -1
  126. package/dist/schema/utils.d.ts +1 -0
  127. package/dist/schema/utils.d.ts.map +1 -1
  128. package/dist/schema/utils.js +3 -1
  129. package/dist/schema/utils.js.map +1 -1
  130. package/dist/schema.js +4 -1
  131. package/dist/smrt-knowledge.json +18 -6
  132. package/dist/sync/apply.d.ts.map +1 -1
  133. package/dist/sync/apply.js +9 -16
  134. package/dist/sync/apply.js.map +1 -1
  135. package/dist/testing/database.d.ts.map +1 -1
  136. package/dist/testing/database.js +1 -0
  137. package/dist/testing/database.js.map +1 -1
  138. package/dist/vite-plugin/sveltekit-generator.d.ts.map +1 -1
  139. package/dist/vite-plugin/sveltekit-generator.js +71 -5
  140. package/dist/vite-plugin/sveltekit-generator.js.map +1 -1
  141. package/dist/vite-plugin/web-collections.d.ts.map +1 -1
  142. package/dist/vite-plugin/web-collections.js +6 -4
  143. package/dist/vite-plugin/web-collections.js.map +1 -1
  144. package/package.json +5 -5
@@ -1,6 +1,7 @@
1
1
  import { isJsonPathIndex, renderIndexTarget } from "../schema/index-utils.js";
2
2
  import { detectEngine, getDDLStrategy } from "../schema/ddl/index.js";
3
3
  import "../schema/utils.js";
4
+ import { planSqliteTableRebuilds, sqliteRebuildPlaceholderSql } from "./sqlite-rebuild.js";
4
5
  import { createLogger } from "@happyvertical/logger";
5
6
  //#region src/migrations/differ.ts
6
7
  /**
@@ -33,14 +34,56 @@ function isValidSQLDataType(type) {
33
34
  * Suffix patterns for indexes that the differ refuses to drop.
34
35
  * - `_pkey`: PostgreSQL primary key implicit index.
35
36
  * - `_key`: PostgreSQL implicit index for inline `UNIQUE (...)` table
36
- * constraints. Dropping these by name does not drop the underlying
37
- * constraint, and dropping the constraint requires a separate DDL path
37
+ * constraints, and the unique index the differ itself creates for a
38
+ * `unique: true` column added via ADD COLUMN on engines that reject an
39
+ * inline UNIQUE there (SQLite/DuckDB — see {@link uniqueColumnIndexName}).
40
+ * Dropping these by name does not drop the underlying constraint on
41
+ * PostgreSQL, and dropping the constraint requires a separate DDL path
38
42
  * (`ALTER TABLE ... DROP CONSTRAINT`) the differ does not emit today.
43
+ * Unclaimed `_key` unique indexes are instead *reported* as
44
+ * `orphan_index` advisories (#2369).
39
45
  */
40
46
  var PROTECTED_INDEX_SUFFIXES = ["_pkey", "_key"];
41
47
  function isProtectedDbIndexName(name) {
42
48
  return PROTECTED_INDEX_SUFFIXES.some((suffix) => name.endsWith(suffix));
43
49
  }
50
+ /**
51
+ * Name of the unique index that backs a `unique: true` column. Mirrors the
52
+ * `<table>_<column>_key` name PostgreSQL gives the implicit index of an
53
+ * inline column UNIQUE constraint, so the same column ends up with the same
54
+ * index name on every engine and the orphan-index sweep treats it as
55
+ * constraint-owned.
56
+ */
57
+ function uniqueColumnIndexName(tableName, columnName) {
58
+ return `${tableName}_${columnName}_key`;
59
+ }
60
+ /**
61
+ * True when a change is report-only: it carries an advisory and no
62
+ * executable statement. Such changes never reach the migration stream.
63
+ */
64
+ function isAdvisoryOnlyChange(change) {
65
+ if (!change.advisory) return false;
66
+ return (change.sqlStatements ?? (change.sql ? [change.sql] : [])).length === 0;
67
+ }
68
+ /**
69
+ * True for an info-level report-only change: listed in `SchemaDiff.changes`
70
+ * for visibility but not counted by `has_changes` (a harmless orphan column,
71
+ * a live default the manifest no longer declares).
72
+ */
73
+ function isInfoOnlyChange(change) {
74
+ return isAdvisoryOnlyChange(change) && change.advisory?.severity === "info";
75
+ }
76
+ /**
77
+ * True when a change has no executable DDL: `type_mismatch`, an advisory-only
78
+ * change, or a change whose only SQL is an advisory comment (e.g. a SQLite
79
+ * `ALTER COLUMN` the engine cannot perform in place).
80
+ */
81
+ function isManualOrAdvisoryChange(change) {
82
+ if (change.type === "type_mismatch") return true;
83
+ if (isAdvisoryOnlyChange(change)) return true;
84
+ const statements = change.sqlStatements ?? (change.sql ? [change.sql] : []);
85
+ return statements.length > 0 && statements.every((statement) => statement.trim().startsWith("--"));
86
+ }
44
87
  function resolveDatabaseUrl(db) {
45
88
  const dbWithConfig = db;
46
89
  return db.url || dbWithConfig.config?.url || "";
@@ -120,6 +163,125 @@ function extractIndexPredicate(createIndexSql) {
120
163
  return normalizeIndexPredicate(match[1]);
121
164
  }
122
165
  /**
166
+ * Canonical form of a column default for drift comparison (#2369).
167
+ *
168
+ * The manifest side is the DDL fragment `formatDefaultValue()` would emit
169
+ * (`'anon'`, `0`, `TRUE`, `'{}'`, `current_timestamp`); the database side is
170
+ * whatever the catalog reports, which each engine renders differently:
171
+ * PostgreSQL appends casts (`'anon'::text`, `'{}'::jsonb`, `'-1'::integer`)
172
+ * and upper-cases `CURRENT_TIMESTAMP`, DuckDB wraps booleans
173
+ * (`CAST('t' AS BOOLEAN)`), SQLite echoes the literal verbatim. Both sides
174
+ * are reduced by the *manifest column type* to a `{kind, key}` pair:
175
+ *
176
+ * - `none` — no default (`undefined`, empty, or the `NULL` keyword)
177
+ * - `now` — the current-timestamp family (`current_timestamp`, `now()`,
178
+ * `transaction_timestamp()`, `datetime('now')`)
179
+ * - `number` — numeric columns, compared by numeric value
180
+ * - `bool` — boolean columns (`true`/`false`/`t`/`f`/`1`/`0`)
181
+ * - `text` — string literal contents (JSON columns compare the parsed
182
+ * value when both sides parse)
183
+ * - `func` — other function calls / keywords, compared lowercase and
184
+ * whitespace-collapsed
185
+ * - `raw` — could not be classified; callers must skip the comparison
186
+ * rather than risk a false positive
187
+ */
188
+ function canonicalizeDefault(fragment, columnType) {
189
+ if (fragment === void 0) return {
190
+ kind: "none",
191
+ key: ""
192
+ };
193
+ let s = fragment.trim();
194
+ if (s === "") return {
195
+ kind: "none",
196
+ key: ""
197
+ };
198
+ for (let guard = 0; guard < 8; guard++) {
199
+ const before = s;
200
+ const castMatch = s.match(/^CAST\s*\((.*)\s+AS\s+[A-Za-z_][\w\s()]*\)$/is);
201
+ if (castMatch) s = castMatch[1].trim();
202
+ if (s.startsWith("(") && s.endsWith(")")) s = s.slice(1, -1).trim();
203
+ s = s.replace(/::[A-Za-z_][\w ]*(\(\s*\d+(\s*,\s*\d+)?\s*\))?(\[\])?\s*$/i, "").trim();
204
+ if (s === before) break;
205
+ }
206
+ if (/^null$/i.test(s)) return {
207
+ kind: "none",
208
+ key: ""
209
+ };
210
+ const lower = s.toLowerCase().replace(/\s+/g, " ");
211
+ if (lower === "current_timestamp" || lower === "current_timestamp()" || lower === "now()" || lower === "transaction_timestamp()" || lower === "datetime('now')" || lower === "localtimestamp") return {
212
+ kind: "now",
213
+ key: "now"
214
+ };
215
+ const upperType = String(columnType).toUpperCase();
216
+ const isNumericType = upperType === "INTEGER" || upperType === "REAL" || upperType === "DECIMAL";
217
+ const isBooleanType = upperType === "BOOLEAN";
218
+ const isJsonType = upperType === "JSON";
219
+ const literalMatch = s.match(/^'((?:[^']|'')*)'$/s);
220
+ const literal = literalMatch !== null ? literalMatch[1].replace(/''/g, "'") : null;
221
+ if (isBooleanType) {
222
+ const token = (literal ?? s).trim().toLowerCase();
223
+ if ([
224
+ "true",
225
+ "t",
226
+ "1",
227
+ "yes",
228
+ "on"
229
+ ].includes(token)) return {
230
+ kind: "bool",
231
+ key: "true"
232
+ };
233
+ if ([
234
+ "false",
235
+ "f",
236
+ "0",
237
+ "no",
238
+ "off"
239
+ ].includes(token)) return {
240
+ kind: "bool",
241
+ key: "false"
242
+ };
243
+ return {
244
+ kind: "raw",
245
+ key: lower
246
+ };
247
+ }
248
+ if (isNumericType) {
249
+ const token = (literal ?? s).trim();
250
+ if (/^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/.test(token)) return {
251
+ kind: "number",
252
+ key: String(Number(token))
253
+ };
254
+ if (literal !== null) return {
255
+ kind: "text",
256
+ key: literal
257
+ };
258
+ }
259
+ if (literal !== null) {
260
+ if (isJsonType) try {
261
+ return {
262
+ kind: "text",
263
+ key: JSON.stringify(JSON.parse(literal))
264
+ };
265
+ } catch {}
266
+ return {
267
+ kind: "text",
268
+ key: literal
269
+ };
270
+ }
271
+ if (/^[+-]?(\d+\.?\d*|\.\d+)$/.test(s)) return {
272
+ kind: "text",
273
+ key: String(Number(s))
274
+ };
275
+ if (/^[A-Za-z_][\w.]*\s*\(.*\)$/s.test(s) || /^[A-Za-z_]\w*$/.test(s)) return {
276
+ kind: "func",
277
+ key: lower
278
+ };
279
+ return {
280
+ kind: "raw",
281
+ key: lower
282
+ };
283
+ }
284
+ /**
123
285
  * SchemaComparer class for comparing manifest schemas to database
124
286
  */
125
287
  var SchemaComparer = class {
@@ -157,15 +319,19 @@ var SchemaComparer = class {
157
319
  const tableChanges = await this.compareTable(tableName, schema);
158
320
  if (tableChanges.length > 0) {
159
321
  diff.changes.push(...tableChanges);
160
- diff.has_changes = true;
322
+ if (tableChanges.some((change) => !isInfoOnlyChange(change))) diff.has_changes = true;
161
323
  }
162
324
  }
163
- if (this.options.includeDroppedTables) for (const tableName of existingTables) {
325
+ const orphanTables = [];
326
+ for (const tableName of existingTables) {
164
327
  if (tableName.startsWith("_smrt_") || tableName.startsWith("sqlite_")) continue;
165
- if (!manifestSchemas[tableName]) {
166
- diff.dropped_tables.push(tableName);
167
- diff.has_changes = true;
168
- }
328
+ if (!manifestSchemas[tableName]) orphanTables.push(tableName);
329
+ }
330
+ orphanTables.sort();
331
+ diff.orphan_tables = orphanTables;
332
+ if (this.options.includeDroppedTables && orphanTables.length > 0) {
333
+ diff.dropped_tables.push(...orphanTables);
334
+ diff.has_changes = true;
169
335
  }
170
336
  return diff;
171
337
  }
@@ -176,28 +342,40 @@ var SchemaComparer = class {
176
342
  const changes = [];
177
343
  const dbSchema = await this.db.getTableSchema?.(tableName);
178
344
  if (!dbSchema) return changes;
179
- const columnChanges = this.compareColumns(tableName, manifest, dbSchema);
180
- changes.push(...columnChanges);
345
+ const columnChanges = await this.compareColumns(tableName, manifest, dbSchema);
346
+ changes.push(...this.engine === "sqlite" ? await planSqliteTableRebuilds({
347
+ db: this.db,
348
+ tableName,
349
+ changes: columnChanges,
350
+ mapType: (type) => this.ddlStrategy.mapType(type)
351
+ }) : columnChanges);
181
352
  const dbIndexPredicates = await this.getDbIndexPredicates(tableName);
182
353
  const indexChanges = this.compareIndexes(tableName, manifest, dbSchema, dbIndexPredicates);
183
354
  changes.push(...indexChanges);
184
355
  return changes;
185
356
  }
186
357
  /**
187
- * Compare columns between manifest and database
358
+ * Compare columns between manifest and database.
359
+ *
360
+ * Three families of drift (#2369):
361
+ *
362
+ * 1. **Missing column** — emit `add_column` with an executable plan for the
363
+ * engine (see {@link generateAddColumnChanges}).
364
+ * 2. **Existing column** — type drift (as before), then nullability and
365
+ * default drift (see {@link compareColumnConstraints}).
366
+ * 3. **Orphan column** (in DB, not in manifest) — always reported. With
367
+ * `includeDroppedColumns` it becomes an executable `drop_column`; a
368
+ * NOT NULL orphan without a default is a `warning` advisory because ORM
369
+ * inserts never supply it and will fail (`23502`); with `relaxColumns`
370
+ * that orphan gets an executable `DROP NOT NULL` where the engine allows.
188
371
  */
189
- compareColumns(tableName, manifest, dbSchema) {
372
+ async compareColumns(tableName, manifest, dbSchema) {
190
373
  const changes = [];
191
374
  const dbColumnNames = new Set(Object.keys(dbSchema.columns));
192
- for (const [colName, colDef] of Object.entries(manifest.columns)) if (!dbColumnNames.has(colName)) changes.push({
193
- type: "add_column",
194
- table: tableName,
195
- name: colName,
196
- column: colDef,
197
- sql: this.generateAddColumnSQL(tableName, colName, colDef)
198
- });
375
+ for (const [colName, colDef] of Object.entries(manifest.columns)) if (!dbColumnNames.has(colName)) changes.push(...await this.generateAddColumnChanges(tableName, colName, colDef));
199
376
  else {
200
377
  const dbCol = dbSchema.columns[colName];
378
+ let typeDrifted = false;
201
379
  const manifestType = colDef.type;
202
380
  if (!isValidSQLDataType(manifestType)) logger.warn(`[SchemaComparer] Invalid manifest type "${manifestType}" for ${tableName}.${colName}, treating as TEXT`);
203
381
  const validatedType = isValidSQLDataType(manifestType) ? manifestType : "TEXT";
@@ -206,6 +384,7 @@ var SchemaComparer = class {
206
384
  const normalizedActual = this.normalizeType(dbCol.type);
207
385
  const isUuidTextEquivalent = this.isUuidTextEquivalentColumn(colName, colDef, normalizedExpected, normalizedActual);
208
386
  if (normalizedExpected !== normalizedActual && !isUuidTextEquivalent && !(normalizedExpected === "JSON" && normalizedActual === "TEXT" || normalizedExpected === "TEXT" && normalizedActual === "JSON")) {
387
+ typeDrifted = true;
209
388
  if (this.isCompatibleTypeUpgrade(colDef.type, dbCol.type)) {
210
389
  const generatedSQL = this.generateTypeUpgradeSQL(tableName, colName, colDef, dbCol.type);
211
390
  changes.push({
@@ -230,18 +409,297 @@ var SchemaComparer = class {
230
409
  }
231
410
  });
232
411
  }
412
+ if (!typeDrifted) changes.push(...await this.compareColumnConstraints(tableName, colName, colDef, validatedType, dbCol));
233
413
  }
234
- if (this.options.includeDroppedColumns) {
235
- const manifestColumnNames = new Set(Object.keys(manifest.columns));
236
- for (const colName of dbColumnNames) if (!manifestColumnNames.has(colName)) changes.push({
237
- type: "drop_column",
414
+ const manifestColumnNames = new Set(Object.keys(manifest.columns));
415
+ for (const colName of dbColumnNames) {
416
+ if (manifestColumnNames.has(colName)) continue;
417
+ const dbCol = dbSchema.columns[colName];
418
+ if (this.options.includeDroppedColumns) {
419
+ changes.push({
420
+ type: "drop_column",
421
+ table: tableName,
422
+ name: colName,
423
+ mismatch: {
424
+ expected: "(not in manifest)",
425
+ actual: this.describeDbColumn(dbCol)
426
+ },
427
+ sql: this.generateDropColumnSQL(tableName, colName)
428
+ });
429
+ continue;
430
+ }
431
+ changes.push(this.describeOrphanColumn(tableName, colName, dbCol));
432
+ }
433
+ return changes;
434
+ }
435
+ /**
436
+ * Compare nullability and default between a manifest column and the live
437
+ * column, emitting `alter_column` changes (#2369).
438
+ *
439
+ * Rules:
440
+ * - Primary-key columns are owned by the PK constraint (SQLite reports a
441
+ * `TEXT PRIMARY KEY` as nullable while PostgreSQL/DuckDB imply NOT NULL),
442
+ * so they are skipped entirely.
443
+ * - Strengthening (`SET NOT NULL`, `SET DEFAULT`) is emitted as executable
444
+ * SQL on engines with `ALTER COLUMN` (PostgreSQL/DuckDB). A `SET NOT NULL`
445
+ * is preceded by a backfill of the manifest default when there is one;
446
+ * without a default the live data is probed and, if NULLs exist, the
447
+ * change is reported as manual with the suggested backfill instead of
448
+ * emitting an ALTER the engine would reject mid-batch.
449
+ * - Relaxing (`DROP NOT NULL`, `DROP DEFAULT`) is reported as an advisory
450
+ * unless `relaxColumns` is set, in which case it is executable.
451
+ * - SQLite has no `ALTER COLUMN`; every alteration is reported as manual
452
+ * (advisory-comment SQL) until the table-rebuild path (#2370) lands.
453
+ * - Defaults are compared through {@link canonicalizeDefault}; when either
454
+ * side cannot be canonicalized the comparison is skipped rather than
455
+ * risking a false positive that would churn every run.
456
+ */
457
+ async compareColumnConstraints(tableName, colName, colDef, validatedType, dbCol) {
458
+ if (colDef.primaryKey || dbCol.primaryKey) return [];
459
+ const changes = [];
460
+ const manifestNotNull = colDef.notNull === true;
461
+ const dbNotNull = dbCol.notNull === true;
462
+ const manifestDefaultSql = colDef.defaultValue !== void 0 ? this.ddlStrategy.formatDefaultValue(colDef.defaultValue, validatedType) : void 0;
463
+ const manifestDefaultCanon = canonicalizeDefault(manifestDefaultSql, validatedType);
464
+ const dbDefaultCanon = canonicalizeDefault(dbCol.defaultValue === null || dbCol.defaultValue === void 0 ? void 0 : String(dbCol.defaultValue), validatedType);
465
+ const hasManifestDefault = manifestDefaultCanon.kind !== "none";
466
+ const defaultDrifted = manifestDefaultCanon.kind !== "raw" && dbDefaultCanon.kind !== "raw" && `${manifestDefaultCanon.kind}:${manifestDefaultCanon.key}` !== `${dbDefaultCanon.kind}:${dbDefaultCanon.key}`;
467
+ const dbHasDefault = dbDefaultCanon.kind !== "none";
468
+ if (defaultDrifted) {
469
+ if (hasManifestDefault && manifestDefaultSql !== void 0) changes.push(this.buildAlterColumnChange({
470
+ tableName,
471
+ colName,
472
+ colDef,
473
+ alteration: "set_default",
474
+ expected: `DEFAULT ${manifestDefaultSql}`,
475
+ actual: dbHasDefault ? `DEFAULT ${String(dbCol.defaultValue)}` : "(no default)",
476
+ statements: this.supportsAlterColumn() ? [this.generateSetDefaultSQL(tableName, colName, manifestDefaultSql, validatedType)] : null
477
+ }));
478
+ else if (dbHasDefault) changes.push(this.buildAlterColumnChange({
479
+ tableName,
480
+ colName,
481
+ colDef,
482
+ alteration: "drop_default",
483
+ expected: "(no default)",
484
+ actual: `DEFAULT ${String(dbCol.defaultValue)}`,
485
+ statements: this.supportsAlterColumn() ? [`ALTER TABLE ${this.quoteIdentifier(tableName)} ALTER COLUMN ${this.quoteIdentifier(colName)} DROP DEFAULT`] : null,
486
+ relaxation: {
487
+ severity: "info",
488
+ message: `${tableName}.${colName} has a live default (${String(dbCol.defaultValue)}) the manifest no longer declares; inserts that omit the column keep receiving it. ${this.relaxHint("drop it")}`
489
+ }
490
+ }));
491
+ }
492
+ if (manifestNotNull && !dbNotNull) {
493
+ const quotedTable = this.quoteIdentifier(tableName);
494
+ const quotedCol = this.quoteIdentifier(colName);
495
+ const setNotNull = `ALTER TABLE ${quotedTable} ALTER COLUMN ${quotedCol} SET NOT NULL`;
496
+ if (!this.supportsAlterColumn()) changes.push(this.buildAlterColumnChange({
497
+ tableName,
498
+ colName,
499
+ colDef,
500
+ alteration: "set_not_null",
501
+ expected: "NOT NULL",
502
+ actual: "NULL",
503
+ statements: null
504
+ }));
505
+ else if (hasManifestDefault && manifestDefaultSql !== void 0) changes.push(this.buildAlterColumnChange({
506
+ tableName,
507
+ colName,
508
+ colDef,
509
+ alteration: "set_not_null",
510
+ expected: "NOT NULL",
511
+ actual: "NULL",
512
+ statements: [`UPDATE ${quotedTable} SET ${quotedCol} = ${manifestDefaultSql} WHERE ${quotedCol} IS NULL`, setNotNull]
513
+ }));
514
+ else if (await this.columnHasNulls(tableName, colName)) changes.push({
515
+ type: "alter_column",
238
516
  table: tableName,
239
517
  name: colName,
240
- sql: this.generateDropColumnSQL(tableName, colName)
518
+ column: colDef,
519
+ alteration: "set_not_null",
520
+ mismatch: {
521
+ expected: "NOT NULL",
522
+ actual: "NULL"
523
+ },
524
+ sql: `-- ${tableName}.${colName} is required by the manifest but live rows hold NULL and the column has no default to backfill; UPDATE those rows, then run: ${setNotNull}`,
525
+ advisory: {
526
+ severity: "warning",
527
+ message: `${tableName}.${colName} is required by the manifest but live rows hold NULL and there is no default to backfill. Backfill the NULLs, then rerun db:migrate (or declare a default on the field).`,
528
+ suggestedSql: [`UPDATE ${quotedTable} SET ${quotedCol} = <value> WHERE ${quotedCol} IS NULL`, setNotNull]
529
+ }
241
530
  });
242
- }
531
+ else changes.push(this.buildAlterColumnChange({
532
+ tableName,
533
+ colName,
534
+ colDef,
535
+ alteration: "set_not_null",
536
+ expected: "NOT NULL",
537
+ actual: "NULL",
538
+ statements: [setNotNull]
539
+ }));
540
+ } else if (!manifestNotNull && dbNotNull) changes.push(this.buildAlterColumnChange({
541
+ tableName,
542
+ colName,
543
+ colDef,
544
+ alteration: "drop_not_null",
545
+ expected: "NULL",
546
+ actual: "NOT NULL",
547
+ statements: this.supportsAlterColumn() ? [`ALTER TABLE ${this.quoteIdentifier(tableName)} ALTER COLUMN ${this.quoteIdentifier(colName)} DROP NOT NULL`] : null,
548
+ relaxation: {
549
+ severity: "warning",
550
+ message: `${tableName}.${colName} is NOT NULL in the database but nullable in the manifest; writes that store NULL will fail. ${this.relaxHint("drop the constraint")}`
551
+ }
552
+ }));
243
553
  return changes;
244
554
  }
555
+ /**
556
+ * Assemble an `alter_column` change.
557
+ *
558
+ * - `statements: null` means the engine cannot alter the column in place
559
+ * (SQLite): the change is reported as manual with advisory-comment SQL.
560
+ * - `relaxation` marks a weakening change: without `relaxColumns` it is
561
+ * emitted as a report-only advisory (no SQL); with it, the statements are
562
+ * executable.
563
+ */
564
+ buildAlterColumnChange(input) {
565
+ const { tableName, colName, colDef, alteration, expected, actual, statements, relaxation } = input;
566
+ const base = {
567
+ type: "alter_column",
568
+ table: tableName,
569
+ name: colName,
570
+ column: colDef,
571
+ alteration,
572
+ mismatch: {
573
+ expected,
574
+ actual
575
+ }
576
+ };
577
+ if (relaxation && !this.options.relaxColumns) return {
578
+ ...base,
579
+ advisory: {
580
+ severity: relaxation.severity,
581
+ message: relaxation.message,
582
+ suggestedSql: statements ?? [`-- SQLite cannot ALTER COLUMN; ${alteration === "drop_not_null" ? "dropping NOT NULL" : "dropping the default"} requires a table rebuild (#2370)`]
583
+ }
584
+ };
585
+ if (statements === null) {
586
+ const what = alteration === "set_not_null" ? `enforcing NOT NULL on ${colName}` : alteration === "drop_not_null" ? `dropping NOT NULL from ${colName}` : alteration === "set_default" ? `setting the default on ${colName}` : `dropping the default from ${colName}`;
587
+ return {
588
+ ...base,
589
+ sql: `-- SQLite: ${what} requires table recreation (expected ${expected}, found ${actual}; #2370)`
590
+ };
591
+ }
592
+ return {
593
+ ...base,
594
+ sql: statements[statements.length - 1],
595
+ ...statements.length > 1 ? { sqlStatements: statements } : {}
596
+ };
597
+ }
598
+ /**
599
+ * Describe a DB column that has no manifest counterpart. Blocking when it
600
+ * is NOT NULL without a default: ORM inserts never supply it, so every
601
+ * insert fails with a not-null violation while `db:status` would otherwise
602
+ * say the schema is in sync (#2369).
603
+ */
604
+ describeOrphanColumn(tableName, colName, dbCol) {
605
+ const dbHasDefault = dbCol.defaultValue !== null && dbCol.defaultValue !== void 0;
606
+ const blocking = dbCol.notNull === true && !dbHasDefault;
607
+ const quotedTable = this.quoteIdentifier(tableName);
608
+ const quotedCol = this.quoteIdentifier(colName);
609
+ const dropSql = `ALTER TABLE ${quotedTable} DROP COLUMN ${quotedCol}`;
610
+ const relaxSql = `ALTER TABLE ${quotedTable} ALTER COLUMN ${quotedCol} DROP NOT NULL`;
611
+ const shape = this.describeDbColumn(dbCol);
612
+ const base = {
613
+ type: "orphan_column",
614
+ table: tableName,
615
+ name: colName,
616
+ mismatch: {
617
+ expected: "(not in manifest)",
618
+ actual: shape
619
+ }
620
+ };
621
+ if (!blocking) return {
622
+ ...base,
623
+ advisory: {
624
+ severity: "info",
625
+ message: `${tableName}.${colName} exists in the database but not in the manifest (${shape}). Harmless for inserts; pass includeDroppedColumns / --drop-columns to drop it.`,
626
+ suggestedSql: [dropSql]
627
+ }
628
+ };
629
+ if (this.options.relaxColumns && this.supportsAlterColumn()) return {
630
+ type: "alter_column",
631
+ table: tableName,
632
+ name: colName,
633
+ alteration: "drop_not_null",
634
+ mismatch: {
635
+ expected: "(not in manifest)",
636
+ actual: shape
637
+ },
638
+ sql: relaxSql
639
+ };
640
+ const fix = this.supportsAlterColumn() ? `Pass relaxColumns / --relax-columns to drop the NOT NULL (keeps data), or includeDroppedColumns / --drop-columns to drop the column.` : `SQLite cannot drop NOT NULL in place; pass includeDroppedColumns / --drop-columns to drop the column (or rebuild the table, #2370).`;
641
+ return {
642
+ ...base,
643
+ advisory: {
644
+ severity: "warning",
645
+ message: `${tableName}.${colName} is NOT NULL without a default and is not in the manifest — every ORM insert into ${tableName} will fail (not-null violation). ${fix}`,
646
+ suggestedSql: this.supportsAlterColumn() ? [relaxSql, dropSql] : [dropSql]
647
+ }
648
+ };
649
+ }
650
+ /**
651
+ * Remediation hint for a relaxation advisory. On engines with `ALTER
652
+ * COLUMN` the flag executes it; SQLite cannot relax in place, so the hint
653
+ * says so instead of implying the flag will act (#2370).
654
+ */
655
+ relaxHint(action) {
656
+ return this.supportsAlterColumn() ? `Pass relaxColumns / --relax-columns to ${action}.` : `SQLite cannot ${action} in place (no ALTER COLUMN); it requires a table rebuild (#2370) — relaxColumns / --relax-columns only reports it as manual here.`;
657
+ }
658
+ describeDbColumn(dbCol) {
659
+ const parts = [String(dbCol.type ?? "").toUpperCase() || "UNKNOWN"];
660
+ if (dbCol.notNull) parts.push("NOT NULL");
661
+ if (dbCol.defaultValue !== null && dbCol.defaultValue !== void 0) parts.push(`DEFAULT ${String(dbCol.defaultValue)}`);
662
+ return parts.join(" ");
663
+ }
664
+ /**
665
+ * Whether the engine can alter nullability/defaults of an existing column
666
+ * in place. SQLite cannot (`ALTER TABLE ... ALTER COLUMN` does not exist);
667
+ * PostgreSQL, DuckDB and the DuckDB-backed JSON adapter can.
668
+ */
669
+ supportsAlterColumn() {
670
+ return this.engine !== "sqlite";
671
+ }
672
+ generateSetDefaultSQL(tableName, colName, formattedDefault, validatedType) {
673
+ const defaultSql = this.engine === "postgres" && this.normalizeType(validatedType) === "JSON" ? `${formattedDefault}::${this.ddlStrategy.mapType(validatedType).toLowerCase()}` : formattedDefault;
674
+ return `ALTER TABLE ${this.quoteIdentifier(tableName)} ALTER COLUMN ${this.quoteIdentifier(colName)} SET DEFAULT ${defaultSql}`;
675
+ }
676
+ /**
677
+ * Live-data probe: does the table hold any row? Used to decide whether an
678
+ * added required column without a default can be enforced NOT NULL right
679
+ * away. Errors (adapter without raw query, etc.) resolve to `true` so the
680
+ * caller takes the conservative path.
681
+ */
682
+ async tableHasRows(tableName) {
683
+ try {
684
+ return ((await this.db.query(`SELECT 1 AS present FROM ${this.quoteIdentifier(tableName)} LIMIT 1`)).rows?.length ?? 0) > 0;
685
+ } catch (err) {
686
+ logger.debug(`[SchemaComparer] Row probe unavailable for ${tableName}; assuming populated`, { error: err instanceof Error ? err.message : String(err) });
687
+ return true;
688
+ }
689
+ }
690
+ /**
691
+ * Live-data probe: does the column hold any NULL? Used before emitting a
692
+ * `SET NOT NULL` that has no default to backfill with. Errors resolve to
693
+ * `true` (conservative: report instead of emitting an ALTER that fails).
694
+ */
695
+ async columnHasNulls(tableName, colName) {
696
+ try {
697
+ return ((await this.db.query(`SELECT 1 AS present FROM ${this.quoteIdentifier(tableName)} WHERE ${this.quoteIdentifier(colName)} IS NULL LIMIT 1`)).rows?.length ?? 0) > 0;
698
+ } catch (err) {
699
+ logger.debug(`[SchemaComparer] NULL probe unavailable for ${tableName}.${colName}; assuming NULLs exist`, { error: err instanceof Error ? err.message : String(err) });
700
+ return true;
701
+ }
702
+ }
245
703
  isUuidTextEquivalentColumn(columnName, colDef, normalizedExpected, normalizedActual) {
246
704
  if (!(normalizedExpected === "UUID" && normalizedActual === "TEXT") && !(normalizedExpected === "TEXT" && normalizedActual === "UUID")) return false;
247
705
  return this.isStructuralUuidCompatibleColumn(columnName, colDef);
@@ -346,11 +804,31 @@ var SchemaComparer = class {
346
804
  sql: this.generateAddIndexSQL(tableName, idx)
347
805
  });
348
806
  }
349
- if (this.options.includeDroppedIndexes) for (const idx of dbSchema.indexes) {
807
+ for (const idx of dbSchema.indexes) {
350
808
  if (claimedDbIndexes.has(idx.name)) continue;
351
- if (isProtectedDbIndexName(idx.name)) continue;
352
809
  const idxSignature = this.getIndexSignature(idx.columns, idx.unique ?? false, dbPredicateFor(idx.name));
353
810
  if (manifestSignatureSet.has(idxSignature)) continue;
811
+ if (isProtectedDbIndexName(idx.name)) {
812
+ if (!idx.unique || idx.name.endsWith("_pkey")) continue;
813
+ if (idx.columns.length === 1 && manifest.columns[idx.columns[0]]?.unique === true) continue;
814
+ const cols = idx.columns.map((c) => this.quoteIdentifier(c)).join(", ");
815
+ changes.push({
816
+ type: "orphan_index",
817
+ table: tableName,
818
+ name: idx.name,
819
+ mismatch: {
820
+ expected: "(not in manifest)",
821
+ actual: `UNIQUE (${idx.columns.join(", ")})`
822
+ },
823
+ advisory: {
824
+ severity: "warning",
825
+ message: `Unique constraint ${idx.name} on ${tableName}(${idx.columns.join(", ")}) is no longer declared by the manifest; duplicate values the model now allows will still be rejected. Drop it manually.`,
826
+ suggestedSql: this.engine === "postgres" ? [`ALTER TABLE ${this.quoteIdentifier(tableName)} DROP CONSTRAINT IF EXISTS ${this.quoteIdentifier(idx.name)}`, `DROP INDEX IF EXISTS ${this.quoteIdentifier(idx.name)} -- if it is a plain unique index on ${cols} rather than a constraint`] : [this.generateDropIndexSQL(idx.name)]
827
+ }
828
+ });
829
+ continue;
830
+ }
831
+ if (!this.options.includeDroppedIndexes) continue;
354
832
  changes.push({
355
833
  type: "drop_index",
356
834
  table: tableName,
@@ -529,7 +1007,7 @@ var SchemaComparer = class {
529
1007
  switch (this.engine) {
530
1008
  case "sqlite":
531
1009
  if (this.normalizeType(manifestType) === "JSON" && this.normalizeType(dbType) === "TEXT") return { sql: `-- SQLite: ${quotedCol} already stores JSON as TEXT (no change needed)` };
532
- return { sql: `-- SQLite: Type upgrade for ${quotedCol} requires table recreation` };
1010
+ return { sql: sqliteRebuildPlaceholderSql(colName) };
533
1011
  case "postgres": {
534
1012
  const manifestNormalized = this.normalizeType(manifestType);
535
1013
  const dbNormalized = this.normalizeType(dbType);
@@ -593,21 +1071,88 @@ var SchemaComparer = class {
593
1071
  return `'${value.replace(/'/g, "''")}'`;
594
1072
  }
595
1073
  /**
596
- * Generate SQL for adding a column
1074
+ * Plan the changes that add a missing column so the emitted DDL is
1075
+ * executable on the active engine (#2369).
1076
+ *
1077
+ * Engines disagree on what `ALTER TABLE ... ADD COLUMN` may carry inline
1078
+ * (probed on SQLite 3.4x/3.5x, DuckDB 1.4.x, PostgreSQL 16/17):
1079
+ *
1080
+ * | clause | SQLite | DuckDB | PostgreSQL |
1081
+ * |-----------------|--------------------------------|----------|---------------------|
1082
+ * | NOT NULL + DEF | ok | rejected | ok |
1083
+ * | NOT NULL, no DEF| ok on empty, rejected populated| rejected | ok empty, rej. pop. |
1084
+ * | UNIQUE | rejected | rejected | ok |
1085
+ * | DEFAULT | ok | ok | ok |
1086
+ * | CHECK | ok | rejected | ok |
1087
+ *
1088
+ * so the plan is:
1089
+ * - `NOT NULL` with a default: inline on SQLite/PostgreSQL (existing rows
1090
+ * receive the default); DuckDB adds with `DEFAULT` then a separate
1091
+ * `ALTER COLUMN ... SET NOT NULL`.
1092
+ * - `NOT NULL` without a default: enforced only when the table is empty
1093
+ * (inline on SQLite/PostgreSQL, `SET NOT NULL` step on DuckDB). On a
1094
+ * populated table there is nothing to backfill with, so the column is
1095
+ * added nullable and a manual `alter_column` (`set_not_null`) is reported
1096
+ * with the exact remediation instead of emitting DDL every engine rejects.
1097
+ * - `UNIQUE`: inline on PostgreSQL (a real constraint); SQLite/DuckDB get a
1098
+ * separate `CREATE UNIQUE INDEX <table>_<col>_key` (the PostgreSQL
1099
+ * constraint-index name, so the orphan sweep leaves it alone).
1100
+ * - `CHECK`: inline where accepted; DuckDB rejects it in ADD COLUMN, so it
1101
+ * is dropped there with a warning (SMRT's generators never emit `check`).
1102
+ *
1103
+ * The primary `add_column` change carries the whole plan in
1104
+ * `sqlStatements` (and the last statement in `sql`) so both the CLI and the
1105
+ * orchestrator execute it as one unit.
597
1106
  */
598
- generateAddColumnSQL(tableName, colName, colDef) {
1107
+ async generateAddColumnChanges(tableName, colName, colDef) {
599
1108
  const validatedType = isValidSQLDataType(colDef.type) ? colDef.type : "TEXT";
600
1109
  if (!isValidSQLDataType(colDef.type)) logger.warn(`[SchemaComparer] Invalid manifest type "${colDef.type}" for ${tableName}.${colName}, treating as TEXT`);
601
- const parts = [this.quoteIdentifier(colName), this.ddlStrategy.mapType(validatedType)];
602
- if (colDef.notNull) parts.push("NOT NULL");
603
- if (colDef.unique) parts.push("UNIQUE");
604
- if (colDef.defaultValue !== void 0) {
605
- const defaultVal = this.ddlStrategy.formatDefaultValue(colDef.defaultValue, validatedType);
606
- parts.push(`DEFAULT ${defaultVal}`);
607
- }
608
- if (colDef.check) parts.push(`CHECK (${colDef.check})`);
609
- const columnDefinition = parts.join(" ");
610
- return `ALTER TABLE ${this.quoteIdentifier(tableName)} ADD COLUMN ${columnDefinition}`;
1110
+ const quotedTable = this.quoteIdentifier(tableName);
1111
+ const quotedCol = this.quoteIdentifier(colName);
1112
+ const parts = [quotedCol, this.ddlStrategy.mapType(validatedType)];
1113
+ const followUps = [];
1114
+ const extraChanges = [];
1115
+ const hasDefault = colDef.defaultValue !== void 0 && colDef.defaultValue !== null;
1116
+ const formattedDefault = hasDefault ? this.ddlStrategy.formatDefaultValue(colDef.defaultValue, validatedType) : void 0;
1117
+ const inlineConstraintsAllowed = this.engine !== "duckdb" && this.engine !== "json";
1118
+ const setNotNull = `ALTER TABLE ${quotedTable} ALTER COLUMN ${quotedCol} SET NOT NULL`;
1119
+ let enforceNotNull = false;
1120
+ if (colDef.notNull) if (hasDefault) enforceNotNull = true;
1121
+ else if (!await this.tableHasRows(tableName)) enforceNotNull = true;
1122
+ else extraChanges.push({
1123
+ type: "alter_column",
1124
+ table: tableName,
1125
+ name: colName,
1126
+ column: colDef,
1127
+ alteration: "set_not_null",
1128
+ mismatch: {
1129
+ expected: "NOT NULL",
1130
+ actual: "NULL"
1131
+ },
1132
+ sql: `-- ${tableName}.${colName} is required by the manifest but has no default to backfill existing rows; the column is added nullable. Backfill it, then run: ${setNotNull}`,
1133
+ advisory: {
1134
+ severity: "warning",
1135
+ message: `${tableName}.${colName} is required by the manifest but has no default, and ${tableName} already holds rows — no engine can add it NOT NULL. It is added nullable; backfill it, then rerun db:migrate (or declare a default on the field).`,
1136
+ suggestedSql: [`UPDATE ${quotedTable} SET ${quotedCol} = <value> WHERE ${quotedCol} IS NULL`, this.supportsAlterColumn() ? setNotNull : `-- SQLite: enforcing NOT NULL afterwards requires a table rebuild (#2370)`]
1137
+ }
1138
+ });
1139
+ if (enforceNotNull && inlineConstraintsAllowed) parts.push("NOT NULL");
1140
+ if (colDef.unique && this.engine === "postgres") parts.push("UNIQUE");
1141
+ if (formattedDefault !== void 0) parts.push(`DEFAULT ${formattedDefault}`);
1142
+ if (colDef.check) if (inlineConstraintsAllowed) parts.push(`CHECK (${colDef.check})`);
1143
+ else logger.warn(`[SchemaComparer] ${this.engine} rejects CHECK constraints in ADD COLUMN; ${tableName}.${colName} is added without CHECK (${colDef.check})`);
1144
+ const addColumn = `ALTER TABLE ${quotedTable} ADD COLUMN ${parts.join(" ")}`;
1145
+ if (enforceNotNull && !inlineConstraintsAllowed) followUps.push(setNotNull);
1146
+ if (colDef.unique && this.engine !== "postgres") followUps.push(`CREATE UNIQUE INDEX ${this.quoteIdentifier(uniqueColumnIndexName(tableName, colName))} ON ${quotedTable} (${quotedCol})`);
1147
+ const statements = [addColumn, ...followUps];
1148
+ return [{
1149
+ type: "add_column",
1150
+ table: tableName,
1151
+ name: colName,
1152
+ column: colDef,
1153
+ sql: addColumn,
1154
+ ...statements.length > 1 ? { sqlStatements: statements } : {}
1155
+ }, ...extraChanges];
611
1156
  }
612
1157
  /**
613
1158
  * Generate SQL for dropping a column
@@ -626,11 +1171,16 @@ var SchemaComparer = class {
626
1171
  * reject partial indexes — the predicate is dropped so the emitted DDL stays
627
1172
  * executable (a partial index degrades to a full index there). The predicate
628
1173
  * is trimmed and a redundant leading `WHERE` stripped for robustness.
1174
+ *
1175
+ * `IF NOT EXISTS` matches the canonical `generateIndexes()` DDL path and
1176
+ * makes a retry after a partially applied batch repair the schema instead of
1177
+ * erroring on the indexes that did land (issue #2362). All three engines
1178
+ * (SQLite, PostgreSQL, DuckDB) accept the clause.
629
1179
  */
630
1180
  generateAddIndexSQL(tableName, idx) {
631
1181
  const uniqueStr = idx.unique ? "UNIQUE " : "";
632
1182
  const target = renderIndexTarget(idx, this.engine);
633
- let sql = `CREATE ${uniqueStr}INDEX ${this.quoteIdentifier(idx.name)} ON ${this.quoteIdentifier(tableName)} (${target})`;
1183
+ let sql = `CREATE ${uniqueStr}INDEX IF NOT EXISTS ${this.quoteIdentifier(idx.name)} ON ${this.quoteIdentifier(tableName)} (${target})`;
634
1184
  const where = idx.where?.trim().replace(/^WHERE\s+/i, "");
635
1185
  if (this.supportsPartialIndexes() && where) sql += ` WHERE ${where}`;
636
1186
  return sql;
@@ -660,22 +1210,30 @@ async function generateSchemaDiff(db, manifestSchemas, options = {}) {
660
1210
  return new SchemaComparer(db, options).compare(manifestSchemas);
661
1211
  }
662
1212
  /**
663
- * Check if a diff has any actionable changes (excluding type mismatches)
1213
+ * Check if a diff has any actionable changes a table to create or drop, or
1214
+ * a change with executable DDL. Incompatible type mismatches, report-only
1215
+ * advisories (orphan columns/indexes, relaxations the caller has not opted
1216
+ * into) and comment-only changes (SQLite in-place limitations, live data
1217
+ * blocking a repair) are not actionable; they surface through
1218
+ * `unactionableChanges` / the CLI's manual bucket instead.
664
1219
  */
665
1220
  function hasActionableChanges(diff) {
666
1221
  if (diff.added_tables.length > 0) return true;
667
1222
  if (diff.dropped_tables.length > 0) return true;
668
- return diff.changes.some((c) => c.type !== "type_mismatch");
1223
+ return diff.changes.some((c) => !isManualOrAdvisoryChange(c));
669
1224
  }
670
1225
  /**
671
- * Get SQL statements from a diff for execution
1226
+ * Get SQL statements from a diff for execution. Advisory-only changes carry
1227
+ * no statements and are skipped; comment-only statements (SQLite in-place
1228
+ * limitations) pass through unchanged so callers can filter them the way
1229
+ * they already do for `type_upgrade`.
672
1230
  */
673
1231
  function getSQLFromDiff(diff) {
674
1232
  const statements = [];
675
- for (const change of diff.changes) if (change.type !== "type_mismatch") statements.push(...change.sqlStatements ?? (change.sql ? [change.sql] : []));
1233
+ for (const change of diff.changes) if (change.type !== "type_mismatch" && !isAdvisoryOnlyChange(change)) statements.push(...change.sqlStatements ?? (change.sql ? [change.sql] : []));
676
1234
  return statements;
677
1235
  }
678
1236
  //#endregion
679
- export { SchemaComparer, extractIndexPredicate, generateSchemaDiff, getSQLFromDiff, hasActionableChanges, normalizeIndexPredicate };
1237
+ export { SchemaComparer, canonicalizeDefault, extractIndexPredicate, generateSchemaDiff, getSQLFromDiff, hasActionableChanges, isAdvisoryOnlyChange, isInfoOnlyChange, isManualOrAdvisoryChange, normalizeIndexPredicate, uniqueColumnIndexName };
680
1238
 
681
1239
  //# sourceMappingURL=differ.js.map