@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
@@ -0,0 +1,381 @@
1
+ import { detectEngine } from "../schema/ddl/index.js";
2
+ import { BackfillTracker } from "./backfill-tracker.js";
3
+ import { createLogger } from "@happyvertical/logger";
4
+ //#region src/migrations/minor-units.ts
5
+ /**
6
+ * Money-column rescale: floating-point major units → integer minor units (#2401).
7
+ *
8
+ * SMRT's rule is that money is exact and is stored as **integer minor units**
9
+ * (cents, satoshis) — `$19.99` is `1999`. Packages that predate the rule
10
+ * declared their money fields `= 0.0`, which compiles to a floating-point
11
+ * column holding *major* units. Flipping the declaration to `= 0` changes the
12
+ * column type, and REAL→INTEGER is deliberately **not** one of the differ's
13
+ * whitelisted upgrades: an automatic `ALTER … TYPE integer` would truncate
14
+ * `19.99` to `19`, silently destroying 99 cents on every row.
15
+ *
16
+ * So the conversion is an explicit, opt-in migration with two halves:
17
+ *
18
+ * 1. {@link preflightMinorUnitsRescale} — read-only. Reports, per column,
19
+ * whether it still needs converting, and lists the rows that would lose
20
+ * information: values whose scaled form is not a whole number (a half-cent
21
+ * that has to be rounded away) and values that overflow PostgreSQL's `int4`
22
+ * once multiplied by the scale. Run it against a production snapshot and
23
+ * read the summary before converting anything.
24
+ * 2. {@link rescaleMoneyColumnsToMinorUnits} — the conversion itself, guarded
25
+ * by a {@link BackfillTracker} marker so re-running it can never multiply a
26
+ * table by 100 twice.
27
+ *
28
+ * Per-engine behaviour:
29
+ *
30
+ * | Engine | Column type | Values |
31
+ * | --- | --- | --- |
32
+ * | PostgreSQL | `ALTER COLUMN … TYPE integer USING round(col * scale)` | converted by the cast |
33
+ * | DuckDB | `ALTER COLUMN … TYPE INTEGER USING round(col * scale)` | converted by the cast |
34
+ * | SQLite | **not changed here** — SQLite cannot alter a column's declared type in place | rescaled with an `UPDATE`, values become exact integers |
35
+ *
36
+ * SQLite's declared type only sets an affinity, so a rescaled column already
37
+ * behaves as integer minor units; bringing the *declaration* into line needs
38
+ * the table-rebuild path (#2370). The result flags that with
39
+ * {@link MinorUnitsRescaleResult.declaredTypeChangePending} rather than
40
+ * pretending the column type moved.
41
+ */
42
+ var logger = createLogger({ level: "info" });
43
+ /** PostgreSQL `int4` bounds — the ceiling this conversion has to respect. */
44
+ var INT4_MIN = -2147483648;
45
+ var INT4_MAX = 2147483647;
46
+ /**
47
+ * How far a scaled value may sit from a whole number and still count as
48
+ * integral.
49
+ *
50
+ * `19.99 * 100` is `1998.9999999999998` in IEEE-754, so a zero tolerance would
51
+ * report every ordinary two-decimal price as non-integral. A genuine half-cent
52
+ * is off by `0.5`, five orders of magnitude above this bound, so the two cases
53
+ * never blur.
54
+ */
55
+ var INTEGRALITY_TOLERANCE = 1e-6;
56
+ /** Default minor units per major unit — cents. */
57
+ var DEFAULT_SCALE = 100;
58
+ /**
59
+ * Round half away from zero, the way SQL `round()` does — and the way
60
+ * `Math.round` does not.
61
+ *
62
+ * `Math.round` rounds halves toward +∞, so `Math.round(-0.5)` is `-0` while
63
+ * SQLite's and DuckDB's `round(-0.5)` is `-1`. Money here can legitimately be
64
+ * negative (`BillingAdjustment.amount` is a signed credit), so a preflight that
65
+ * predicted the conversion with `Math.round` would report a value one minor
66
+ * unit above what the database actually stores. The PostgreSQL DDL casts to
67
+ * `numeric` for the same reason — see {@link buildMinorUnitsStatements}.
68
+ */
69
+ function roundHalfAwayFromZero(value) {
70
+ return Math.sign(value) * Math.round(Math.abs(value));
71
+ }
72
+ /**
73
+ * A money-losing preflight finding blocked the conversion.
74
+ *
75
+ * Carries the full preflight so a caller can print the summary, fix the data,
76
+ * and retry rather than re-deriving what went wrong.
77
+ */
78
+ var MinorUnitsPreflightError = class extends Error {
79
+ preflight;
80
+ constructor(preflight) {
81
+ super(`Money minor-units rescale refused: ${preflight.nonIntegralRows} row(s) would be rounded and ${preflight.overflowRows} row(s) would overflow int4. Fix the data or pass \`force: true\` to accept the rounding.
82
+ ` + preflight.summary);
83
+ this.preflight = preflight;
84
+ this.name = "MinorUnitsPreflightError";
85
+ }
86
+ };
87
+ function resolveDatabaseUrl(db) {
88
+ const dbWithConfig = db;
89
+ return db.url || dbWithConfig.config?.url || "";
90
+ }
91
+ function resolveEngine(db, engineHint) {
92
+ return typeof db.exportTable === "function" ? "json" : detectEngine(resolveDatabaseUrl(db), engineHint);
93
+ }
94
+ /**
95
+ * Quote an identifier for DDL.
96
+ *
97
+ * Table and column names here come from a package's own migration module, not
98
+ * from user input, but a rejected embedded quote is cheaper than trusting that
99
+ * forever.
100
+ */
101
+ function quoteIdentifier(name) {
102
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new Error(`Money minor-units rescale: '${name}' is not a plain SQL identifier.`);
103
+ return `"${name}"`;
104
+ }
105
+ /** Is this declared type a floating-point one that still needs converting? */
106
+ function isFloatingType(declaredType) {
107
+ return /^(REAL|FLOAT|DOUBLE|DECIMAL|NUMERIC|NUMBER)/i.test(declaredType.trim());
108
+ }
109
+ /** Is this declared type already an integer one? */
110
+ function isIntegerType(declaredType) {
111
+ return /^(INTEGER|INT|INT4|INT8|BIGINT|SMALLINT|TINYINT|HUGEINT)\b/i.test(declaredType.trim());
112
+ }
113
+ /**
114
+ * Read declared column types for one table.
115
+ *
116
+ * PostgreSQL and DuckDB both expose `information_schema.columns`; SQLite does
117
+ * not, so it uses `PRAGMA table_info`. Returns an empty map when the table does
118
+ * not exist, which the caller reports as `missing` rather than an error — a
119
+ * package's migration lists its own tables, and a deployment may legitimately
120
+ * not have created all of them yet.
121
+ */
122
+ async function readColumnTypes(db, engine, table) {
123
+ const types = /* @__PURE__ */ new Map();
124
+ try {
125
+ if (engine === "postgres" || engine === "duckdb") {
126
+ const result = await db.query("SELECT column_name, data_type FROM information_schema.columns WHERE table_name = ?", table);
127
+ for (const row of result.rows) if (row.column_name) types.set(row.column_name, row.data_type ?? "");
128
+ return types;
129
+ }
130
+ const result = await db.query(`PRAGMA table_info(${quoteIdentifier(table)})`);
131
+ for (const row of result.rows) if (row.name) types.set(row.name, row.type ?? "");
132
+ } catch (error) {
133
+ logger.debug(`[minor-units] Could not introspect ${table}; treating its columns as missing`, { error: error instanceof Error ? error.message : String(error) });
134
+ }
135
+ return types;
136
+ }
137
+ /** Does this table expose an `id` column we can name in problem rows? */
138
+ function hasIdColumn(types) {
139
+ return types.has("id");
140
+ }
141
+ function toFiniteNumber(value) {
142
+ if (value == null) return null;
143
+ const parsed = typeof value === "number" ? value : Number(value);
144
+ return Number.isFinite(parsed) ? parsed : null;
145
+ }
146
+ /**
147
+ * Inspect every non-null value in one floating-point money column.
148
+ *
149
+ * Rows are read rather than aggregated in SQL because the integrality test has
150
+ * to survive three dialects' differing `round`/modulo semantics, and a money
151
+ * table small enough to be worth converting by hand is small enough to scan.
152
+ */
153
+ async function inspectColumn(db, table, column, scale, maxProblemRows, withId) {
154
+ const quotedTable = quoteIdentifier(table);
155
+ const quotedColumn = quoteIdentifier(column);
156
+ const selection = withId ? `"id", ${quotedColumn} AS value` : `${quotedColumn} AS value`;
157
+ const result = await db.query(`SELECT ${selection} FROM ${quotedTable} WHERE ${quotedColumn} IS NOT NULL`);
158
+ const nonIntegral = [];
159
+ const overflow = [];
160
+ let inspectedRows = 0;
161
+ for (const row of result.rows) {
162
+ const value = toFiniteNumber(row.value);
163
+ if (value === null) continue;
164
+ inspectedRows += 1;
165
+ const scaled = value * scale;
166
+ const rounded = roundHalfAwayFromZero(scaled);
167
+ const id = row.id == null ? null : String(row.id);
168
+ if (Math.abs(scaled - rounded) > INTEGRALITY_TOLERANCE) {
169
+ if (nonIntegral.length < maxProblemRows) nonIntegral.push({
170
+ id,
171
+ value,
172
+ scaled
173
+ });
174
+ }
175
+ if (rounded > INT4_MAX || rounded < INT4_MIN) {
176
+ if (overflow.length < maxProblemRows) overflow.push({
177
+ id,
178
+ value,
179
+ scaled
180
+ });
181
+ }
182
+ }
183
+ return {
184
+ inspectedRows,
185
+ nonIntegral,
186
+ overflow
187
+ };
188
+ }
189
+ function renderSummary(result) {
190
+ const lines = [
191
+ `Money minor-units preflight (engine=${result.engine}, scale=${result.scale})`,
192
+ ` columns: ${result.pendingColumns} pending, ${result.convertedColumns} already integer, ${result.missingColumns} missing`,
193
+ ` rows that would be rounded: ${result.nonIntegralRows}`,
194
+ ` rows that would overflow int4: ${result.overflowRows}`
195
+ ];
196
+ for (const column of result.columns) {
197
+ const label = `${column.table}.${column.column}`;
198
+ if (column.state === "missing") {
199
+ lines.push(` - ${label}: missing`);
200
+ continue;
201
+ }
202
+ if (column.state === "converted") {
203
+ lines.push(` - ${label}: already integer (${column.declaredType ?? "unknown"})`);
204
+ continue;
205
+ }
206
+ lines.push(` - ${label}: pending (${column.declaredType ?? "unknown"}), ${column.inspectedRows} row(s) inspected, ${column.nonIntegral.length} non-integral, ${column.overflow.length} overflow`);
207
+ for (const row of column.nonIntegral) lines.push(` non-integral id=${row.id ?? "<no id>"} value=${row.value} scaled=${row.scaled}`);
208
+ for (const row of column.overflow) lines.push(` overflow id=${row.id ?? "<no id>"} value=${row.value} scaled=${row.scaled}`);
209
+ }
210
+ return lines.join("\n");
211
+ }
212
+ /**
213
+ * The statements that convert one column, for one engine.
214
+ *
215
+ * Exported so the emitted DDL can be asserted without a live PostgreSQL or
216
+ * DuckDB connection; each owning package's `test:postgres` lane then proves the
217
+ * same SQL runs against a real server.
218
+ *
219
+ * @param engine - `postgres`, `duckdb`, or anything SQLite-shaped.
220
+ * @param table - Physical table name.
221
+ * @param column - Physical column name.
222
+ * @param scale - Minor units per major unit.
223
+ * @returns Statements in execution order.
224
+ */
225
+ function buildMinorUnitsStatements(engine, table, column, scale) {
226
+ const quotedTable = quoteIdentifier(table);
227
+ const quotedColumn = quoteIdentifier(column);
228
+ if (engine === "postgres" || engine === "duckdb") {
229
+ const scaled = engine === "postgres" ? `round((${quotedColumn})::numeric * ${scale})` : `round(${quotedColumn} * ${scale})`;
230
+ return [
231
+ `ALTER TABLE ${quotedTable} ALTER COLUMN ${quotedColumn} DROP DEFAULT`,
232
+ `ALTER TABLE ${quotedTable} ALTER COLUMN ${quotedColumn} TYPE INTEGER USING ${scaled}`,
233
+ `ALTER TABLE ${quotedTable} ALTER COLUMN ${quotedColumn} SET DEFAULT 0`
234
+ ];
235
+ }
236
+ return [`UPDATE ${quotedTable} SET ${quotedColumn} = CAST(round(${quotedColumn} * ${scale}) AS INTEGER) WHERE ${quotedColumn} IS NOT NULL`];
237
+ }
238
+ /**
239
+ * Report what {@link rescaleMoneyColumnsToMinorUnits} would do, without
240
+ * writing anything.
241
+ *
242
+ * @param db - Root database handle.
243
+ * @param targets - Tables and money columns owned by the calling package.
244
+ * @param options - Scale, engine hint, problem-row cap.
245
+ * @returns Per-column state plus the rows that would lose information.
246
+ */
247
+ async function preflightMinorUnitsRescale(db, targets, options = {}) {
248
+ const scale = options.scale ?? DEFAULT_SCALE;
249
+ if (!Number.isInteger(scale) || scale <= 0) throw new Error(`Money minor-units rescale: scale must be a positive integer (got ${scale}).`);
250
+ const engine = resolveEngine(db, options.engineHint);
251
+ const maxProblemRows = options.maxProblemRows ?? 20;
252
+ for (const target of targets) {
253
+ quoteIdentifier(target.table);
254
+ for (const column of target.columns) quoteIdentifier(column);
255
+ }
256
+ const columns = [];
257
+ for (const target of targets) {
258
+ const types = await readColumnTypes(db, engine, target.table);
259
+ const withId = hasIdColumn(types);
260
+ for (const column of target.columns) {
261
+ const declaredType = types.get(column) ?? null;
262
+ if (declaredType === null) {
263
+ columns.push({
264
+ table: target.table,
265
+ column,
266
+ state: "missing",
267
+ declaredType: null,
268
+ inspectedRows: 0,
269
+ nonIntegral: [],
270
+ overflow: []
271
+ });
272
+ continue;
273
+ }
274
+ if (isIntegerType(declaredType) || !isFloatingType(declaredType)) {
275
+ columns.push({
276
+ table: target.table,
277
+ column,
278
+ state: "converted",
279
+ declaredType,
280
+ inspectedRows: 0,
281
+ nonIntegral: [],
282
+ overflow: []
283
+ });
284
+ continue;
285
+ }
286
+ const inspected = await inspectColumn(db, target.table, column, scale, maxProblemRows, withId);
287
+ columns.push({
288
+ table: target.table,
289
+ column,
290
+ state: "pending",
291
+ declaredType,
292
+ ...inspected
293
+ });
294
+ }
295
+ }
296
+ const base = {
297
+ engine,
298
+ scale,
299
+ columns,
300
+ pendingColumns: columns.filter((c) => c.state === "pending").length,
301
+ convertedColumns: columns.filter((c) => c.state === "converted").length,
302
+ missingColumns: columns.filter((c) => c.state === "missing").length,
303
+ nonIntegralRows: columns.reduce((sum, c) => sum + c.nonIntegral.length, 0),
304
+ overflowRows: columns.reduce((sum, c) => sum + c.overflow.length, 0)
305
+ };
306
+ const ok = base.nonIntegralRows === 0 && base.overflowRows === 0;
307
+ return {
308
+ ...base,
309
+ ok,
310
+ summary: renderSummary({
311
+ ...base,
312
+ ok
313
+ })
314
+ };
315
+ }
316
+ /**
317
+ * Convert floating-point major-unit money columns to integer minor units.
318
+ *
319
+ * Idempotent: the run is wrapped in a {@link BackfillTracker} marker, so a
320
+ * second call is a no-op even on SQLite where the column type cannot record
321
+ * that the conversion already happened.
322
+ *
323
+ * @param db - Root database handle (must support `query`).
324
+ * @param targets - Tables and money columns owned by the calling package.
325
+ * @param options - Marker name, scale, and whether to convert despite
326
+ * information-losing rows.
327
+ * @throws {MinorUnitsPreflightError} when the preflight found rows that would
328
+ * be rounded or overflow and `force` was not set.
329
+ */
330
+ async function rescaleMoneyColumnsToMinorUnits(db, targets, options) {
331
+ const scale = options.scale ?? DEFAULT_SCALE;
332
+ const engine = resolveEngine(db, options.engineHint);
333
+ const tracker = new BackfillTracker({ db });
334
+ if (await tracker.isApplied(options.backfillName)) return {
335
+ ran: false,
336
+ engine,
337
+ scale,
338
+ preflight: await preflightMinorUnitsRescale(db, targets, options),
339
+ rescaledColumns: [],
340
+ statements: [],
341
+ declaredTypeChangePending: []
342
+ };
343
+ const preflight = await preflightMinorUnitsRescale(db, targets, options);
344
+ if (!preflight.ok && !options.force) throw new MinorUnitsPreflightError(preflight);
345
+ const statements = [];
346
+ const rescaledColumns = [];
347
+ const declaredTypeChangePending = [];
348
+ const altersColumnType = engine === "postgres" || engine === "duckdb";
349
+ for (const report of preflight.columns) {
350
+ if (report.state !== "pending") continue;
351
+ for (const sql of buildMinorUnitsStatements(engine, report.table, report.column, scale)) {
352
+ await db.query(sql);
353
+ statements.push(sql);
354
+ }
355
+ if (!altersColumnType) declaredTypeChangePending.push({
356
+ table: report.table,
357
+ column: report.column
358
+ });
359
+ rescaledColumns.push({
360
+ table: report.table,
361
+ column: report.column
362
+ });
363
+ }
364
+ await tracker.recordApplied(options.backfillName, {
365
+ description: `Money columns rescaled from major units to integer minor units (scale ${scale}).`,
366
+ packageName: options.packageName
367
+ });
368
+ return {
369
+ ran: true,
370
+ engine,
371
+ scale,
372
+ preflight,
373
+ rescaledColumns,
374
+ statements,
375
+ declaredTypeChangePending
376
+ };
377
+ }
378
+ //#endregion
379
+ export { MinorUnitsPreflightError, buildMinorUnitsStatements, preflightMinorUnitsRescale, rescaleMoneyColumnsToMinorUnits };
380
+
381
+ //# sourceMappingURL=minor-units.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"minor-units.js","names":[],"sources":["../../src/migrations/minor-units.ts"],"sourcesContent":["/**\n * Money-column rescale: floating-point major units → integer minor units (#2401).\n *\n * SMRT's rule is that money is exact and is stored as **integer minor units**\n * (cents, satoshis) — `$19.99` is `1999`. Packages that predate the rule\n * declared their money fields `= 0.0`, which compiles to a floating-point\n * column holding *major* units. Flipping the declaration to `= 0` changes the\n * column type, and REAL→INTEGER is deliberately **not** one of the differ's\n * whitelisted upgrades: an automatic `ALTER … TYPE integer` would truncate\n * `19.99` to `19`, silently destroying 99 cents on every row.\n *\n * So the conversion is an explicit, opt-in migration with two halves:\n *\n * 1. {@link preflightMinorUnitsRescale} — read-only. Reports, per column,\n * whether it still needs converting, and lists the rows that would lose\n * information: values whose scaled form is not a whole number (a half-cent\n * that has to be rounded away) and values that overflow PostgreSQL's `int4`\n * once multiplied by the scale. Run it against a production snapshot and\n * read the summary before converting anything.\n * 2. {@link rescaleMoneyColumnsToMinorUnits} — the conversion itself, guarded\n * by a {@link BackfillTracker} marker so re-running it can never multiply a\n * table by 100 twice.\n *\n * Per-engine behaviour:\n *\n * | Engine | Column type | Values |\n * | --- | --- | --- |\n * | PostgreSQL | `ALTER COLUMN … TYPE integer USING round(col * scale)` | converted by the cast |\n * | DuckDB | `ALTER COLUMN … TYPE INTEGER USING round(col * scale)` | converted by the cast |\n * | SQLite | **not changed here** — SQLite cannot alter a column's declared type in place | rescaled with an `UPDATE`, values become exact integers |\n *\n * SQLite's declared type only sets an affinity, so a rescaled column already\n * behaves as integer minor units; bringing the *declaration* into line needs\n * the table-rebuild path (#2370). The result flags that with\n * {@link MinorUnitsRescaleResult.declaredTypeChangePending} rather than\n * pretending the column type moved.\n */\n\nimport { createLogger } from '@happyvertical/logger';\nimport { detectEngine } from '../schema/ddl/index.js';\nimport { BackfillTracker } from './backfill-tracker.js';\nimport type { DatabaseInterface } from './types.js';\n\nconst logger = createLogger({ level: 'info' });\n\n/** PostgreSQL `int4` bounds — the ceiling this conversion has to respect. */\nconst INT4_MIN = -2147483648;\nconst INT4_MAX = 2147483647;\n\n/**\n * How far a scaled value may sit from a whole number and still count as\n * integral.\n *\n * `19.99 * 100` is `1998.9999999999998` in IEEE-754, so a zero tolerance would\n * report every ordinary two-decimal price as non-integral. A genuine half-cent\n * is off by `0.5`, five orders of magnitude above this bound, so the two cases\n * never blur.\n */\nconst INTEGRALITY_TOLERANCE = 1e-6;\n\n/** Default minor units per major unit — cents. */\nconst DEFAULT_SCALE = 100;\n\n/**\n * Round half away from zero, the way SQL `round()` does — and the way\n * `Math.round` does not.\n *\n * `Math.round` rounds halves toward +∞, so `Math.round(-0.5)` is `-0` while\n * SQLite's and DuckDB's `round(-0.5)` is `-1`. Money here can legitimately be\n * negative (`BillingAdjustment.amount` is a signed credit), so a preflight that\n * predicted the conversion with `Math.round` would report a value one minor\n * unit above what the database actually stores. The PostgreSQL DDL casts to\n * `numeric` for the same reason — see {@link buildMinorUnitsStatements}.\n */\nfunction roundHalfAwayFromZero(value: number): number {\n return Math.sign(value) * Math.round(Math.abs(value));\n}\n\n/** A table and the money columns on it that must move to minor units. */\nexport interface MoneyColumnTarget {\n /** Physical table name, e.g. `payments`. */\n table: string;\n /** Physical column names, e.g. `['amount', 'native_amount']`. */\n columns: string[];\n}\n\n/** What the preflight found about one column. */\nexport type MinorUnitsColumnState =\n /** Floating-point today: needs converting. */\n | 'pending'\n /** Already an integer column: nothing to do. */\n | 'converted'\n /** Table or column absent (package not installed, older schema). */\n | 'missing';\n\n/** One row that cannot be converted without losing information. */\nexport interface MinorUnitsProblemRow {\n /** Primary key, when the table has an `id` column. */\n id: string | null;\n /** The stored major-unit value. */\n value: number;\n /** `value * scale`, before rounding. */\n scaled: number;\n}\n\n/** Preflight findings for a single column. */\nexport interface MinorUnitsColumnReport {\n table: string;\n column: string;\n state: MinorUnitsColumnState;\n /** Declared column type as the database reports it, when resolvable. */\n declaredType: string | null;\n /** Non-null values inspected. `0` unless `state` is `pending`. */\n inspectedRows: number;\n /** Values whose scaled form is not a whole number — rounding loses money. */\n nonIntegral: MinorUnitsProblemRow[];\n /** Values whose scaled form falls outside PostgreSQL `int4`. */\n overflow: MinorUnitsProblemRow[];\n}\n\n/** Result of {@link preflightMinorUnitsRescale}. */\nexport interface MinorUnitsPreflightResult {\n engine: string;\n scale: number;\n columns: MinorUnitsColumnReport[];\n /** Columns still holding floating-point major units. */\n pendingColumns: number;\n /** Columns already integer. */\n convertedColumns: number;\n /** Columns whose table/column does not exist. */\n missingColumns: number;\n /** Total rows that would be rounded. */\n nonIntegralRows: number;\n /** Total rows that would overflow `int4`. */\n overflowRows: number;\n /** No row would lose information. */\n ok: boolean;\n /** Human-readable report, suitable for pasting into a migration log or PR. */\n summary: string;\n}\n\n/** Options shared by the preflight and the rescale. */\nexport interface MinorUnitsOptions {\n /** Minor units per major unit. Default `100` (cents). */\n scale?: number;\n /** Engine hint for adapters whose URL does not identify the engine. */\n engineHint?: string;\n /** Problem rows recorded per column before truncating. Default `20`. */\n maxProblemRows?: number;\n}\n\n/** Options for {@link rescaleMoneyColumnsToMinorUnits}. */\nexport interface MinorUnitsRescaleOptions extends MinorUnitsOptions {\n /**\n * Stable marker name recorded in `_smrt_backfills`, e.g.\n * `@happyvertical/smrt-commerce:money-minor-units:v1`.\n *\n * This is what makes the migration idempotent on SQLite, where the rescale\n * is an `UPDATE` no column type can distinguish from an unconverted table.\n */\n backfillName: string;\n /** Package recorded alongside the marker. */\n packageName?: string;\n /**\n * Convert even when the preflight found rows that would be rounded or\n * overflow. Off by default: silently rounding a half-cent away is exactly\n * the failure this migration exists to prevent.\n */\n force?: boolean;\n}\n\n/** Result of {@link rescaleMoneyColumnsToMinorUnits}. */\nexport interface MinorUnitsRescaleResult {\n /** `false` when the backfill marker was already present. */\n ran: boolean;\n engine: string;\n scale: number;\n /** The preflight that gated the run. */\n preflight: MinorUnitsPreflightResult;\n /** Columns whose values were rescaled. */\n rescaledColumns: Array<{ table: string; column: string }>;\n /** Statements executed, in order — useful for logs and tests. */\n statements: string[];\n /**\n * Columns whose values are now minor units but whose *declared* type is\n * still floating-point. Only ever populated on SQLite, which cannot alter a\n * column type in place; clearing it needs the table-rebuild path (#2370).\n */\n declaredTypeChangePending: Array<{ table: string; column: string }>;\n}\n\n/**\n * A money-losing preflight finding blocked the conversion.\n *\n * Carries the full preflight so a caller can print the summary, fix the data,\n * and retry rather than re-deriving what went wrong.\n */\nexport class MinorUnitsPreflightError extends Error {\n constructor(readonly preflight: MinorUnitsPreflightResult) {\n super(\n `Money minor-units rescale refused: ${preflight.nonIntegralRows} row(s) ` +\n `would be rounded and ${preflight.overflowRows} row(s) would overflow ` +\n 'int4. Fix the data or pass `force: true` to accept the rounding.\\n' +\n preflight.summary,\n );\n this.name = 'MinorUnitsPreflightError';\n }\n}\n\nfunction resolveDatabaseUrl(db: DatabaseInterface): string {\n const dbWithConfig = db as DatabaseInterface & { config?: { url?: string } };\n return db.url || dbWithConfig.config?.url || '';\n}\n\nfunction resolveEngine(db: DatabaseInterface, engineHint?: string): string {\n return typeof (db as { exportTable?: unknown }).exportTable === 'function'\n ? 'json'\n : detectEngine(resolveDatabaseUrl(db), engineHint);\n}\n\n/**\n * Quote an identifier for DDL.\n *\n * Table and column names here come from a package's own migration module, not\n * from user input, but a rejected embedded quote is cheaper than trusting that\n * forever.\n */\nfunction quoteIdentifier(name: string): string {\n if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {\n throw new Error(\n `Money minor-units rescale: '${name}' is not a plain SQL identifier.`,\n );\n }\n return `\"${name}\"`;\n}\n\n/** Is this declared type a floating-point one that still needs converting? */\nfunction isFloatingType(declaredType: string): boolean {\n return /^(REAL|FLOAT|DOUBLE|DECIMAL|NUMERIC|NUMBER)/i.test(\n declaredType.trim(),\n );\n}\n\n/** Is this declared type already an integer one? */\nfunction isIntegerType(declaredType: string): boolean {\n return /^(INTEGER|INT|INT4|INT8|BIGINT|SMALLINT|TINYINT|HUGEINT)\\b/i.test(\n declaredType.trim(),\n );\n}\n\n/**\n * Read declared column types for one table.\n *\n * PostgreSQL and DuckDB both expose `information_schema.columns`; SQLite does\n * not, so it uses `PRAGMA table_info`. Returns an empty map when the table does\n * not exist, which the caller reports as `missing` rather than an error — a\n * package's migration lists its own tables, and a deployment may legitimately\n * not have created all of them yet.\n */\nasync function readColumnTypes(\n db: DatabaseInterface,\n engine: string,\n table: string,\n): Promise<Map<string, string>> {\n const types = new Map<string, string>();\n try {\n if (engine === 'postgres' || engine === 'duckdb') {\n const result = await db.query(\n 'SELECT column_name, data_type FROM information_schema.columns WHERE table_name = ?',\n table,\n );\n for (const row of result.rows as {\n column_name?: string;\n data_type?: string;\n }[]) {\n if (row.column_name) types.set(row.column_name, row.data_type ?? '');\n }\n return types;\n }\n // SQLite. `PRAGMA table_info` does not accept a bound parameter, and the\n // identifier has already been validated by `quoteIdentifier`.\n const result = await db.query(\n `PRAGMA table_info(${quoteIdentifier(table)})`,\n );\n for (const row of result.rows as { name?: string; type?: string }[]) {\n if (row.name) types.set(row.name, row.type ?? '');\n }\n } catch (error) {\n logger.debug(\n `[minor-units] Could not introspect ${table}; treating its columns as missing`,\n { error: error instanceof Error ? error.message : String(error) },\n );\n }\n return types;\n}\n\n/** Does this table expose an `id` column we can name in problem rows? */\nfunction hasIdColumn(types: Map<string, string>): boolean {\n return types.has('id');\n}\n\nfunction toFiniteNumber(value: unknown): number | null {\n if (value == null) return null;\n const parsed = typeof value === 'number' ? value : Number(value);\n return Number.isFinite(parsed) ? parsed : null;\n}\n\n/**\n * Inspect every non-null value in one floating-point money column.\n *\n * Rows are read rather than aggregated in SQL because the integrality test has\n * to survive three dialects' differing `round`/modulo semantics, and a money\n * table small enough to be worth converting by hand is small enough to scan.\n */\nasync function inspectColumn(\n db: DatabaseInterface,\n table: string,\n column: string,\n scale: number,\n maxProblemRows: number,\n withId: boolean,\n): Promise<{\n inspectedRows: number;\n nonIntegral: MinorUnitsProblemRow[];\n overflow: MinorUnitsProblemRow[];\n}> {\n const quotedTable = quoteIdentifier(table);\n const quotedColumn = quoteIdentifier(column);\n const selection = withId\n ? `\"id\", ${quotedColumn} AS value`\n : `${quotedColumn} AS value`;\n const result = await db.query(\n `SELECT ${selection} FROM ${quotedTable} WHERE ${quotedColumn} IS NOT NULL`,\n );\n\n const nonIntegral: MinorUnitsProblemRow[] = [];\n const overflow: MinorUnitsProblemRow[] = [];\n let inspectedRows = 0;\n\n for (const row of result.rows as { id?: unknown; value?: unknown }[]) {\n const value = toFiniteNumber(row.value);\n if (value === null) continue;\n inspectedRows += 1;\n const scaled = value * scale;\n const rounded = roundHalfAwayFromZero(scaled);\n const id = row.id == null ? null : String(row.id);\n if (Math.abs(scaled - rounded) > INTEGRALITY_TOLERANCE) {\n if (nonIntegral.length < maxProblemRows) {\n nonIntegral.push({ id, value, scaled });\n }\n }\n if (rounded > INT4_MAX || rounded < INT4_MIN) {\n if (overflow.length < maxProblemRows) {\n overflow.push({ id, value, scaled });\n }\n }\n }\n\n return { inspectedRows, nonIntegral, overflow };\n}\n\nfunction renderSummary(result: Omit<MinorUnitsPreflightResult, 'summary'>) {\n const lines: string[] = [\n `Money minor-units preflight (engine=${result.engine}, scale=${result.scale})`,\n ` columns: ${result.pendingColumns} pending, ${result.convertedColumns} already integer, ${result.missingColumns} missing`,\n ` rows that would be rounded: ${result.nonIntegralRows}`,\n ` rows that would overflow int4: ${result.overflowRows}`,\n ];\n for (const column of result.columns) {\n const label = `${column.table}.${column.column}`;\n if (column.state === 'missing') {\n lines.push(` - ${label}: missing`);\n continue;\n }\n if (column.state === 'converted') {\n lines.push(\n ` - ${label}: already integer (${column.declaredType ?? 'unknown'})`,\n );\n continue;\n }\n lines.push(\n ` - ${label}: pending (${column.declaredType ?? 'unknown'}), ` +\n `${column.inspectedRows} row(s) inspected, ` +\n `${column.nonIntegral.length} non-integral, ${column.overflow.length} overflow`,\n );\n for (const row of column.nonIntegral) {\n lines.push(\n ` non-integral id=${row.id ?? '<no id>'} value=${row.value} scaled=${row.scaled}`,\n );\n }\n for (const row of column.overflow) {\n lines.push(\n ` overflow id=${row.id ?? '<no id>'} value=${row.value} scaled=${row.scaled}`,\n );\n }\n }\n return lines.join('\\n');\n}\n\n/**\n * The statements that convert one column, for one engine.\n *\n * Exported so the emitted DDL can be asserted without a live PostgreSQL or\n * DuckDB connection; each owning package's `test:postgres` lane then proves the\n * same SQL runs against a real server.\n *\n * @param engine - `postgres`, `duckdb`, or anything SQLite-shaped.\n * @param table - Physical table name.\n * @param column - Physical column name.\n * @param scale - Minor units per major unit.\n * @returns Statements in execution order.\n */\nexport function buildMinorUnitsStatements(\n engine: string,\n table: string,\n column: string,\n scale: number,\n): string[] {\n const quotedTable = quoteIdentifier(table);\n const quotedColumn = quoteIdentifier(column);\n if (engine === 'postgres' || engine === 'duckdb') {\n // PostgreSQL's `round(double precision)` is banker's rounding — it breaks\n // ties to even, so `round(-0.5)` is `-0` and `round(2.5)` is `2`. Casting\n // to `numeric` first selects the half-away-from-zero overload, which is\n // what SQLite and DuckDB do and what the preflight predicts. Money can be\n // negative here (a billing credit), so the tie-break is not academic.\n const scaled =\n engine === 'postgres'\n ? `round((${quotedColumn})::numeric * ${scale})`\n : `round(${quotedColumn} * ${scale})`;\n // A `0.0` DEFAULT is not assignable to an integer column on PostgreSQL, so\n // it is dropped before the type change and restored after it. `0` is the\n // framework default for every money field.\n return [\n `ALTER TABLE ${quotedTable} ALTER COLUMN ${quotedColumn} DROP DEFAULT`,\n `ALTER TABLE ${quotedTable} ALTER COLUMN ${quotedColumn} TYPE INTEGER ` +\n `USING ${scaled}`,\n `ALTER TABLE ${quotedTable} ALTER COLUMN ${quotedColumn} SET DEFAULT 0`,\n ];\n }\n // SQLite: the declared type is only an affinity and cannot be altered in\n // place, so rescale the values and leave the declaration to the table-rebuild\n // path (#2370).\n return [\n `UPDATE ${quotedTable} SET ${quotedColumn} = CAST(round(${quotedColumn} * ${scale}) AS INTEGER) ` +\n `WHERE ${quotedColumn} IS NOT NULL`,\n ];\n}\n\n/**\n * Report what {@link rescaleMoneyColumnsToMinorUnits} would do, without\n * writing anything.\n *\n * @param db - Root database handle.\n * @param targets - Tables and money columns owned by the calling package.\n * @param options - Scale, engine hint, problem-row cap.\n * @returns Per-column state plus the rows that would lose information.\n */\nexport async function preflightMinorUnitsRescale(\n db: DatabaseInterface,\n targets: MoneyColumnTarget[],\n options: MinorUnitsOptions = {},\n): Promise<MinorUnitsPreflightResult> {\n const scale = options.scale ?? DEFAULT_SCALE;\n if (!Number.isInteger(scale) || scale <= 0) {\n throw new Error(\n `Money minor-units rescale: scale must be a positive integer (got ${scale}).`,\n );\n }\n const engine = resolveEngine(db, options.engineHint);\n const maxProblemRows = options.maxProblemRows ?? 20;\n\n // Validate every identifier up front. Introspection swallows errors so an\n // absent table reads as `missing`, and a malformed name must not disappear\n // down that same path — it is a bug in the caller's migration module.\n for (const target of targets) {\n quoteIdentifier(target.table);\n for (const column of target.columns) quoteIdentifier(column);\n }\n\n const columns: MinorUnitsColumnReport[] = [];\n for (const target of targets) {\n const types = await readColumnTypes(db, engine, target.table);\n const withId = hasIdColumn(types);\n for (const column of target.columns) {\n const declaredType = types.get(column) ?? null;\n if (declaredType === null) {\n columns.push({\n table: target.table,\n column,\n state: 'missing',\n declaredType: null,\n inspectedRows: 0,\n nonIntegral: [],\n overflow: [],\n });\n continue;\n }\n if (isIntegerType(declaredType) || !isFloatingType(declaredType)) {\n // Anything that is not floating-point is either already converted or a\n // type this migration has no business rewriting.\n columns.push({\n table: target.table,\n column,\n state: 'converted',\n declaredType,\n inspectedRows: 0,\n nonIntegral: [],\n overflow: [],\n });\n continue;\n }\n const inspected = await inspectColumn(\n db,\n target.table,\n column,\n scale,\n maxProblemRows,\n withId,\n );\n columns.push({\n table: target.table,\n column,\n state: 'pending',\n declaredType,\n ...inspected,\n });\n }\n }\n\n const base = {\n engine,\n scale,\n columns,\n pendingColumns: columns.filter((c) => c.state === 'pending').length,\n convertedColumns: columns.filter((c) => c.state === 'converted').length,\n missingColumns: columns.filter((c) => c.state === 'missing').length,\n nonIntegralRows: columns.reduce((sum, c) => sum + c.nonIntegral.length, 0),\n overflowRows: columns.reduce((sum, c) => sum + c.overflow.length, 0),\n };\n const ok = base.nonIntegralRows === 0 && base.overflowRows === 0;\n return { ...base, ok, summary: renderSummary({ ...base, ok }) };\n}\n\n/**\n * Convert floating-point major-unit money columns to integer minor units.\n *\n * Idempotent: the run is wrapped in a {@link BackfillTracker} marker, so a\n * second call is a no-op even on SQLite where the column type cannot record\n * that the conversion already happened.\n *\n * @param db - Root database handle (must support `query`).\n * @param targets - Tables and money columns owned by the calling package.\n * @param options - Marker name, scale, and whether to convert despite\n * information-losing rows.\n * @throws {MinorUnitsPreflightError} when the preflight found rows that would\n * be rounded or overflow and `force` was not set.\n */\nexport async function rescaleMoneyColumnsToMinorUnits(\n db: DatabaseInterface,\n targets: MoneyColumnTarget[],\n options: MinorUnitsRescaleOptions,\n): Promise<MinorUnitsRescaleResult> {\n const scale = options.scale ?? DEFAULT_SCALE;\n const engine = resolveEngine(db, options.engineHint);\n const tracker = new BackfillTracker({ db });\n\n if (await tracker.isApplied(options.backfillName)) {\n return {\n ran: false,\n engine,\n scale,\n preflight: await preflightMinorUnitsRescale(db, targets, options),\n rescaledColumns: [],\n statements: [],\n declaredTypeChangePending: [],\n };\n }\n\n const preflight = await preflightMinorUnitsRescale(db, targets, options);\n if (!preflight.ok && !options.force) {\n throw new MinorUnitsPreflightError(preflight);\n }\n\n const statements: string[] = [];\n const rescaledColumns: Array<{ table: string; column: string }> = [];\n const declaredTypeChangePending: Array<{ table: string; column: string }> =\n [];\n\n const altersColumnType = engine === 'postgres' || engine === 'duckdb';\n for (const report of preflight.columns) {\n if (report.state !== 'pending') continue;\n for (const sql of buildMinorUnitsStatements(\n engine,\n report.table,\n report.column,\n scale,\n )) {\n await db.query(sql);\n statements.push(sql);\n }\n if (!altersColumnType) {\n declaredTypeChangePending.push({\n table: report.table,\n column: report.column,\n });\n }\n rescaledColumns.push({ table: report.table, column: report.column });\n }\n\n await tracker.recordApplied(options.backfillName, {\n description:\n `Money columns rescaled from major units to integer minor units ` +\n `(scale ${scale}).`,\n packageName: options.packageName,\n });\n\n return {\n ran: true,\n engine,\n scale,\n preflight,\n rescaledColumns,\n statements,\n declaredTypeChangePending,\n };\n}\n"],"mappings":"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"}
@@ -164,12 +164,37 @@ function isCommentOnlySql(sql) {
164
164
  return lines.every((line) => line.startsWith("--"));
165
165
  }
166
166
  /**
167
+ * True if `sql` is an advisory comment that says *nothing needs to happen* —
168
+ * as opposed to one that says a human has to act.
169
+ *
170
+ * The differ emits both kinds. "This SQLite column already stores JSON as
171
+ * TEXT" and "#2370: this column is rebuilt together with its sibling" are
172
+ * no-ops; "requires table recreation" / "blocked: …" are real manual drift.
173
+ * Treating the first kind as drift makes a fully auto-repairable migration
174
+ * report `hasManualDrift: true`.
175
+ *
176
+ * Keep in sync with `classifyTypeUpgradeSql`'s `noop` arm in
177
+ * `@happyvertical/smrt-cli` (`src/commands/db-migrate-actions.ts`), which
178
+ * applies the same rule on the CLI side.
179
+ */
180
+ function isNoOpAdvisorySql(sql) {
181
+ const trimmed = sql.trim();
182
+ return /no change needed/i.test(trimmed) || /already stores .* as /i.test(trimmed);
183
+ }
184
+ /**
167
185
  * Surface changes the differ produced but that the migrator cannot apply
168
- * automatically — either `type_mismatch` entries (the differ explicitly
169
- * gives up on these) or `type_upgrade` entries whose generated SQL is
170
- * advisory-comment-only (SQLite table-recreation cases, etc.). Callers
171
- * use this to distinguish "schema is in sync" from "schema is drifted but
172
- * we can't fix it from here."
186
+ * automatically — `type_mismatch` entries (the differ explicitly gives up on
187
+ * these), `type_upgrade`/`alter_column` entries whose generated SQL is
188
+ * advisory-comment-only (a SQLite rebuild that would be unsafe, an in-place
189
+ * constraint change SQLite cannot perform), and warning-level report-only
190
+ * advisories (an orphan NOT NULL column that will break inserts, a required
191
+ * column that could not be enforced, a stale unique constraint, a relaxation
192
+ * the caller has not opted into). Info-level advisories (a harmless orphan
193
+ * column, a stale default) and comments that mean "already handled" (the
194
+ * no-op advisories a table rebuild leaves on covered siblings, #2370) are
195
+ * deliberately excluded so `hasManualDrift` keeps meaning "something needs
196
+ * an operator". Callers use this to distinguish "schema is in sync" from
197
+ * "schema is drifted but we can't fix it from here".
173
198
  */
174
199
  function collectUnactionableChanges(diff) {
175
200
  const unactionable = [];
@@ -179,7 +204,11 @@ function collectUnactionableChanges(diff) {
179
204
  continue;
180
205
  }
181
206
  const statements = change.sqlStatements ?? (change.sql ? [change.sql] : []);
182
- if (statements.length > 0 && statements.every((stmt) => isCommentOnlySql(stmt.trim()))) unactionable.push(change);
207
+ if (statements.length === 0) {
208
+ if (change.advisory?.severity === "warning") unactionable.push(change);
209
+ continue;
210
+ }
211
+ if (statements.every((stmt) => isCommentOnlySql(stmt.trim())) && !statements.every((stmt) => isNoOpAdvisorySql(stmt))) unactionable.push(change);
183
212
  }
184
213
  return unactionable;
185
214
  }
@@ -1 +1 @@
1
- {"version":3,"file":"orchestrate.js","names":[],"sources":["../../src/migrations/orchestrate.ts"],"sourcesContent":["/**\n * High-level \"make my SMRT app's schema match its database\" orchestration.\n *\n * Wraps `ObjectRegistry.getAllSchemasAsDefinitions()` → `generateSchemaDiff`\n * → `MigrationTracker.applyAll`. Apps that just want to run pending\n * schema changes call `migrateSmrtSchemas({ db, packageName, version })`\n * and don't have to assemble the migration definition by hand.\n *\n * The migration name is timestamped, so re-running on an up-to-date\n * database is a no-op (the diff is empty, no statements are generated,\n * `applied: false` is returned).\n */\nimport type { DatabaseInterface } from '@happyvertical/sql';\nimport { ObjectRegistry } from '../registry.js';\nimport { detectEngine, getDDLStrategy } from '../schema/ddl/index.js';\nimport type { DatabaseEngine } from '../schema/ddl/types.js';\nimport type { MigrationResult, SchemaChange } from '../schema/types.js';\nimport {\n generateSchemaDiff,\n getSQLFromDiff,\n hasActionableChanges,\n} from './differ.js';\nimport {\n createMigrationDefinition,\n generateMigrationTimestamp,\n} from './generator.js';\nimport { MigrationTracker } from './tracker.js';\n\nexport interface MigrateSmrtSchemasOptions {\n db: DatabaseInterface;\n /** Stored on the migration record so the audit log shows which app applied it. */\n packageName: string;\n /**\n * Stored on the migration record. Typically the app's package.json\n * version. Optional because most apps don't have a runtime-readable\n * version handy and would hardcode a stale string if forced to supply\n * one — `'unknown'` is honest. Pass an explicit value when you can\n * (e.g. `version: process.env.APP_VERSION`).\n */\n version?: string;\n /** Override the description on the synthetic migration definition. */\n description?: string;\n /** Override the migration name (default: `<timestamp>_smrt_schema_sync`). */\n name?: string;\n /** Forwarded to MigrationTracker — defaults to true for pg-safe runs. */\n postgresSafe?: boolean;\n /** Forwarded to MigrationTracker — defaults to true. */\n reconcile?: boolean;\n /** Forwarded to MigrationTracker — defaults to true for `CREATE INDEX CONCURRENTLY`. */\n useConcurrentIndexes?: boolean;\n /**\n * Forwarded to MigrationTracker (Postgres lock timeout, milliseconds).\n * Lets apps that customize tracker timeouts directly preserve their\n * tuning when going through the orchestrator. Falls back to the\n * tracker's default (30000ms) when omitted.\n */\n lockTimeout?: number;\n /**\n * Forwarded to MigrationTracker (Postgres statement timeout, ms). Falls\n * back to the tracker's default (60000ms) when omitted.\n */\n statementTimeout?: number;\n /**\n * Explicit engine hint forwarded to `detectEngine`, `SchemaComparer`, and\n * `MigrationTracker`. Useful when `db.url` doesn't unambiguously identify\n * the engine (e.g. the JSON adapter, which uses DuckDB internally and may\n * not be recognizable by URL alone). All three layers honor the same hint\n * so the generated DDL, drift comparison, and execution path stay\n * consistent — without it, an empty-URL connection could produce\n * Postgres-flavored DDL but run through the SQLite tracker path.\n *\n * Typed as `DatabaseEngine` so unsupported values (e.g. `'mysql'`) fail at\n * compile time rather than silently falling back to `'sqlite'` at runtime.\n */\n engineHint?: DatabaseEngine;\n /**\n * Confirm that historical PostgreSQL Date writers used UTC wall times before\n * reinterpreting legacy TIMESTAMP/TEXT/JSON columns as TIMESTAMPTZ.\n */\n postgresTimestampMigration?: { legacyTimezone: 'UTC' };\n}\n\nexport interface MigrateSmrtSchemasResult {\n applied: boolean;\n results: MigrationResult[];\n statements: string[];\n schemaCount: number;\n /**\n * Schema changes the orchestrator detected but cannot apply automatically\n * (incompatible type mismatches, SQLite type upgrades that require table\n * recreation, etc.). Empty when the schema is fully reconciled. Inspect\n * this to surface a \"manual migration required\" warning in CLI output.\n */\n unactionableChanges: SchemaChange[];\n /**\n * Convenience: `unactionableChanges.length > 0`. Lets a caller write\n * `if (result.hasManualDrift) ...` without iterating the array first,\n * and makes the case visible to anyone scanning the result shape (the\n * full array is easy to gloss over when `applied: false`).\n */\n hasManualDrift: boolean;\n}\n\nexport interface PendingSchemaStatementsResult {\n diff: Awaited<ReturnType<typeof generateSchemaDiff>>;\n statements: string[];\n schemaCount: number;\n hasChanges: boolean;\n /**\n * Schema changes the differ detected but that have no executable SQL\n * (incompatible `type_mismatch` entries, or `type_upgrade` entries whose\n * only generated SQL is an advisory comment because the engine cannot\n * upgrade the column in place — e.g. SQLite type widening). Surfacing\n * these lets status commands distinguish \"no drift\" from \"drift the\n * orchestrator can't fix on its own.\"\n */\n unactionableChanges: SchemaChange[];\n /**\n * Convenience: `unactionableChanges.length > 0`. Mirrors the\n * `MigrateSmrtSchemasResult.hasManualDrift` shortcut.\n */\n hasManualDrift: boolean;\n}\n\n/**\n * Compute the SQL statements needed to bring `db` in sync with the\n * currently-registered SMRT object schemas. Pure inspection — no writes.\n *\n * Useful for status commands (\"how far behind is the schema?\") and as the\n * inner step of `migrateSmrtSchemas`.\n *\n * **Note on the returned `statements`:** these are the engine-correct DDL\n * statements as the differ + DDL strategy produce them, *prior to* any\n * Postgres-specific rewrites the `MigrationTracker` applies at execution\n * time (e.g. rewriting `CREATE INDEX` → `CREATE INDEX CONCURRENTLY` and\n * moving CONCURRENTLY statements outside the surrounding transaction).\n * The list is suitable for preview/status/\"what would change?\" use cases.\n * For the exact byte-for-byte SQL the tracker ran, inspect the\n * `MigrationResult`s from `migrateSmrtSchemas` or the tracker's own logs.\n */\nexport async function getPendingSchemaStatements(\n db: DatabaseInterface,\n options: {\n engineHint?: DatabaseEngine;\n postgresTimestampMigration?: { legacyTimezone: 'UTC' };\n } = {},\n): Promise<PendingSchemaStatementsResult> {\n const schemas = ObjectRegistry.getAllSchemasAsDefinitions();\n // Forward engineHint into the diff itself so the SchemaComparer's\n // existing-table SQL (ALTER/index drift) uses the same DDL strategy\n // we use for newly-added tables — otherwise the two halves of the\n // statement list can land on different engines (split-brain) when\n // `db.url` is empty or ambiguous.\n const diff = await generateSchemaDiff(db, schemas, {\n engineHint: options.engineHint,\n postgresTimestampMigration: options.postgresTimestampMigration,\n });\n const statements = collectStatementsFromDiff(diff, db, options.engineHint);\n const unactionableChanges = collectUnactionableChanges(diff);\n return {\n diff,\n schemaCount: Object.keys(schemas).length,\n statements,\n hasChanges: hasActionableChanges(diff),\n unactionableChanges,\n hasManualDrift: unactionableChanges.length > 0,\n };\n}\n\n/**\n * Apply any pending schema changes from `ObjectRegistry` to `db`.\n *\n * Returns `applied: false` (with no error) if the database is already in\n * sync. Throws on the first migration failure with the underlying error\n * preserved (callers should let it propagate to the CLI).\n *\n * **Note on the returned `statements`:** mirrors `getPendingSchemaStatements`\n * — the list is the planned DDL, prior to Postgres-specific tracker rewrites\n * (CONCURRENTLY, transaction reordering). The actual executed SQL is\n * tracked in the returned `results`.\n *\n * **Note on `applied` semantics under `reconcile: true` (the default):**\n * the tracker may re-execute a migration that is already in `completed`\n * state when the checksum matches — that counts as `applied: true` in the\n * returned `MigrationResult`. So `result.applied === true` means \"the\n * tracker ran the migration\", not specifically \"the database changed for\n * the first time.\" Pass `reconcile: false` if you need to distinguish a\n * fresh apply from a reconcile-replay over an existing completed record.\n *\n * **Note on repeated failures:** the migration name defaults to a fresh\n * timestamp on every call, so a sequence of failed runs leaves one\n * `failed` row in `_smrt_schema_migrations` per attempt. Pass an explicit\n * `name` if you want to overwrite the same record across retries.\n *\n * **Note on `unactionableChanges`:** the differ can detect schema drift\n * the migration step can't auto-resolve — incompatible type mismatches,\n * SQLite type widening that needs table recreation, etc. Those changes\n * appear in `result.unactionableChanges` and are NOT applied (the\n * statements list is filtered to executable DDL only). When the diff\n * contains *only* unactionable changes, the function returns\n * `applied: false` with a populated `unactionableChanges` so callers can\n * surface a \"manual migration required\" signal. Without inspecting that\n * field, the result would look indistinguishable from \"already in sync.\"\n */\nexport async function migrateSmrtSchemas(\n options: MigrateSmrtSchemasOptions,\n): Promise<MigrateSmrtSchemasResult> {\n const pending = await getPendingSchemaStatements(options.db, {\n engineHint: options.engineHint,\n postgresTimestampMigration: options.postgresTimestampMigration,\n });\n if (!pending.hasChanges || pending.statements.length === 0) {\n return {\n applied: false,\n results: [],\n statements: [],\n schemaCount: pending.schemaCount,\n unactionableChanges: pending.unactionableChanges,\n hasManualDrift: pending.hasManualDrift,\n };\n }\n\n const tracker = new MigrationTracker({\n db: options.db,\n engineHint: options.engineHint,\n lockTimeout: options.lockTimeout,\n statementTimeout: options.statementTimeout,\n useConcurrentIndexes: options.useConcurrentIndexes ?? true,\n });\n const migration = createMigrationDefinition(\n options.name ?? `${generateMigrationTimestamp()}_smrt_schema_sync`,\n pending.statements,\n [],\n {\n description: options.description ?? 'Synchronize SMRT object schemas',\n packageName: options.packageName,\n version: options.version ?? 'unknown',\n },\n );\n\n const results = await tracker.applyAll([migration], {\n postgresSafe: options.postgresSafe ?? true,\n reconcile: options.reconcile ?? true,\n });\n\n const failed = results.find((result) => !result.success);\n if (failed) {\n throw failed.error instanceof Error\n ? failed.error\n : new Error(String(failed.error ?? `Migration ${failed.name} failed`));\n }\n\n const applied = results.some(\n (result) => result.applied !== false && result.skipped !== true,\n );\n\n return {\n applied,\n results,\n statements: pending.statements,\n schemaCount: pending.schemaCount,\n unactionableChanges: pending.unactionableChanges,\n hasManualDrift: pending.hasManualDrift,\n };\n}\n\n/** Mirror of the `getDatabaseUrl` helper in class.ts — some adapters\n * (JSON, certain in-memory wrappers) leave `db.url` undefined and expose\n * the URL on `db.config?.url` instead. Detecting the engine from a raw\n * `db.url` would throw or silently fall through to the sqlite default.\n */\nfunction resolveDatabaseUrl(db: DatabaseInterface): string {\n const dbWithConfig = db as DatabaseInterface & {\n config?: { url?: string };\n };\n return db.url || dbWithConfig.config?.url || '';\n}\n\n/**\n * Materialize the diff into the engine-correct SQL statements.\n *\n * For new tables we delegate to the engine's `DDLStrategy` so column types\n * (`REAL` → `DOUBLE PRECISION` on Postgres, `JSON` → `JSONB`, etc.),\n * partial-index `WHERE` clauses, and trigger syntax all match what the\n * SchemaComparer would expect on subsequent runs. Emitting the raw\n * abstract `SchemaDefinition.ddl` here would cause immediate type-drift\n * migrations on Postgres/DuckDB.\n *\n * Column/index/trigger changes for existing tables continue to come from\n * `getSQLFromDiff`, which is already engine-aware via the differ's per-\n * change `sqlStatements`.\n */\nfunction collectStatementsFromDiff(\n diff: Awaited<ReturnType<typeof generateSchemaDiff>>,\n db: DatabaseInterface,\n engineHint?: DatabaseEngine,\n): string[] {\n const strategy = getDDLStrategy(\n detectEngine(resolveDatabaseUrl(db), engineHint),\n );\n const statements: string[] = [];\n for (const schema of diff.added_tables) {\n statements.push(strategy.generateCreateTable(schema));\n statements.push(...strategy.generateIndexes(schema));\n statements.push(...strategy.generateTriggers(schema));\n }\n statements.push(...getSQLFromDiff(diff));\n // Drop empty and comment-only entries. SQLite type-widening upgrades\n // surface as `-- SQLite: Type upgrade for X requires table recreation`\n // — passing those through to the tracker records a successful migration\n // without actually fixing the column, so the same drift re-appears on\n // every subsequent run. Those changes are surfaced separately via\n // `unactionableChanges` so callers can prompt for manual remediation.\n return statements.filter((statement) => {\n const trimmed = statement.trim();\n if (trimmed.length === 0) return false;\n return !isCommentOnlySql(trimmed);\n });\n}\n\n/**\n * True if `sql` contains no executable DDL — only line comments (`--`)\n * and/or block comments (`/* … *​/`). Used to discard advisory comments\n * the differ emits when a column upgrade can't be performed in place\n * (e.g. SQLite type widening). Defensive: strips block comments first so\n * a future differ that emits `/* type_upgrade: no-op *​/` doesn't slip\n * through.\n */\nfunction isCommentOnlySql(sql: string): boolean {\n // Strip /* ... */ block comments (non-greedy, spans newlines).\n const withoutBlockComments = sql.replace(/\\/\\*[\\s\\S]*?\\*\\//g, '');\n const lines = withoutBlockComments\n .split('\\n')\n .map((line) => line.trim())\n .filter((line) => line.length > 0);\n if (lines.length === 0) return sql.trim().length > 0;\n return lines.every((line) => line.startsWith('--'));\n}\n\n/**\n * Surface changes the differ produced but that the migrator cannot apply\n * automatically — either `type_mismatch` entries (the differ explicitly\n * gives up on these) or `type_upgrade` entries whose generated SQL is\n * advisory-comment-only (SQLite table-recreation cases, etc.). Callers\n * use this to distinguish \"schema is in sync\" from \"schema is drifted but\n * we can't fix it from here.\"\n */\nfunction collectUnactionableChanges(\n diff: Awaited<ReturnType<typeof generateSchemaDiff>>,\n): SchemaChange[] {\n const unactionable: SchemaChange[] = [];\n for (const change of diff.changes) {\n if (change.type === 'type_mismatch') {\n unactionable.push(change);\n continue;\n }\n const statements = change.sqlStatements ?? (change.sql ? [change.sql] : []);\n if (\n statements.length > 0 &&\n statements.every((stmt) => isCommentOnlySql(stmt.trim()))\n ) {\n unactionable.push(change);\n }\n }\n return unactionable;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"orchestrate.js","names":[],"sources":["../../src/migrations/orchestrate.ts"],"sourcesContent":["/**\n * High-level \"make my SMRT app's schema match its database\" orchestration.\n *\n * Wraps `ObjectRegistry.getAllSchemasAsDefinitions()` → `generateSchemaDiff`\n * → `MigrationTracker.applyAll`. Apps that just want to run pending\n * schema changes call `migrateSmrtSchemas({ db, packageName, version })`\n * and don't have to assemble the migration definition by hand.\n *\n * The migration name is timestamped, so re-running on an up-to-date\n * database is a no-op (the diff is empty, no statements are generated,\n * `applied: false` is returned).\n */\nimport type { DatabaseInterface } from '@happyvertical/sql';\nimport { ObjectRegistry } from '../registry.js';\nimport { detectEngine, getDDLStrategy } from '../schema/ddl/index.js';\nimport type { DatabaseEngine } from '../schema/ddl/types.js';\nimport type { MigrationResult, SchemaChange } from '../schema/types.js';\nimport {\n generateSchemaDiff,\n getSQLFromDiff,\n hasActionableChanges,\n} from './differ.js';\nimport {\n createMigrationDefinition,\n generateMigrationTimestamp,\n} from './generator.js';\nimport { MigrationTracker } from './tracker.js';\n\nexport interface MigrateSmrtSchemasOptions {\n db: DatabaseInterface;\n /** Stored on the migration record so the audit log shows which app applied it. */\n packageName: string;\n /**\n * Stored on the migration record. Typically the app's package.json\n * version. Optional because most apps don't have a runtime-readable\n * version handy and would hardcode a stale string if forced to supply\n * one — `'unknown'` is honest. Pass an explicit value when you can\n * (e.g. `version: process.env.APP_VERSION`).\n */\n version?: string;\n /** Override the description on the synthetic migration definition. */\n description?: string;\n /** Override the migration name (default: `<timestamp>_smrt_schema_sync`). */\n name?: string;\n /** Forwarded to MigrationTracker — defaults to true for pg-safe runs. */\n postgresSafe?: boolean;\n /** Forwarded to MigrationTracker — defaults to true. */\n reconcile?: boolean;\n /** Forwarded to MigrationTracker — defaults to true for `CREATE INDEX CONCURRENTLY`. */\n useConcurrentIndexes?: boolean;\n /**\n * Forwarded to MigrationTracker (Postgres lock timeout, milliseconds).\n * Lets apps that customize tracker timeouts directly preserve their\n * tuning when going through the orchestrator. Falls back to the\n * tracker's default (30000ms) when omitted.\n */\n lockTimeout?: number;\n /**\n * Forwarded to MigrationTracker (Postgres statement timeout, ms). Falls\n * back to the tracker's default (60000ms) when omitted.\n */\n statementTimeout?: number;\n /**\n * Explicit engine hint forwarded to `detectEngine`, `SchemaComparer`, and\n * `MigrationTracker`. Useful when `db.url` doesn't unambiguously identify\n * the engine (e.g. the JSON adapter, which uses DuckDB internally and may\n * not be recognizable by URL alone). All three layers honor the same hint\n * so the generated DDL, drift comparison, and execution path stay\n * consistent — without it, an empty-URL connection could produce\n * Postgres-flavored DDL but run through the SQLite tracker path.\n *\n * Typed as `DatabaseEngine` so unsupported values (e.g. `'mysql'`) fail at\n * compile time rather than silently falling back to `'sqlite'` at runtime.\n */\n engineHint?: DatabaseEngine;\n /**\n * Confirm that historical PostgreSQL Date writers used UTC wall times before\n * reinterpreting legacy TIMESTAMP/TEXT/JSON columns as TIMESTAMPTZ.\n */\n postgresTimestampMigration?: { legacyTimezone: 'UTC' };\n}\n\nexport interface MigrateSmrtSchemasResult {\n applied: boolean;\n results: MigrationResult[];\n statements: string[];\n schemaCount: number;\n /**\n * Schema changes the orchestrator detected but cannot apply automatically\n * (incompatible type mismatches, SQLite type upgrades that require table\n * recreation, etc.). Empty when the schema is fully reconciled. Inspect\n * this to surface a \"manual migration required\" warning in CLI output.\n */\n unactionableChanges: SchemaChange[];\n /**\n * Convenience: `unactionableChanges.length > 0`. Lets a caller write\n * `if (result.hasManualDrift) ...` without iterating the array first,\n * and makes the case visible to anyone scanning the result shape (the\n * full array is easy to gloss over when `applied: false`).\n */\n hasManualDrift: boolean;\n}\n\nexport interface PendingSchemaStatementsResult {\n diff: Awaited<ReturnType<typeof generateSchemaDiff>>;\n statements: string[];\n schemaCount: number;\n hasChanges: boolean;\n /**\n * Schema changes the differ detected but that have no executable SQL\n * (incompatible `type_mismatch` entries, or `type_upgrade` entries whose\n * only generated SQL is an advisory comment because the engine cannot\n * upgrade the column in place — e.g. SQLite type widening). Surfacing\n * these lets status commands distinguish \"no drift\" from \"drift the\n * orchestrator can't fix on its own.\"\n */\n unactionableChanges: SchemaChange[];\n /**\n * Convenience: `unactionableChanges.length > 0`. Mirrors the\n * `MigrateSmrtSchemasResult.hasManualDrift` shortcut.\n */\n hasManualDrift: boolean;\n}\n\n/**\n * Compute the SQL statements needed to bring `db` in sync with the\n * currently-registered SMRT object schemas. Pure inspection — no writes.\n *\n * Useful for status commands (\"how far behind is the schema?\") and as the\n * inner step of `migrateSmrtSchemas`.\n *\n * **Note on the returned `statements`:** these are the engine-correct DDL\n * statements as the differ + DDL strategy produce them, *prior to* any\n * Postgres-specific rewrites the `MigrationTracker` applies at execution\n * time (e.g. rewriting `CREATE INDEX` → `CREATE INDEX CONCURRENTLY` and\n * moving CONCURRENTLY statements outside the surrounding transaction).\n * The list is suitable for preview/status/\"what would change?\" use cases.\n * For the exact byte-for-byte SQL the tracker ran, inspect the\n * `MigrationResult`s from `migrateSmrtSchemas` or the tracker's own logs.\n */\nexport async function getPendingSchemaStatements(\n db: DatabaseInterface,\n options: {\n engineHint?: DatabaseEngine;\n postgresTimestampMigration?: { legacyTimezone: 'UTC' };\n } = {},\n): Promise<PendingSchemaStatementsResult> {\n const schemas = ObjectRegistry.getAllSchemasAsDefinitions();\n // Forward engineHint into the diff itself so the SchemaComparer's\n // existing-table SQL (ALTER/index drift) uses the same DDL strategy\n // we use for newly-added tables — otherwise the two halves of the\n // statement list can land on different engines (split-brain) when\n // `db.url` is empty or ambiguous.\n const diff = await generateSchemaDiff(db, schemas, {\n engineHint: options.engineHint,\n postgresTimestampMigration: options.postgresTimestampMigration,\n });\n const statements = collectStatementsFromDiff(diff, db, options.engineHint);\n const unactionableChanges = collectUnactionableChanges(diff);\n return {\n diff,\n schemaCount: Object.keys(schemas).length,\n statements,\n hasChanges: hasActionableChanges(diff),\n unactionableChanges,\n hasManualDrift: unactionableChanges.length > 0,\n };\n}\n\n/**\n * Apply any pending schema changes from `ObjectRegistry` to `db`.\n *\n * Returns `applied: false` (with no error) if the database is already in\n * sync. Throws on the first migration failure with the underlying error\n * preserved (callers should let it propagate to the CLI).\n *\n * **Note on the returned `statements`:** mirrors `getPendingSchemaStatements`\n * — the list is the planned DDL, prior to Postgres-specific tracker rewrites\n * (CONCURRENTLY, transaction reordering). The actual executed SQL is\n * tracked in the returned `results`.\n *\n * **Note on `applied` semantics under `reconcile: true` (the default):**\n * the tracker may re-execute a migration that is already in `completed`\n * state when the checksum matches — that counts as `applied: true` in the\n * returned `MigrationResult`. So `result.applied === true` means \"the\n * tracker ran the migration\", not specifically \"the database changed for\n * the first time.\" Pass `reconcile: false` if you need to distinguish a\n * fresh apply from a reconcile-replay over an existing completed record.\n *\n * **Note on repeated failures:** the migration name defaults to a fresh\n * timestamp on every call, so a sequence of failed runs leaves one\n * `failed` row in `_smrt_schema_migrations` per attempt. Pass an explicit\n * `name` if you want to overwrite the same record across retries.\n *\n * **Note on `unactionableChanges`:** the differ can detect schema drift\n * the migration step can't auto-resolve — incompatible type mismatches,\n * SQLite type widening that needs table recreation, etc. Those changes\n * appear in `result.unactionableChanges` and are NOT applied (the\n * statements list is filtered to executable DDL only). When the diff\n * contains *only* unactionable changes, the function returns\n * `applied: false` with a populated `unactionableChanges` so callers can\n * surface a \"manual migration required\" signal. Without inspecting that\n * field, the result would look indistinguishable from \"already in sync.\"\n */\nexport async function migrateSmrtSchemas(\n options: MigrateSmrtSchemasOptions,\n): Promise<MigrateSmrtSchemasResult> {\n const pending = await getPendingSchemaStatements(options.db, {\n engineHint: options.engineHint,\n postgresTimestampMigration: options.postgresTimestampMigration,\n });\n if (!pending.hasChanges || pending.statements.length === 0) {\n return {\n applied: false,\n results: [],\n statements: [],\n schemaCount: pending.schemaCount,\n unactionableChanges: pending.unactionableChanges,\n hasManualDrift: pending.hasManualDrift,\n };\n }\n\n const tracker = new MigrationTracker({\n db: options.db,\n engineHint: options.engineHint,\n lockTimeout: options.lockTimeout,\n statementTimeout: options.statementTimeout,\n useConcurrentIndexes: options.useConcurrentIndexes ?? true,\n });\n const migration = createMigrationDefinition(\n options.name ?? `${generateMigrationTimestamp()}_smrt_schema_sync`,\n pending.statements,\n [],\n {\n description: options.description ?? 'Synchronize SMRT object schemas',\n packageName: options.packageName,\n version: options.version ?? 'unknown',\n },\n );\n\n const results = await tracker.applyAll([migration], {\n postgresSafe: options.postgresSafe ?? true,\n reconcile: options.reconcile ?? true,\n });\n\n const failed = results.find((result) => !result.success);\n if (failed) {\n throw failed.error instanceof Error\n ? failed.error\n : new Error(String(failed.error ?? `Migration ${failed.name} failed`));\n }\n\n const applied = results.some(\n (result) => result.applied !== false && result.skipped !== true,\n );\n\n return {\n applied,\n results,\n statements: pending.statements,\n schemaCount: pending.schemaCount,\n unactionableChanges: pending.unactionableChanges,\n hasManualDrift: pending.hasManualDrift,\n };\n}\n\n/** Mirror of the `getDatabaseUrl` helper in class.ts — some adapters\n * (JSON, certain in-memory wrappers) leave `db.url` undefined and expose\n * the URL on `db.config?.url` instead. Detecting the engine from a raw\n * `db.url` would throw or silently fall through to the sqlite default.\n */\nfunction resolveDatabaseUrl(db: DatabaseInterface): string {\n const dbWithConfig = db as DatabaseInterface & {\n config?: { url?: string };\n };\n return db.url || dbWithConfig.config?.url || '';\n}\n\n/**\n * Materialize the diff into the engine-correct SQL statements.\n *\n * For new tables we delegate to the engine's `DDLStrategy` so column types\n * (`REAL` → `DOUBLE PRECISION` on Postgres, `JSON` → `JSONB`, etc.),\n * partial-index `WHERE` clauses, and trigger syntax all match what the\n * SchemaComparer would expect on subsequent runs. Emitting the raw\n * abstract `SchemaDefinition.ddl` here would cause immediate type-drift\n * migrations on Postgres/DuckDB.\n *\n * Column/index/trigger changes for existing tables continue to come from\n * `getSQLFromDiff`, which is already engine-aware via the differ's per-\n * change `sqlStatements`.\n */\nfunction collectStatementsFromDiff(\n diff: Awaited<ReturnType<typeof generateSchemaDiff>>,\n db: DatabaseInterface,\n engineHint?: DatabaseEngine,\n): string[] {\n const strategy = getDDLStrategy(\n detectEngine(resolveDatabaseUrl(db), engineHint),\n );\n const statements: string[] = [];\n for (const schema of diff.added_tables) {\n statements.push(strategy.generateCreateTable(schema));\n statements.push(...strategy.generateIndexes(schema));\n statements.push(...strategy.generateTriggers(schema));\n }\n statements.push(...getSQLFromDiff(diff));\n // Drop empty and comment-only entries. SQLite type-widening upgrades\n // surface as `-- SQLite: Type upgrade for X requires table recreation`\n // — passing those through to the tracker records a successful migration\n // without actually fixing the column, so the same drift re-appears on\n // every subsequent run. Those changes are surfaced separately via\n // `unactionableChanges` so callers can prompt for manual remediation.\n return statements.filter((statement) => {\n const trimmed = statement.trim();\n if (trimmed.length === 0) return false;\n return !isCommentOnlySql(trimmed);\n });\n}\n\n/**\n * True if `sql` contains no executable DDL — only line comments (`--`)\n * and/or block comments (`/* … *​/`). Used to discard advisory comments\n * the differ emits when a column upgrade can't be performed in place\n * (e.g. SQLite type widening). Defensive: strips block comments first so\n * a future differ that emits `/* type_upgrade: no-op *​/` doesn't slip\n * through.\n */\nfunction isCommentOnlySql(sql: string): boolean {\n // Strip /* ... */ block comments (non-greedy, spans newlines).\n const withoutBlockComments = sql.replace(/\\/\\*[\\s\\S]*?\\*\\//g, '');\n const lines = withoutBlockComments\n .split('\\n')\n .map((line) => line.trim())\n .filter((line) => line.length > 0);\n if (lines.length === 0) return sql.trim().length > 0;\n return lines.every((line) => line.startsWith('--'));\n}\n\n/**\n * True if `sql` is an advisory comment that says *nothing needs to happen* —\n * as opposed to one that says a human has to act.\n *\n * The differ emits both kinds. \"This SQLite column already stores JSON as\n * TEXT\" and \"#2370: this column is rebuilt together with its sibling\" are\n * no-ops; \"requires table recreation\" / \"blocked: …\" are real manual drift.\n * Treating the first kind as drift makes a fully auto-repairable migration\n * report `hasManualDrift: true`.\n *\n * Keep in sync with `classifyTypeUpgradeSql`'s `noop` arm in\n * `@happyvertical/smrt-cli` (`src/commands/db-migrate-actions.ts`), which\n * applies the same rule on the CLI side.\n */\nfunction isNoOpAdvisorySql(sql: string): boolean {\n const trimmed = sql.trim();\n return (\n /no change needed/i.test(trimmed) || /already stores .* as /i.test(trimmed)\n );\n}\n\n/**\n * Surface changes the differ produced but that the migrator cannot apply\n * automatically — `type_mismatch` entries (the differ explicitly gives up on\n * these), `type_upgrade`/`alter_column` entries whose generated SQL is\n * advisory-comment-only (a SQLite rebuild that would be unsafe, an in-place\n * constraint change SQLite cannot perform), and warning-level report-only\n * advisories (an orphan NOT NULL column that will break inserts, a required\n * column that could not be enforced, a stale unique constraint, a relaxation\n * the caller has not opted into). Info-level advisories (a harmless orphan\n * column, a stale default) and comments that mean \"already handled\" (the\n * no-op advisories a table rebuild leaves on covered siblings, #2370) are\n * deliberately excluded so `hasManualDrift` keeps meaning \"something needs\n * an operator\". Callers use this to distinguish \"schema is in sync\" from\n * \"schema is drifted but we can't fix it from here\".\n */\nfunction collectUnactionableChanges(\n diff: Awaited<ReturnType<typeof generateSchemaDiff>>,\n): SchemaChange[] {\n const unactionable: SchemaChange[] = [];\n for (const change of diff.changes) {\n if (change.type === 'type_mismatch') {\n unactionable.push(change);\n continue;\n }\n const statements = change.sqlStatements ?? (change.sql ? [change.sql] : []);\n if (statements.length === 0) {\n if (change.advisory?.severity === 'warning') {\n unactionable.push(change);\n }\n continue;\n }\n if (\n statements.every((stmt) => isCommentOnlySql(stmt.trim())) &&\n !statements.every((stmt) => isNoOpAdvisorySql(stmt))\n ) {\n unactionable.push(change);\n }\n }\n return unactionable;\n}\n"],"mappings":"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"}