@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,602 @@
1
+ import { detectEngine, getDDLStrategy } from "./ddl/index.js";
2
+ import { RETIRED_SYSTEM_TABLES } from "../system/schema.js";
3
+ import { getSystemTableShapes } from "./system-table-shapes.js";
4
+ //#region src/schema/live-parity.ts
5
+ /**
6
+ * Thrown when the live database cannot be introspected. Callers fail closed:
7
+ * an unreadable database is never reported as "in sync".
8
+ */
9
+ var LiveSchemaParityError = class extends Error {
10
+ constructor(message, options) {
11
+ super(message, options);
12
+ this.name = "LiveSchemaParityError";
13
+ }
14
+ };
15
+ /**
16
+ * Compare a live database against the expected shape and return every
17
+ * difference found.
18
+ *
19
+ * @throws {LiveSchemaParityError} when the database cannot be reached or the
20
+ * adapter cannot describe a table. Connection problems must fail closed.
21
+ */
22
+ async function checkLiveSchemaParity(options) {
23
+ const { db, schemas = {}, conflictTargets = {}, includeSystemTables = true, engineHint, reportExtraTables = true } = options;
24
+ const engine = resolveEngine(db, engineHint);
25
+ const expected = buildExpectedTables({
26
+ schemas,
27
+ conflictTargets,
28
+ includeSystemTables,
29
+ engine
30
+ });
31
+ const liveTableNames = await listLiveTables(db, engine);
32
+ const indexCatalog = await readIndexCatalog(db, engine);
33
+ const findings = [];
34
+ let tablesChecked = 0;
35
+ let tablesMissing = 0;
36
+ for (const table of expected) {
37
+ if (!liveTableNames.has(table.name)) {
38
+ tablesMissing++;
39
+ findings.push({
40
+ kind: "missing_table",
41
+ severity: "error",
42
+ table: table.name,
43
+ origin: table.origin,
44
+ message: `Table \`${table.name}\` is declared but does not exist in the live database.`,
45
+ recommendation: table.origin === "system" ? "Initialize SMRT system tables against this database (they are created on framework bootstrap)." : "Run `smrt db:migrate` to create the missing table."
46
+ });
47
+ continue;
48
+ }
49
+ tablesChecked++;
50
+ const liveColumns = await readLiveColumns(db, table.name);
51
+ findings.push(...compareColumns(table, liveColumns, engine));
52
+ if (indexCatalog) {
53
+ const liveIndexes = await indexCatalog.forTable(table.name);
54
+ findings.push(...compareIndexes(table, liveColumns, liveIndexes));
55
+ }
56
+ }
57
+ if (reportExtraTables) {
58
+ const expectedNames = new Set(expected.map((table) => table.name));
59
+ for (const liveName of liveTableNames) {
60
+ if (expectedNames.has(liveName)) continue;
61
+ if (isInternalTableName(liveName)) continue;
62
+ if (!includeSystemTables && liveName.startsWith("_smrt_")) continue;
63
+ const retired = RETIRED_SYSTEM_TABLES.includes(liveName);
64
+ findings.push({
65
+ kind: "extra_table",
66
+ severity: "info",
67
+ table: liveName,
68
+ origin: liveName.startsWith("_smrt_") ? "system" : "application",
69
+ message: retired ? `Live table \`${liveName}\` is a retired SMRT system table; nothing reads or writes it.` : `Live table \`${liveName}\` is not covered by any declared schema.`,
70
+ recommendation: retired ? `Drop it once you have confirmed it is unused: DROP TABLE IF EXISTS ${liveName};` : "Confirm the table belongs to another application sharing this database, or remove it once its owning model is gone."
71
+ });
72
+ }
73
+ }
74
+ const counts = {
75
+ error: 0,
76
+ warning: 0,
77
+ info: 0
78
+ };
79
+ for (const finding of findings) counts[finding.severity]++;
80
+ return {
81
+ engine,
82
+ tablesChecked,
83
+ tablesMissing,
84
+ systemTablesIncluded: includeSystemTables,
85
+ indexIntrospection: indexCatalog ? "full" : "unavailable",
86
+ findings,
87
+ counts,
88
+ ok: counts.error === 0
89
+ };
90
+ }
91
+ function buildExpectedTables(input) {
92
+ const { schemas, conflictTargets, includeSystemTables, engine } = input;
93
+ const strategy = getDDLStrategy(engine);
94
+ const tables = [];
95
+ for (const [key, schema] of Object.entries(schemas)) {
96
+ const tableName = schema?.tableName || key;
97
+ if (!tableName) continue;
98
+ const columns = Object.entries(schema.columns ?? {}).map(([columnName, column]) => ({
99
+ name: columnName,
100
+ type: strategy.mapType(column.type),
101
+ primaryKey: column.primaryKey === true,
102
+ notNull: column.notNull === true || column.primaryKey === true,
103
+ unique: column.unique === true,
104
+ hasDefault: column.defaultValue !== void 0,
105
+ reference: column.foreignKey ? "foreignKey" : column.referenceKind
106
+ }));
107
+ tables.push({
108
+ name: tableName,
109
+ origin: "application",
110
+ columns,
111
+ indexes: (schema.indexes ?? []).filter((index) => !index.jsonPath && (index.columns?.length ?? 0) > 0).map((index) => ({
112
+ name: index.name,
113
+ columns: index.columns,
114
+ unique: index.unique === true
115
+ })),
116
+ conflictTargets: dedupeConflictTargets(conflictTargets[tableName] ?? [])
117
+ });
118
+ }
119
+ if (includeSystemTables) for (const shape of getSystemTableShapes(engine).values()) tables.push({
120
+ name: shape.tableName,
121
+ origin: "system",
122
+ columns: shape.columns.map((column) => ({
123
+ name: column.name,
124
+ type: column.type,
125
+ primaryKey: column.primaryKey,
126
+ notNull: column.notNull || column.primaryKey,
127
+ unique: column.unique,
128
+ hasDefault: column.hasDefault
129
+ })),
130
+ indexes: shape.indexes.map((index) => ({
131
+ name: index.name,
132
+ columns: index.columns,
133
+ unique: index.unique
134
+ })),
135
+ conflictTargets: dedupeConflictTargets(shape.uniqueConstraints.map((columns) => ({
136
+ columns,
137
+ source: `${shape.tableName} DDL`
138
+ })))
139
+ });
140
+ return tables;
141
+ }
142
+ function dedupeConflictTargets(targets) {
143
+ const seen = /* @__PURE__ */ new Map();
144
+ for (const target of targets) {
145
+ const columns = (target.columns ?? []).filter(Boolean);
146
+ if (columns.length === 0) continue;
147
+ const key = [...columns].sort().join(",");
148
+ if (!seen.has(key)) seen.set(key, {
149
+ columns,
150
+ source: target.source
151
+ });
152
+ }
153
+ return [...seen.values()];
154
+ }
155
+ function compareColumns(table, liveColumns, engine) {
156
+ const findings = [];
157
+ const expectedNames = new Set(table.columns.map((column) => column.name));
158
+ for (const column of table.columns) {
159
+ const live = liveColumns.get(column.name);
160
+ if (!live) {
161
+ findings.push({
162
+ kind: "missing_column",
163
+ severity: "error",
164
+ table: table.name,
165
+ target: column.name,
166
+ origin: table.origin,
167
+ message: `Column \`${table.name}.${column.name}\` is declared but missing from the live database.`,
168
+ recommendation: "Run `smrt db:migrate` to add the column before application writes reach this table.",
169
+ details: { expectedType: column.type }
170
+ });
171
+ continue;
172
+ }
173
+ if (!typesAreEquivalent(column, live, engine)) findings.push({
174
+ kind: "column_type_drift",
175
+ severity: "error",
176
+ table: table.name,
177
+ target: column.name,
178
+ origin: table.origin,
179
+ message: `Column \`${table.name}.${column.name}\` is \`${live.type}\` in the live database but declared \`${column.type}\`.`,
180
+ recommendation: "Reconcile the column type with `smrt db:migrate`, or repair the declaration if the live type is correct.",
181
+ details: {
182
+ expected: column.type,
183
+ actual: live.type
184
+ }
185
+ });
186
+ if (!column.primaryKey && column.notNull && !live.notNull) findings.push({
187
+ kind: "column_nullability_drift",
188
+ severity: "warning",
189
+ table: table.name,
190
+ target: column.name,
191
+ origin: table.origin,
192
+ message: `Column \`${table.name}.${column.name}\` is declared NOT NULL but is nullable in the live database.`,
193
+ recommendation: "Backfill any NULL values and add the NOT NULL constraint, or relax the declaration."
194
+ });
195
+ else if (!column.notNull && live.notNull && !live.hasDefault) findings.push({
196
+ kind: "column_nullability_drift",
197
+ severity: "error",
198
+ table: table.name,
199
+ target: column.name,
200
+ origin: table.origin,
201
+ message: `Column \`${table.name}.${column.name}\` is NOT NULL in the live database with no default, but is not declared NOT NULL. Inserts that omit it will fail.`,
202
+ recommendation: "Drop the orphan NOT NULL constraint, give the column a default, or declare the column required."
203
+ });
204
+ }
205
+ for (const live of liveColumns.values()) {
206
+ if (expectedNames.has(live.name)) continue;
207
+ const breaksInserts = live.notNull && !live.hasDefault;
208
+ findings.push({
209
+ kind: "extra_column",
210
+ severity: breaksInserts ? "error" : "info",
211
+ table: table.name,
212
+ target: live.name,
213
+ origin: table.origin,
214
+ message: breaksInserts ? `Live column \`${table.name}.${live.name}\` is undeclared, NOT NULL, and has no default — every framework insert into this table will fail.` : `Live column \`${table.name}.${live.name}\` is not declared by the current schema.`,
215
+ recommendation: breaksInserts ? "Drop the orphan column or give it a default; this is the residue of a renamed or removed required field." : "Confirm the column is intentional legacy data, or drop it once nothing reads it.",
216
+ details: { type: live.type }
217
+ });
218
+ }
219
+ return findings;
220
+ }
221
+ /**
222
+ * Whether a live column type satisfies its declaration.
223
+ *
224
+ * Two tolerances match the model layer's actual conventions, and mirror the
225
+ * migration differ so the two tools never disagree:
226
+ *
227
+ * - `TEXT` ↔ `UUID` for structural identifier/reference columns, because
228
+ * SQLite has no uuid type and older PostgreSQL deployments legitimately
229
+ * still carry text ids (R11).
230
+ * - `TEXT` ↔ `JSON`/`JSONB` in both directions, because SMRT serializes JSON
231
+ * values into text columns and a native json column already holds exactly
232
+ * that (#1335).
233
+ */
234
+ function typesAreEquivalent(expected, live, engine) {
235
+ const expectedType = normalizeSqlType(expected.type);
236
+ const actualType = normalizeSqlType(live.type);
237
+ if (expectedType === actualType) return true;
238
+ if ((expectedType === "UUID" && actualType === "TEXT" || expectedType === "TEXT" && actualType === "UUID") && isStructuralReference(expected)) return true;
239
+ if (expectedType === "JSON" && actualType === "TEXT" || expectedType === "TEXT" && actualType === "JSON") return true;
240
+ if (engine === "sqlite") {
241
+ if (expectedType === "BOOLEAN" && actualType === "INTEGER" || expectedType === "INTEGER" && actualType === "BOOLEAN") return true;
242
+ }
243
+ return false;
244
+ }
245
+ function isStructuralReference(column) {
246
+ return column.primaryKey || column.reference === "id" || column.reference === "foreignKey" || column.reference === "crossPackageRef" || column.reference === "tenantId";
247
+ }
248
+ /** Fold engine-specific type spellings into comparable buckets. */
249
+ function normalizeSqlType(type) {
250
+ const upper = String(type ?? "").toUpperCase().trim().replace(/\(\s*[^)]*\s*\)/g, "").replace(/\[\]$/, "").trim();
251
+ if (/^(INTEGER|INT|INT2|INT4|INT8|BIGINT|SMALLINT|TINYINT|SERIAL|BIGSERIAL)$/.test(upper)) return "INTEGER";
252
+ if (/^(TEXT|CLOB|STRING|VARCHAR|CHAR|CHARACTER|CHARACTER VARYING|NAME)$/.test(upper)) return "TEXT";
253
+ if (upper === "UUID") return "UUID";
254
+ if (/^(REAL|FLOAT|FLOAT4|FLOAT8|DOUBLE|DOUBLE PRECISION|DECIMAL|NUMERIC|NUMBER)$/.test(upper)) return "REAL";
255
+ if (/^(BOOLEAN|BOOL)$/.test(upper)) return "BOOLEAN";
256
+ if (/^(TIMESTAMPTZ|TIMESTAMP WITH TIME ZONE)$/.test(upper)) return "TIMESTAMPTZ";
257
+ if (/^(DATETIME|TIMESTAMP|TIMESTAMP WITHOUT TIME ZONE|DATE|TIME)$/.test(upper)) return "TIMESTAMP";
258
+ if (/^(BLOB|BINARY|BYTEA)$/.test(upper)) return "BLOB";
259
+ if (/^(JSON|JSONB)$/.test(upper)) return "JSON";
260
+ return upper;
261
+ }
262
+ function compareIndexes(table, liveColumns, liveIndexes) {
263
+ const findings = [];
264
+ const byName = new Map(liveIndexes.map((index) => [index.name, index]));
265
+ const signatures = new Set(liveIndexes.map((index) => indexSignature(index.columns, index.unique)));
266
+ for (const index of liveIndexes) {
267
+ if (index.valid) continue;
268
+ findings.push({
269
+ kind: "invalid_index",
270
+ severity: "error",
271
+ table: table.name,
272
+ target: index.name,
273
+ origin: table.origin,
274
+ message: `Index \`${index.name}\` on \`${table.name}\` is INVALID — PostgreSQL will not use it and a unique index in this state enforces nothing.`,
275
+ recommendation: "Drop and rebuild it (`REINDEX INDEX CONCURRENTLY`), then re-run the parity check."
276
+ });
277
+ }
278
+ for (const index of table.indexes) {
279
+ if (signatures.has(indexSignature(index.columns, index.unique))) continue;
280
+ if (table.conflictTargets.some((target) => sameColumnSet(index.columns, target.columns))) continue;
281
+ const sameName = byName.get(index.name);
282
+ findings.push({
283
+ kind: "missing_index",
284
+ severity: "warning",
285
+ table: table.name,
286
+ target: index.name,
287
+ origin: table.origin,
288
+ message: sameName ? `Index \`${index.name}\` on \`${table.name}\` exists but its shape drifted: declared (${index.columns.join(", ")})${index.unique ? " UNIQUE" : ""}, live (${sameName.columns.join(", ")})${sameName.unique ? " UNIQUE" : ""}.` : `Declared index \`${index.name}\` on \`${table.name}\` (${index.columns.join(", ")}) is missing from the live database.`,
289
+ recommendation: table.origin === "system" ? "Re-run SMRT system-table initialization to create the missing system index." : "Run `smrt db:migrate` to create the missing index."
290
+ });
291
+ }
292
+ for (const target of table.conflictTargets) {
293
+ if (target.columns.some((column) => !liveColumns.has(column))) continue;
294
+ const candidates = liveIndexes.filter((index) => sameColumnSet(index.columns, target.columns));
295
+ if (candidates.some((index) => index.unique && !index.partial)) continue;
296
+ const partialUnique = candidates.find((index) => index.unique && index.partial);
297
+ const nonUnique = candidates.find((index) => !index.unique);
298
+ const columnList = target.columns.join(", ");
299
+ const suffix = target.source ? ` (declared by ${target.source})` : "";
300
+ if (nonUnique) {
301
+ findings.push({
302
+ kind: "conflict_target_not_unique",
303
+ severity: "error",
304
+ table: table.name,
305
+ target: nonUnique.name,
306
+ origin: table.origin,
307
+ message: `Upsert conflict target \`${table.name}\` (${columnList})${suffix} is backed by \`${nonUnique.name}\`, which is not UNIQUE.`,
308
+ recommendation: "Recreate the index as UNIQUE (`smrt db:migrate`); until then upserts insert duplicates instead of updating."
309
+ });
310
+ continue;
311
+ }
312
+ findings.push({
313
+ kind: "conflict_target_unindexed",
314
+ severity: "error",
315
+ table: table.name,
316
+ target: partialUnique ? partialUnique.name : columnList,
317
+ origin: table.origin,
318
+ message: partialUnique ? `Upsert conflict target \`${table.name}\` (${columnList})${suffix} is backed only by the partial index \`${partialUnique.name}\`, which cannot arbitrate the upsert.` : `Upsert conflict target \`${table.name}\` (${columnList})${suffix} has no matching UNIQUE index in the live database.`,
319
+ recommendation: "Create a full UNIQUE index over exactly these columns (`smrt db:migrate`); PostgreSQL rejects the upsert outright and other engines duplicate rows."
320
+ });
321
+ }
322
+ for (const column of table.columns) {
323
+ if (!column.unique) continue;
324
+ if (!liveColumns.has(column.name)) continue;
325
+ if (liveIndexes.some((index) => index.unique && !index.partial && sameColumnSet(index.columns, [column.name]))) continue;
326
+ findings.push({
327
+ kind: "unique_constraint_missing",
328
+ severity: "error",
329
+ table: table.name,
330
+ target: column.name,
331
+ origin: table.origin,
332
+ message: `Column \`${table.name}.${column.name}\` is declared unique but no UNIQUE index enforces it in the live database.`,
333
+ recommendation: "De-duplicate existing values and create the UNIQUE index; the declaration is currently decorative."
334
+ });
335
+ }
336
+ for (const column of table.columns) {
337
+ if (!column.reference) continue;
338
+ if (column.reference === "id") continue;
339
+ if (!liveColumns.has(column.name)) continue;
340
+ if (liveIndexes.some((index) => index.columns[0] === column.name)) continue;
341
+ findings.push({
342
+ kind: "unindexed_reference",
343
+ severity: "warning",
344
+ table: table.name,
345
+ target: column.name,
346
+ origin: table.origin,
347
+ message: `Reference column \`${table.name}.${column.name}\` (${column.reference}) leads no index in the live database.`,
348
+ recommendation: "Add an index led by this column; relationship loads, joins and tenant-scoped reads currently scan the whole table.",
349
+ details: { referenceKind: column.reference }
350
+ });
351
+ }
352
+ const policyLeadColumns = new Set(table.columns.filter((column) => column.reference && column.reference !== "id").map((column) => column.name));
353
+ const declaredNames = new Set(table.indexes.map((index) => index.name));
354
+ const declaredSignatures = new Set(table.indexes.map((index) => indexSignature(index.columns, index.unique)));
355
+ for (const index of liveIndexes) {
356
+ if (index.primary) continue;
357
+ if (isConstraintOwnedIndexName(index.name)) continue;
358
+ if (policyLeadColumns.has(index.columns[0])) continue;
359
+ if (declaredNames.has(index.name)) continue;
360
+ if (declaredSignatures.has(indexSignature(index.columns, index.unique))) continue;
361
+ if (table.conflictTargets.some((target) => sameColumnSet(index.columns, target.columns))) continue;
362
+ findings.push({
363
+ kind: "extra_index",
364
+ severity: "info",
365
+ table: table.name,
366
+ target: index.name,
367
+ origin: table.origin,
368
+ message: `Live index \`${index.name}\` on \`${table.name}\` (${index.columns.join(", ") || "expression"}) is not declared by the current schema.`,
369
+ recommendation: "Declare it so a rebuilt database keeps it, or drop it if it is obsolete.",
370
+ details: {
371
+ unique: index.unique,
372
+ partial: index.partial
373
+ }
374
+ });
375
+ }
376
+ return findings;
377
+ }
378
+ function isConstraintOwnedIndexName(name) {
379
+ return name.startsWith("sqlite_autoindex_") || name.endsWith("_pkey") || name.endsWith("_key");
380
+ }
381
+ function indexSignature(columns, unique) {
382
+ return `${columns.join(",")}:${unique}`;
383
+ }
384
+ /**
385
+ * Conflict targets and unique constraints are order-insensitive: PostgreSQL
386
+ * matches `ON CONFLICT (a, b)` against a unique index on `(b, a)`.
387
+ */
388
+ function sameColumnSet(left, right) {
389
+ if (left.length !== right.length) return false;
390
+ const sortedLeft = [...left].sort();
391
+ const sortedRight = [...right].sort();
392
+ return sortedLeft.every((column, index) => column === sortedRight[index]);
393
+ }
394
+ function resolveEngine(db, engineHint) {
395
+ if (typeof db.exportTable === "function") return "json";
396
+ const dbWithConfig = db;
397
+ return detectEngine(db.url || dbWithConfig.config?.url || "", engineHint);
398
+ }
399
+ function isInternalTableName(name) {
400
+ return name.startsWith("sqlite_") || name.startsWith("pg_") || name.startsWith("duckdb_") || name === "information_schema";
401
+ }
402
+ async function listLiveTables(db, engine) {
403
+ const sql = engine === "postgres" ? `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'` : `SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`;
404
+ try {
405
+ const rows = (await db.query(sql))?.rows ?? [];
406
+ return new Set(rows.map((row) => row.name || row.table_name || "").filter(Boolean));
407
+ } catch (error) {
408
+ throw new LiveSchemaParityError(`Could not list tables in the live database: ${describeError(error)}`, { cause: error });
409
+ }
410
+ }
411
+ async function readLiveColumns(db, tableName) {
412
+ if (typeof db.getTableSchema !== "function") throw new LiveSchemaParityError("The configured database adapter cannot describe tables (`getTableSchema` is unavailable), so live-schema parity cannot be verified.");
413
+ let schema;
414
+ try {
415
+ schema = await db.getTableSchema(tableName);
416
+ } catch (error) {
417
+ throw new LiveSchemaParityError(`Could not read the live schema of \`${tableName}\`: ${describeError(error)}`, { cause: error });
418
+ }
419
+ const columns = /* @__PURE__ */ new Map();
420
+ for (const [name, column] of Object.entries(schema?.columns ?? {})) columns.set(name, {
421
+ name,
422
+ type: String(column?.type ?? ""),
423
+ notNull: column?.notNull === true,
424
+ primaryKey: column?.primaryKey === true,
425
+ hasDefault: column?.defaultValue !== void 0 && column?.defaultValue !== null
426
+ });
427
+ return columns;
428
+ }
429
+ /**
430
+ * Build an index reader for the engine, or `null` when index metadata cannot
431
+ * be read. A null catalog disables every index-class check rather than
432
+ * reporting indexes as missing on an engine we cannot introspect.
433
+ */
434
+ async function readIndexCatalog(db, engine) {
435
+ if (engine === "postgres") return readPostgresIndexCatalog(db);
436
+ if (engine === "sqlite") return { forTable: (tableName) => readSqliteIndexes(db, tableName) };
437
+ return readDuckDbIndexCatalog(db);
438
+ }
439
+ async function readPostgresIndexCatalog(db) {
440
+ const sql = `
441
+ SELECT t.relname AS table_name,
442
+ c.relname AS index_name,
443
+ i.indisunique AS is_unique,
444
+ i.indisprimary AS is_primary,
445
+ i.indisvalid AS is_valid,
446
+ (i.indpred IS NOT NULL) AS is_partial,
447
+ pg_get_indexdef(i.indexrelid) AS index_def
448
+ FROM pg_index i
449
+ JOIN pg_class c ON c.oid = i.indexrelid
450
+ JOIN pg_class t ON t.oid = i.indrelid
451
+ JOIN pg_namespace n ON n.oid = t.relnamespace
452
+ WHERE n.nspname = 'public'`;
453
+ let rows;
454
+ try {
455
+ rows = (await db.query(sql))?.rows ?? [];
456
+ } catch (error) {
457
+ throw new LiveSchemaParityError(`Could not read the PostgreSQL index catalog: ${describeError(error)}`, { cause: error });
458
+ }
459
+ const byTable = /* @__PURE__ */ new Map();
460
+ for (const row of rows) {
461
+ const tableName = String(row.table_name ?? "");
462
+ if (!tableName) continue;
463
+ const bucket = byTable.get(tableName) ?? [];
464
+ bucket.push({
465
+ name: String(row.index_name ?? ""),
466
+ columns: parseIndexDefColumns(String(row.index_def ?? "")),
467
+ unique: toBoolean(row.is_unique),
468
+ primary: toBoolean(row.is_primary),
469
+ valid: row.is_valid === void 0 ? true : toBoolean(row.is_valid),
470
+ partial: toBoolean(row.is_partial)
471
+ });
472
+ byTable.set(tableName, bucket);
473
+ }
474
+ return { forTable: async (tableName) => byTable.get(tableName) ?? [] };
475
+ }
476
+ /**
477
+ * Extract the indexed column list from a `CREATE INDEX` definition.
478
+ *
479
+ * Expression components (`lower(name)`, `(meta ->> 'x')`) are kept verbatim so
480
+ * they never collide with a plain column of the same name; ordering is
481
+ * preserved because leading-column coverage is what the policy checks.
482
+ */
483
+ function parseIndexDefColumns(indexDef) {
484
+ const open = indexDef.indexOf("(");
485
+ if (open === -1) return [];
486
+ let depth = 0;
487
+ let end = -1;
488
+ for (let i = open; i < indexDef.length; i++) if (indexDef[i] === "(") depth++;
489
+ else if (indexDef[i] === ")") {
490
+ depth--;
491
+ if (depth === 0) {
492
+ end = i;
493
+ break;
494
+ }
495
+ }
496
+ if (end === -1) return [];
497
+ const body = indexDef.slice(open + 1, end);
498
+ const parts = [];
499
+ let current = "";
500
+ depth = 0;
501
+ for (const char of body) {
502
+ if (char === "(") depth++;
503
+ if (char === ")") depth--;
504
+ if (char === "," && depth === 0) {
505
+ parts.push(current);
506
+ current = "";
507
+ continue;
508
+ }
509
+ current += char;
510
+ }
511
+ parts.push(current);
512
+ return parts.map((part) => part.trim().replace(/\s+(ASC|DESC|NULLS\s+(FIRST|LAST)|[a-z_]+_ops)\b/gi, "").trim().replace(/^"(.*)"$/, "$1")).filter((part) => part.length > 0);
513
+ }
514
+ async function readSqliteIndexes(db, tableName) {
515
+ let listRows;
516
+ try {
517
+ listRows = (await db.query(`SELECT * FROM pragma_index_list(${quoteLiteral(tableName)})`))?.rows ?? [];
518
+ } catch (error) {
519
+ throw new LiveSchemaParityError(`Could not read SQLite indexes for \`${tableName}\`: ${describeError(error)}`, { cause: error });
520
+ }
521
+ const indexes = [];
522
+ for (const row of listRows) {
523
+ const name = String(row.name ?? "");
524
+ if (!name) continue;
525
+ const columns = ((await db.query(`SELECT * FROM pragma_index_info(${quoteLiteral(name)})`))?.rows ?? []).slice().sort((left, right) => Number(left.seqno ?? 0) - Number(right.seqno ?? 0)).map((infoRow) => infoRow.name == null ? "" : String(infoRow.name)).filter((column) => column.length > 0);
526
+ indexes.push({
527
+ name,
528
+ columns,
529
+ unique: toBoolean(row.unique),
530
+ primary: String(row.origin ?? "") === "pk",
531
+ valid: true,
532
+ partial: toBoolean(row.partial)
533
+ });
534
+ }
535
+ return indexes;
536
+ }
537
+ async function readDuckDbIndexCatalog(db) {
538
+ const byTable = /* @__PURE__ */ new Map();
539
+ try {
540
+ const result = await db.query(`SELECT table_name, index_name, is_unique, sql FROM duckdb_indexes()`);
541
+ for (const row of result?.rows ?? []) {
542
+ const tableName = String(row.table_name ?? "");
543
+ if (!tableName) continue;
544
+ const bucket = byTable.get(tableName) ?? [];
545
+ bucket.push({
546
+ name: String(row.index_name ?? ""),
547
+ columns: parseIndexDefColumns(String(row.sql ?? "")),
548
+ unique: toBoolean(row.is_unique),
549
+ primary: false,
550
+ valid: true,
551
+ partial: false
552
+ });
553
+ byTable.set(tableName, bucket);
554
+ }
555
+ } catch {
556
+ return null;
557
+ }
558
+ try {
559
+ const result = await db.query(`SELECT table_name, constraint_type, constraint_column_names FROM duckdb_constraints()`);
560
+ for (const row of result?.rows ?? []) {
561
+ const constraintType = String(row.constraint_type ?? "").toUpperCase();
562
+ if (constraintType !== "UNIQUE" && constraintType !== "PRIMARY KEY") continue;
563
+ const tableName = String(row.table_name ?? "");
564
+ const columns = normalizeColumnNameList(row.constraint_column_names);
565
+ if (!tableName || columns.length === 0) continue;
566
+ const bucket = byTable.get(tableName) ?? [];
567
+ bucket.push({
568
+ name: `${tableName}_${columns.join("_")}_${constraintType === "UNIQUE" ? "key" : "pkey"}`,
569
+ columns,
570
+ unique: true,
571
+ primary: constraintType === "PRIMARY KEY",
572
+ valid: true,
573
+ partial: false
574
+ });
575
+ byTable.set(tableName, bucket);
576
+ }
577
+ } catch {
578
+ return null;
579
+ }
580
+ return { forTable: async (tableName) => byTable.get(tableName) ?? [] };
581
+ }
582
+ function normalizeColumnNameList(value) {
583
+ if (Array.isArray(value)) return value.map((entry) => String(entry)).filter(Boolean);
584
+ if (typeof value === "string") return value.replace(/^[[{]|[\]}]$/g, "").split(",").map((entry) => entry.trim().replace(/^["']|["']$/g, "")).filter(Boolean);
585
+ return [];
586
+ }
587
+ function toBoolean(value) {
588
+ if (typeof value === "boolean") return value;
589
+ if (typeof value === "number") return value !== 0;
590
+ if (typeof value === "string") return value === "1" || value.toLowerCase() === "true" || value === "t";
591
+ return false;
592
+ }
593
+ function quoteLiteral(value) {
594
+ return `'${value.replace(/'/g, "''")}'`;
595
+ }
596
+ function describeError(error) {
597
+ return error instanceof Error ? error.message : String(error);
598
+ }
599
+ //#endregion
600
+ export { LiveSchemaParityError, checkLiveSchemaParity, normalizeSqlType, parseIndexDefColumns };
601
+
602
+ //# sourceMappingURL=live-parity.js.map