@happyvertical/smrt-core 0.40.69 → 0.41.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 (237) hide show
  1. package/AGENTS.md +29 -4
  2. package/README.md +20 -1
  3. package/agents/change-feed.md +1 -1
  4. package/agents/query-bounds.md +45 -0
  5. package/agents/schema-paths.md +786 -0
  6. package/dist/browser.d.ts +1 -0
  7. package/dist/browser.d.ts.map +1 -1
  8. package/dist/browser.js +5 -3
  9. package/dist/cascade.d.ts +120 -0
  10. package/dist/cascade.d.ts.map +1 -0
  11. package/dist/cascade.js +430 -0
  12. package/dist/cascade.js.map +1 -0
  13. package/dist/change-feed.d.ts +34 -2
  14. package/dist/change-feed.d.ts.map +1 -1
  15. package/dist/change-feed.js +52 -11
  16. package/dist/change-feed.js.map +1 -1
  17. package/dist/class.d.ts +36 -3
  18. package/dist/class.d.ts.map +1 -1
  19. package/dist/class.js +87 -9
  20. package/dist/class.js.map +1 -1
  21. package/dist/collection-cache.js +0 -0
  22. package/dist/collection-cache.js.map +1 -1
  23. package/dist/collection.d.ts +130 -2
  24. package/dist/collection.d.ts.map +1 -1
  25. package/dist/collection.js +290 -57
  26. package/dist/collection.js.map +1 -1
  27. package/dist/config.d.ts +10 -0
  28. package/dist/config.d.ts.map +1 -1
  29. package/dist/config.js.map +1 -1
  30. package/dist/database.d.ts +8 -0
  31. package/dist/database.d.ts.map +1 -1
  32. package/dist/database.js +16 -8
  33. package/dist/database.js.map +1 -1
  34. package/dist/db-errors.d.ts +105 -0
  35. package/dist/db-errors.d.ts.map +1 -0
  36. package/dist/db-errors.js +382 -0
  37. package/dist/db-errors.js.map +1 -0
  38. package/dist/decorators/index.d.ts +80 -6
  39. package/dist/decorators/index.d.ts.map +1 -1
  40. package/dist/decorators/index.js +102 -12
  41. package/dist/decorators/index.js.map +1 -1
  42. package/dist/dispatch/bus.d.ts.map +1 -1
  43. package/dist/dispatch/bus.js +4 -3
  44. package/dist/dispatch/bus.js.map +1 -1
  45. package/dist/dispatch/collections/Dispatches.d.ts.map +1 -1
  46. package/dist/dispatch/collections/Dispatches.js +19 -4
  47. package/dist/dispatch/collections/Dispatches.js.map +1 -1
  48. package/dist/dispatch/types.d.ts +5 -0
  49. package/dist/dispatch/types.d.ts.map +1 -1
  50. package/dist/embedded-write-queue.d.ts +46 -0
  51. package/dist/embedded-write-queue.d.ts.map +1 -0
  52. package/dist/embedded-write-queue.js +66 -0
  53. package/dist/embedded-write-queue.js.map +1 -0
  54. package/dist/embeddings/storage.d.ts +7 -0
  55. package/dist/embeddings/storage.d.ts.map +1 -1
  56. package/dist/embeddings/storage.js +29 -12
  57. package/dist/embeddings/storage.js.map +1 -1
  58. package/dist/errors.d.ts +31 -3
  59. package/dist/errors.d.ts.map +1 -1
  60. package/dist/errors.js +34 -2
  61. package/dist/errors.js.map +1 -1
  62. package/dist/generators/changes-route.d.ts.map +1 -1
  63. package/dist/generators/changes-route.js +6 -3
  64. package/dist/generators/changes-route.js.map +1 -1
  65. package/dist/generators/mcp-runtime-template.d.ts +8 -0
  66. package/dist/generators/mcp-runtime-template.d.ts.map +1 -1
  67. package/dist/generators/mcp-runtime-template.js +38 -4
  68. package/dist/generators/mcp-runtime-template.js.map +1 -1
  69. package/dist/generators/mcp.d.ts +16 -0
  70. package/dist/generators/mcp.d.ts.map +1 -1
  71. package/dist/generators/mcp.js +41 -3
  72. package/dist/generators/mcp.js.map +1 -1
  73. package/dist/generators/rest.d.ts +22 -0
  74. package/dist/generators/rest.d.ts.map +1 -1
  75. package/dist/generators/rest.js +34 -3
  76. package/dist/generators/rest.js.map +1 -1
  77. package/dist/hierarchical.js +1 -1
  78. package/dist/index.d.ts +7 -1
  79. package/dist/index.d.ts.map +1 -1
  80. package/dist/index.js +12 -5
  81. package/dist/interceptors.d.ts +21 -0
  82. package/dist/interceptors.d.ts.map +1 -1
  83. package/dist/interceptors.js +27 -1
  84. package/dist/interceptors.js.map +1 -1
  85. package/dist/manifest/generator.d.ts.map +1 -1
  86. package/dist/manifest/generator.js +4 -7
  87. package/dist/manifest/generator.js.map +1 -1
  88. package/dist/manifest/static-manifest.js +10 -10
  89. package/dist/manifest/static-manifest.js.map +1 -1
  90. package/dist/manifest/store.js +1 -1
  91. package/dist/manifest/store.js.map +1 -1
  92. package/dist/manifest.json +19 -19
  93. package/dist/migrations/differ.d.ts +211 -9
  94. package/dist/migrations/differ.d.ts.map +1 -1
  95. package/dist/migrations/differ.js +613 -50
  96. package/dist/migrations/differ.js.map +1 -1
  97. package/dist/migrations/generator.d.ts +31 -4
  98. package/dist/migrations/generator.d.ts.map +1 -1
  99. package/dist/migrations/generator.js +49 -5
  100. package/dist/migrations/generator.js.map +1 -1
  101. package/dist/migrations/index.d.ts +4 -2
  102. package/dist/migrations/index.d.ts.map +1 -1
  103. package/dist/migrations/index.js +6 -3
  104. package/dist/migrations/minor-units.d.ts +162 -0
  105. package/dist/migrations/minor-units.d.ts.map +1 -0
  106. package/dist/migrations/minor-units.js +381 -0
  107. package/dist/migrations/minor-units.js.map +1 -0
  108. package/dist/migrations/orchestrate.js +35 -6
  109. package/dist/migrations/orchestrate.js.map +1 -1
  110. package/dist/migrations/sqlite-rebuild.d.ts +142 -0
  111. package/dist/migrations/sqlite-rebuild.d.ts.map +1 -0
  112. package/dist/migrations/sqlite-rebuild.js +514 -0
  113. package/dist/migrations/sqlite-rebuild.js.map +1 -0
  114. package/dist/migrations/tracker.d.ts +114 -1
  115. package/dist/migrations/tracker.d.ts.map +1 -1
  116. package/dist/migrations/tracker.js +331 -16
  117. package/dist/migrations/tracker.js.map +1 -1
  118. package/dist/migrations/types.d.ts +19 -4
  119. package/dist/migrations/types.d.ts.map +1 -1
  120. package/dist/migrations.js +6 -3
  121. package/dist/object.d.ts +142 -10
  122. package/dist/object.d.ts.map +1 -1
  123. package/dist/object.js +196 -41
  124. package/dist/object.js.map +1 -1
  125. package/dist/postgres-timeouts.d.ts +240 -0
  126. package/dist/postgres-timeouts.d.ts.map +1 -0
  127. package/dist/postgres-timeouts.js +204 -0
  128. package/dist/postgres-timeouts.js.map +1 -0
  129. package/dist/query-bounds.d.ts +101 -0
  130. package/dist/query-bounds.d.ts.map +1 -0
  131. package/dist/query-bounds.js +177 -0
  132. package/dist/query-bounds.js.map +1 -0
  133. package/dist/registry/class-registration.d.ts.map +1 -1
  134. package/dist/registry/class-registration.js +3 -1
  135. package/dist/registry/class-registration.js.map +1 -1
  136. package/dist/registry/manifest-field-merge.d.ts +12 -0
  137. package/dist/registry/manifest-field-merge.d.ts.map +1 -1
  138. package/dist/registry/manifest-field-merge.js +14 -2
  139. package/dist/registry/manifest-field-merge.js.map +1 -1
  140. package/dist/registry/schema-builder.d.ts +22 -1
  141. package/dist/registry/schema-builder.d.ts.map +1 -1
  142. package/dist/registry/schema-builder.js +205 -165
  143. package/dist/registry/schema-builder.js.map +1 -1
  144. package/dist/registry/types.d.ts +35 -3
  145. package/dist/registry/types.d.ts.map +1 -1
  146. package/dist/registry.d.ts +41 -46
  147. package/dist/registry.d.ts.map +1 -1
  148. package/dist/registry.js +61 -83
  149. package/dist/registry.js.map +1 -1
  150. package/dist/scanner/manifest-generator.d.ts +45 -0
  151. package/dist/scanner/manifest-generator.d.ts.map +1 -1
  152. package/dist/scanner/manifest-generator.js +92 -28
  153. package/dist/scanner/manifest-generator.js.map +1 -1
  154. package/dist/scanner/types.d.ts +5 -0
  155. package/dist/scanner/types.d.ts.map +1 -1
  156. package/dist/scanner/types.js.map +1 -1
  157. package/dist/schema/conflict-target.d.ts +104 -0
  158. package/dist/schema/conflict-target.d.ts.map +1 -0
  159. package/dist/schema/conflict-target.js +129 -0
  160. package/dist/schema/conflict-target.js.map +1 -0
  161. package/dist/schema/ddl/base-strategy.d.ts.map +1 -1
  162. package/dist/schema/ddl/base-strategy.js +2 -2
  163. package/dist/schema/ddl/base-strategy.js.map +1 -1
  164. package/dist/schema/ddl/duckdb-strategy.d.ts.map +1 -1
  165. package/dist/schema/ddl/duckdb-strategy.js +2 -1
  166. package/dist/schema/ddl/duckdb-strategy.js.map +1 -1
  167. package/dist/schema/ddl/postgres-strategy.d.ts.map +1 -1
  168. package/dist/schema/ddl/postgres-strategy.js +12 -1
  169. package/dist/schema/ddl/postgres-strategy.js.map +1 -1
  170. package/dist/schema/generator.d.ts +307 -6
  171. package/dist/schema/generator.d.ts.map +1 -1
  172. package/dist/schema/generator.js +510 -87
  173. package/dist/schema/generator.js.map +1 -1
  174. package/dist/schema/index-utils.d.ts +120 -0
  175. package/dist/schema/index-utils.d.ts.map +1 -1
  176. package/dist/schema/index-utils.js +242 -1
  177. package/dist/schema/index-utils.js.map +1 -1
  178. package/dist/schema/index.d.ts +3 -0
  179. package/dist/schema/index.d.ts.map +1 -1
  180. package/dist/schema/index.js +4 -1
  181. package/dist/schema/live-parity.d.ts +90 -0
  182. package/dist/schema/live-parity.d.ts.map +1 -0
  183. package/dist/schema/live-parity.js +602 -0
  184. package/dist/schema/live-parity.js.map +1 -0
  185. package/dist/schema/manifest-schema.d.ts +121 -0
  186. package/dist/schema/manifest-schema.d.ts.map +1 -0
  187. package/dist/schema/manifest-schema.js +267 -0
  188. package/dist/schema/manifest-schema.js.map +1 -0
  189. package/dist/schema/schema-aggregator.d.ts +24 -10
  190. package/dist/schema/schema-aggregator.d.ts.map +1 -1
  191. package/dist/schema/schema-aggregator.js +35 -90
  192. package/dist/schema/schema-aggregator.js.map +1 -1
  193. package/dist/schema/system-table-shapes.d.ts +65 -0
  194. package/dist/schema/system-table-shapes.d.ts.map +1 -0
  195. package/dist/schema/system-table-shapes.js +187 -0
  196. package/dist/schema/system-table-shapes.js.map +1 -0
  197. package/dist/schema/types.d.ts +103 -4
  198. package/dist/schema/types.d.ts.map +1 -1
  199. package/dist/schema/utils.d.ts +2 -1
  200. package/dist/schema/utils.d.ts.map +1 -1
  201. package/dist/schema/utils.js +5 -3
  202. package/dist/schema/utils.js.map +1 -1
  203. package/dist/schema.js +4 -1
  204. package/dist/smrt-knowledge.json +20 -8
  205. package/dist/sync/apply.d.ts.map +1 -1
  206. package/dist/sync/apply.js +9 -16
  207. package/dist/sync/apply.js.map +1 -1
  208. package/dist/system/compatibility.d.ts +42 -0
  209. package/dist/system/compatibility.d.ts.map +1 -1
  210. package/dist/system/compatibility.js +182 -9
  211. package/dist/system/compatibility.js.map +1 -1
  212. package/dist/system/index.d.ts +1 -0
  213. package/dist/system/index.d.ts.map +1 -1
  214. package/dist/system/index.js +3 -2
  215. package/dist/system/retention.d.ts +237 -0
  216. package/dist/system/retention.d.ts.map +1 -0
  217. package/dist/system/retention.js +497 -0
  218. package/dist/system/retention.js.map +1 -0
  219. package/dist/system/schema.d.ts +100 -15
  220. package/dist/system/schema.d.ts.map +1 -1
  221. package/dist/system/schema.js +81 -45
  222. package/dist/system/schema.js.map +1 -1
  223. package/dist/system/types.d.ts +0 -2
  224. package/dist/system/types.d.ts.map +1 -1
  225. package/dist/testing/database.d.ts.map +1 -1
  226. package/dist/testing/database.js +1 -0
  227. package/dist/testing/database.js.map +1 -1
  228. package/dist/vite-plugin/index.d.ts.map +1 -1
  229. package/dist/vite-plugin/index.js +4 -9
  230. package/dist/vite-plugin/index.js.map +1 -1
  231. package/dist/vite-plugin/sveltekit-generator.d.ts.map +1 -1
  232. package/dist/vite-plugin/sveltekit-generator.js +71 -5
  233. package/dist/vite-plugin/sveltekit-generator.js.map +1 -1
  234. package/dist/vite-plugin/web-collections.d.ts.map +1 -1
  235. package/dist/vite-plugin/web-collections.js +6 -4
  236. package/dist/vite-plugin/web-collections.js.map +1 -1
  237. package/package.json +5 -5
@@ -1,6 +1,7 @@
1
- import { isJsonPathIndex, renderIndexTarget } from "../schema/index-utils.js";
1
+ import { isJsonPathIndex, isStiSubtypeUniqueIndex, 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);
@@ -273,7 +731,10 @@ var SchemaComparer = class {
273
731
  * caller opts in via `includeDroppedIndexes`, and even then never for
274
732
  * PostgreSQL implicit indexes (`*_pkey`, `*_key`) — those are owned by
275
733
  * table-level constraints and need a separate `DROP CONSTRAINT` path
276
- * that the differ does not emit yet.
734
+ * that the differ does not emit yet. One exception is unconditional: a
735
+ * redundant single-column index over the live primary key (the legacy
736
+ * `<table>_id_idx`, #2359) is dropped even without the opt-in, because
737
+ * the primary-key constraint already serves it.
277
738
  *
278
739
  * 4. **Partial-index predicate drift / collision** — two indexes on the
279
740
  * same column(s) and uniqueness that differ only by their `WHERE`
@@ -292,6 +753,8 @@ var SchemaComparer = class {
292
753
  const predicateAware = dbIndexPredicates !== null;
293
754
  const dbPredicateFor = (name) => predicateAware ? dbIndexPredicates?.get(name) ?? "" : "";
294
755
  const manifestPredicateFor = (idx) => predicateAware ? normalizeIndexPredicate(idx.where) : "";
756
+ const manifestIndexes = this.supportsPartialIndexes() ? manifest.indexes : manifest.indexes.filter((idx) => !isStiSubtypeUniqueIndex(idx));
757
+ const primaryKeyColumns = Object.entries(dbSchema.columns).filter(([, column]) => column.primaryKey === true).map(([name]) => name);
295
758
  const dbIndexesByName = /* @__PURE__ */ new Map();
296
759
  const dbIndexSignatures = /* @__PURE__ */ new Map();
297
760
  for (const idx of dbSchema.indexes) {
@@ -309,9 +772,9 @@ var SchemaComparer = class {
309
772
  bucket.add(idx.name);
310
773
  }
311
774
  const manifestSignatureSet = /* @__PURE__ */ new Set();
312
- for (const idx of manifest.indexes) manifestSignatureSet.add(this.getIndexSignature(idx, void 0, manifestPredicateFor(idx)));
775
+ for (const idx of manifestIndexes) manifestSignatureSet.add(this.getIndexSignature(idx, void 0, manifestPredicateFor(idx)));
313
776
  const claimedDbIndexes = /* @__PURE__ */ new Set();
314
- for (const idx of manifest.indexes) {
777
+ for (const idx of manifestIndexes) {
315
778
  const manifestSignature = this.getIndexSignature(idx, void 0, manifestPredicateFor(idx));
316
779
  const dbByName = dbIndexesByName.get(idx.name);
317
780
  if (dbByName) {
@@ -346,11 +809,31 @@ var SchemaComparer = class {
346
809
  sql: this.generateAddIndexSQL(tableName, idx)
347
810
  });
348
811
  }
349
- if (this.options.includeDroppedIndexes) for (const idx of dbSchema.indexes) {
812
+ for (const idx of dbSchema.indexes) {
350
813
  if (claimedDbIndexes.has(idx.name)) continue;
351
- if (isProtectedDbIndexName(idx.name)) continue;
352
814
  const idxSignature = this.getIndexSignature(idx.columns, idx.unique ?? false, dbPredicateFor(idx.name));
353
815
  if (manifestSignatureSet.has(idxSignature)) continue;
816
+ if (isProtectedDbIndexName(idx.name)) {
817
+ if (!idx.unique || idx.name.endsWith("_pkey")) continue;
818
+ if (idx.columns.length === 1 && manifest.columns[idx.columns[0]]?.unique === true) continue;
819
+ const cols = idx.columns.map((c) => this.quoteIdentifier(c)).join(", ");
820
+ changes.push({
821
+ type: "orphan_index",
822
+ table: tableName,
823
+ name: idx.name,
824
+ mismatch: {
825
+ expected: "(not in manifest)",
826
+ actual: `UNIQUE (${idx.columns.join(", ")})`
827
+ },
828
+ advisory: {
829
+ severity: "warning",
830
+ 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.`,
831
+ 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)]
832
+ }
833
+ });
834
+ continue;
835
+ }
836
+ if (!(idx.unique !== true && idx.columns.length === 1 && dbPredicateFor(idx.name) === "" && primaryKeyColumns.length === 1 && primaryKeyColumns[0] === idx.columns[0]) && !this.options.includeDroppedIndexes) continue;
354
837
  changes.push({
355
838
  type: "drop_index",
356
839
  table: tableName,
@@ -529,7 +1012,7 @@ var SchemaComparer = class {
529
1012
  switch (this.engine) {
530
1013
  case "sqlite":
531
1014
  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` };
1015
+ return { sql: sqliteRebuildPlaceholderSql(colName) };
533
1016
  case "postgres": {
534
1017
  const manifestNormalized = this.normalizeType(manifestType);
535
1018
  const dbNormalized = this.normalizeType(dbType);
@@ -593,21 +1076,88 @@ var SchemaComparer = class {
593
1076
  return `'${value.replace(/'/g, "''")}'`;
594
1077
  }
595
1078
  /**
596
- * Generate SQL for adding a column
1079
+ * Plan the changes that add a missing column so the emitted DDL is
1080
+ * executable on the active engine (#2369).
1081
+ *
1082
+ * Engines disagree on what `ALTER TABLE ... ADD COLUMN` may carry inline
1083
+ * (probed on SQLite 3.4x/3.5x, DuckDB 1.4.x, PostgreSQL 16/17):
1084
+ *
1085
+ * | clause | SQLite | DuckDB | PostgreSQL |
1086
+ * |-----------------|--------------------------------|----------|---------------------|
1087
+ * | NOT NULL + DEF | ok | rejected | ok |
1088
+ * | NOT NULL, no DEF| ok on empty, rejected populated| rejected | ok empty, rej. pop. |
1089
+ * | UNIQUE | rejected | rejected | ok |
1090
+ * | DEFAULT | ok | ok | ok |
1091
+ * | CHECK | ok | rejected | ok |
1092
+ *
1093
+ * so the plan is:
1094
+ * - `NOT NULL` with a default: inline on SQLite/PostgreSQL (existing rows
1095
+ * receive the default); DuckDB adds with `DEFAULT` then a separate
1096
+ * `ALTER COLUMN ... SET NOT NULL`.
1097
+ * - `NOT NULL` without a default: enforced only when the table is empty
1098
+ * (inline on SQLite/PostgreSQL, `SET NOT NULL` step on DuckDB). On a
1099
+ * populated table there is nothing to backfill with, so the column is
1100
+ * added nullable and a manual `alter_column` (`set_not_null`) is reported
1101
+ * with the exact remediation instead of emitting DDL every engine rejects.
1102
+ * - `UNIQUE`: inline on PostgreSQL (a real constraint); SQLite/DuckDB get a
1103
+ * separate `CREATE UNIQUE INDEX <table>_<col>_key` (the PostgreSQL
1104
+ * constraint-index name, so the orphan sweep leaves it alone).
1105
+ * - `CHECK`: inline where accepted; DuckDB rejects it in ADD COLUMN, so it
1106
+ * is dropped there with a warning (SMRT's generators never emit `check`).
1107
+ *
1108
+ * The primary `add_column` change carries the whole plan in
1109
+ * `sqlStatements` (and the last statement in `sql`) so both the CLI and the
1110
+ * orchestrator execute it as one unit.
597
1111
  */
598
- generateAddColumnSQL(tableName, colName, colDef) {
1112
+ async generateAddColumnChanges(tableName, colName, colDef) {
599
1113
  const validatedType = isValidSQLDataType(colDef.type) ? colDef.type : "TEXT";
600
1114
  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}`;
1115
+ const quotedTable = this.quoteIdentifier(tableName);
1116
+ const quotedCol = this.quoteIdentifier(colName);
1117
+ const parts = [quotedCol, this.ddlStrategy.mapType(validatedType)];
1118
+ const followUps = [];
1119
+ const extraChanges = [];
1120
+ const hasDefault = colDef.defaultValue !== void 0 && colDef.defaultValue !== null;
1121
+ const formattedDefault = hasDefault ? this.ddlStrategy.formatDefaultValue(colDef.defaultValue, validatedType) : void 0;
1122
+ const inlineConstraintsAllowed = this.engine !== "duckdb" && this.engine !== "json";
1123
+ const setNotNull = `ALTER TABLE ${quotedTable} ALTER COLUMN ${quotedCol} SET NOT NULL`;
1124
+ let enforceNotNull = false;
1125
+ if (colDef.notNull) if (hasDefault) enforceNotNull = true;
1126
+ else if (!await this.tableHasRows(tableName)) enforceNotNull = true;
1127
+ else extraChanges.push({
1128
+ type: "alter_column",
1129
+ table: tableName,
1130
+ name: colName,
1131
+ column: colDef,
1132
+ alteration: "set_not_null",
1133
+ mismatch: {
1134
+ expected: "NOT NULL",
1135
+ actual: "NULL"
1136
+ },
1137
+ 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}`,
1138
+ advisory: {
1139
+ severity: "warning",
1140
+ 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).`,
1141
+ suggestedSql: [`UPDATE ${quotedTable} SET ${quotedCol} = <value> WHERE ${quotedCol} IS NULL`, this.supportsAlterColumn() ? setNotNull : `-- SQLite: enforcing NOT NULL afterwards requires a table rebuild (#2370)`]
1142
+ }
1143
+ });
1144
+ if (enforceNotNull && inlineConstraintsAllowed) parts.push("NOT NULL");
1145
+ if (colDef.unique && this.engine === "postgres") parts.push("UNIQUE");
1146
+ if (formattedDefault !== void 0) parts.push(`DEFAULT ${formattedDefault}`);
1147
+ if (colDef.check) if (inlineConstraintsAllowed) parts.push(`CHECK (${colDef.check})`);
1148
+ else logger.warn(`[SchemaComparer] ${this.engine} rejects CHECK constraints in ADD COLUMN; ${tableName}.${colName} is added without CHECK (${colDef.check})`);
1149
+ const addColumn = `ALTER TABLE ${quotedTable} ADD COLUMN ${parts.join(" ")}`;
1150
+ if (enforceNotNull && !inlineConstraintsAllowed) followUps.push(setNotNull);
1151
+ if (colDef.unique && this.engine !== "postgres") followUps.push(`CREATE UNIQUE INDEX ${this.quoteIdentifier(uniqueColumnIndexName(tableName, colName))} ON ${quotedTable} (${quotedCol})`);
1152
+ const statements = [addColumn, ...followUps];
1153
+ return [{
1154
+ type: "add_column",
1155
+ table: tableName,
1156
+ name: colName,
1157
+ column: colDef,
1158
+ sql: addColumn,
1159
+ ...statements.length > 1 ? { sqlStatements: statements } : {}
1160
+ }, ...extraChanges];
611
1161
  }
612
1162
  /**
613
1163
  * Generate SQL for dropping a column
@@ -626,11 +1176,16 @@ var SchemaComparer = class {
626
1176
  * reject partial indexes — the predicate is dropped so the emitted DDL stays
627
1177
  * executable (a partial index degrades to a full index there). The predicate
628
1178
  * is trimmed and a redundant leading `WHERE` stripped for robustness.
1179
+ *
1180
+ * `IF NOT EXISTS` matches the canonical `generateIndexes()` DDL path and
1181
+ * makes a retry after a partially applied batch repair the schema instead of
1182
+ * erroring on the indexes that did land (issue #2362). All three engines
1183
+ * (SQLite, PostgreSQL, DuckDB) accept the clause.
629
1184
  */
630
1185
  generateAddIndexSQL(tableName, idx) {
631
1186
  const uniqueStr = idx.unique ? "UNIQUE " : "";
632
1187
  const target = renderIndexTarget(idx, this.engine);
633
- let sql = `CREATE ${uniqueStr}INDEX ${this.quoteIdentifier(idx.name)} ON ${this.quoteIdentifier(tableName)} (${target})`;
1188
+ let sql = `CREATE ${uniqueStr}INDEX IF NOT EXISTS ${this.quoteIdentifier(idx.name)} ON ${this.quoteIdentifier(tableName)} (${target})`;
634
1189
  const where = idx.where?.trim().replace(/^WHERE\s+/i, "");
635
1190
  if (this.supportsPartialIndexes() && where) sql += ` WHERE ${where}`;
636
1191
  return sql;
@@ -660,22 +1215,30 @@ async function generateSchemaDiff(db, manifestSchemas, options = {}) {
660
1215
  return new SchemaComparer(db, options).compare(manifestSchemas);
661
1216
  }
662
1217
  /**
663
- * Check if a diff has any actionable changes (excluding type mismatches)
1218
+ * Check if a diff has any actionable changes a table to create or drop, or
1219
+ * a change with executable DDL. Incompatible type mismatches, report-only
1220
+ * advisories (orphan columns/indexes, relaxations the caller has not opted
1221
+ * into) and comment-only changes (SQLite in-place limitations, live data
1222
+ * blocking a repair) are not actionable; they surface through
1223
+ * `unactionableChanges` / the CLI's manual bucket instead.
664
1224
  */
665
1225
  function hasActionableChanges(diff) {
666
1226
  if (diff.added_tables.length > 0) return true;
667
1227
  if (diff.dropped_tables.length > 0) return true;
668
- return diff.changes.some((c) => c.type !== "type_mismatch");
1228
+ return diff.changes.some((c) => !isManualOrAdvisoryChange(c));
669
1229
  }
670
1230
  /**
671
- * Get SQL statements from a diff for execution
1231
+ * Get SQL statements from a diff for execution. Advisory-only changes carry
1232
+ * no statements and are skipped; comment-only statements (SQLite in-place
1233
+ * limitations) pass through unchanged so callers can filter them the way
1234
+ * they already do for `type_upgrade`.
672
1235
  */
673
1236
  function getSQLFromDiff(diff) {
674
1237
  const statements = [];
675
- for (const change of diff.changes) if (change.type !== "type_mismatch") statements.push(...change.sqlStatements ?? (change.sql ? [change.sql] : []));
1238
+ for (const change of diff.changes) if (change.type !== "type_mismatch" && !isAdvisoryOnlyChange(change)) statements.push(...change.sqlStatements ?? (change.sql ? [change.sql] : []));
676
1239
  return statements;
677
1240
  }
678
1241
  //#endregion
679
- export { SchemaComparer, extractIndexPredicate, generateSchemaDiff, getSQLFromDiff, hasActionableChanges, normalizeIndexPredicate };
1242
+ export { SchemaComparer, canonicalizeDefault, extractIndexPredicate, generateSchemaDiff, getSQLFromDiff, hasActionableChanges, isAdvisoryOnlyChange, isInfoOnlyChange, isManualOrAdvisoryChange, normalizeIndexPredicate, uniqueColumnIndexName };
680
1243
 
681
1244
  //# sourceMappingURL=differ.js.map