@obsunified/collector 2.2.1 → 2.3.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 (53) hide show
  1. package/dist/auth.d.ts +1 -1
  2. package/dist/auth.js +1 -1
  3. package/dist/{chunk-KMEZKGSO.js → chunk-2FALTDCE.js} +12 -4
  4. package/dist/chunk-2FALTDCE.js.map +1 -0
  5. package/dist/{chunk-ZSF5XP3B.js → chunk-RASSTGNB.js} +30 -22
  6. package/dist/chunk-RASSTGNB.js.map +1 -0
  7. package/dist/index.d.ts +10 -2
  8. package/dist/index.js +2892 -2240
  9. package/dist/index.js.map +1 -1
  10. package/dist/{ingest-auth-C5-8Mly5.d.ts → ingest-auth-BmP5-f96.d.ts} +32 -1
  11. package/dist/{openai-GSJZGFGL.js → openai-VDKVVZAV.js} +2 -2
  12. package/package.json +4 -3
  13. package/src/__tests__/autosre-closed-loop.test.ts +700 -0
  14. package/src/__tests__/db-parity.integration.test.ts +961 -0
  15. package/src/__tests__/internal-contracts-routes.test.ts +451 -0
  16. package/src/auth/dashboard-auth.test.ts +61 -0
  17. package/src/auth/dashboard-auth.ts +17 -2
  18. package/src/framework/env.ts +105 -0
  19. package/src/index.ts +13 -4
  20. package/src/lib/ai-store/calls.ts +43 -23
  21. package/src/lib/ai-store/evaluations.test.ts +3 -3
  22. package/src/lib/ai-store.ts +12 -1
  23. package/src/lib/analyses-store.test.ts +6 -6
  24. package/src/lib/autosre-findings.ts +169 -14
  25. package/src/lib/autosre-remediation-gate.ts +156 -26
  26. package/src/lib/db-parity.ts +883 -0
  27. package/src/lib/eval-cases-store.test.ts +1 -1
  28. package/src/lib/eval-cases-store.ts +16 -4
  29. package/src/lib/evidence-references.ts +30 -22
  30. package/src/lib/projects-store.test.ts +38 -1
  31. package/src/lib/projects-store.ts +23 -4
  32. package/src/lib/sql-db-postgres.integration.test.ts +75 -2
  33. package/src/lib/stage6.test.ts +1 -1
  34. package/src/lib/usage-store.ts +9 -3
  35. package/src/migrations/052_remediation_callback.sql +10 -0
  36. package/src/migrations/053_remediation_callback_delivery.sql +7 -0
  37. package/src/migrations/parity.test.ts +105 -3
  38. package/src/migrations-postgres/052_remediation_callback.sql +10 -0
  39. package/src/migrations-postgres/053_remediation_callback_delivery.sql +7 -0
  40. package/src/plugins/ai-call-by-id.test.ts +73 -0
  41. package/src/plugins/ai-receiver.ts +15 -0
  42. package/src/plugins/autosre-routes.ts +409 -147
  43. package/src/plugins/connected-routes/deep-link-shapes.test.ts +44 -0
  44. package/src/plugins/connected-routes/manifest.test.ts +224 -0
  45. package/src/plugins/connected-routes/manifest.ts +131 -28
  46. package/src/plugins/connected-routes-links.test.ts +96 -0
  47. package/src/plugins/connected-routes.test.ts +13 -11
  48. package/src/plugins/connected-routes.ts +53 -20
  49. package/src/plugins/eval-cases-routes.ts +33 -10
  50. package/src/plugins/evidence-retrieval-routes.ts +89 -24
  51. package/dist/chunk-KMEZKGSO.js.map +0 -1
  52. package/dist/chunk-ZSF5XP3B.js.map +0 -1
  53. /package/dist/{openai-GSJZGFGL.js.map → openai-VDKVVZAV.js.map} +0 -0
@@ -0,0 +1,883 @@
1
+ /**
2
+ * Native-engine schema introspection and semantic parity comparator for
3
+ * SQLite (`node:sqlite` / D1) and PostgreSQL (`pg`).
4
+ *
5
+ * Workstream 4 (RFC 0014): Rather than interpreting Postgres DDL in SQLite or
6
+ * relying on regexes over raw `.sql` text, this module executes each migration
7
+ * suite on its native engine, introspects the resulting catalogs, normalizes
8
+ * documented logical representations (`INTEGER` <-> `bigint`/`integer`/`boolean`,
9
+ * `REAL` <-> `double precision`/`real`, `TEXT` <-> `text`/`jsonb`), and
10
+ * reports any structural or semantic drift across tables, columns, defaults,
11
+ * primary keys, unique/foreign-key/check constraints, and partial/B-tree
12
+ * indexes.
13
+ */
14
+
15
+ import type { DatabaseSync } from "node:sqlite";
16
+
17
+ export type LogicalColumnType =
18
+ | "text"
19
+ | "integer"
20
+ | "float"
21
+ | "timestamptz"
22
+ | "other";
23
+
24
+ export interface NormalizedColumn {
25
+ name: string;
26
+ rawType: string;
27
+ logicalType: LogicalColumnType;
28
+ notNull: boolean;
29
+ defaultValue: string | null;
30
+ }
31
+
32
+ export interface NormalizedForeignKey {
33
+ columns: string[];
34
+ refTable: string;
35
+ refColumns: string[];
36
+ onDelete: string;
37
+ onUpdate: string;
38
+ }
39
+
40
+ export interface NormalizedIndex {
41
+ name: string;
42
+ unique: boolean;
43
+ method: "btree" | "gin" | string;
44
+ columns: string[];
45
+ where: string | null;
46
+ }
47
+
48
+ export interface NormalizedTableSchema {
49
+ name: string;
50
+ columns: Record<string, NormalizedColumn>;
51
+ primaryKey: string[];
52
+ uniqueConstraints: string[][];
53
+ foreignKeys: NormalizedForeignKey[];
54
+ checkConstraints: string[];
55
+ indexes: Record<string, NormalizedIndex>;
56
+ }
57
+
58
+ export interface NormalizedDatabaseSchema {
59
+ engine: "sqlite" | "postgres";
60
+ tables: Record<string, NormalizedTableSchema>;
61
+ }
62
+
63
+ export interface SchemaParityDifference {
64
+ table: string;
65
+ kind:
66
+ | "missing_table"
67
+ | "missing_column"
68
+ | "column_type"
69
+ | "column_nullability"
70
+ | "column_default"
71
+ | "primary_key"
72
+ | "unique_constraint"
73
+ | "foreign_key"
74
+ | "check_constraint"
75
+ | "missing_index"
76
+ | "index_unique"
77
+ | "index_columns"
78
+ | "index_where";
79
+ detail: string;
80
+ }
81
+
82
+ export interface SchemaParityReport {
83
+ ok: boolean;
84
+ tableCount: number;
85
+ differences: SchemaParityDifference[];
86
+ }
87
+
88
+ export interface PgQueryClient {
89
+ query<R = Record<string, unknown>>(
90
+ sql: string,
91
+ params?: unknown[],
92
+ ): Promise<{ rows: R[] }>;
93
+ }
94
+
95
+ /**
96
+ * Narrowly scoped, documented engine representation exceptions:
97
+ * 1. Migration 040 (`evidence_retrieval_refs`, `evidence_ref_expansions`)
98
+ * declares non-retention audit timestamps as `TIMESTAMPTZ` on Postgres and
99
+ * `TEXT` on SQLite.
100
+ * 2. Postgres migrations add `USING GIN` indexes on `JSONB` columns (see
101
+ * `migrations-postgres/README.md`).
102
+ */
103
+ export const DOCUMENTED_TIMESTAMPTZ_COLUMNS = new Set<string>([
104
+ "evidence_ref_expansions.expanded_at",
105
+ "evidence_retrieval_refs.issued_at",
106
+ "evidence_retrieval_refs.last_seen_at",
107
+ "evidence_retrieval_refs.expires_at",
108
+ ]);
109
+
110
+ export const DOCUMENTED_POSTGRES_GIN_INDEXES = new Set<string>([
111
+ "actions.idx_actions_attrs_gin",
112
+ "agent_runs.idx_agent_runs_metadata_gin",
113
+ "eval_case_results.idx_eval_case_results_details_gin",
114
+ "eval_cases.idx_eval_cases_metadata_gin",
115
+ "eval_results.idx_eval_results_rubric_gin",
116
+ "eval_runs.idx_eval_runs_metadata_gin",
117
+ "profile_blobs.idx_profile_blobs_resource_attrs_gin",
118
+ "retrieval_events.idx_retrieval_events_docs_gin",
119
+ "telemetry_spans.idx_spans_attributes_gin",
120
+ "tool_calls.idx_tool_calls_args_redacted_gin",
121
+ "tool_calls.idx_tool_calls_result_redacted_gin",
122
+ ]);
123
+
124
+ export function normalizeSqliteType(rawType: string): LogicalColumnType {
125
+ const upper = rawType.trim().toUpperCase();
126
+ if (upper === "TEXT" || upper.startsWith("VARCHAR")) return "text";
127
+ if (upper === "INTEGER" || upper === "INT" || upper === "BIGINT") {
128
+ return "integer";
129
+ }
130
+ if (upper === "REAL" || upper === "DOUBLE" || upper === "FLOAT") {
131
+ return "float";
132
+ }
133
+ return "other";
134
+ }
135
+
136
+ export function normalizePostgresType(rawType: string): LogicalColumnType {
137
+ const lower = rawType.trim().toLowerCase();
138
+ if (
139
+ lower === "text" ||
140
+ lower === "character varying" ||
141
+ lower === "jsonb" ||
142
+ lower === "json"
143
+ ) {
144
+ return "text";
145
+ }
146
+ if (
147
+ lower === "integer" ||
148
+ lower === "bigint" ||
149
+ lower === "smallint" ||
150
+ lower === "boolean"
151
+ ) {
152
+ return "integer";
153
+ }
154
+ if (lower === "double precision" || lower === "real" || lower === "numeric") {
155
+ return "float";
156
+ }
157
+ if (lower === "timestamp with time zone") {
158
+ return "timestamptz";
159
+ }
160
+ return "other";
161
+ }
162
+
163
+ export function normalizeDefaultValue(
164
+ rawDefault: string | null | undefined,
165
+ rawType: string,
166
+ ): string | null {
167
+ if (rawDefault === null || rawDefault === undefined) return null;
168
+ let expr = rawDefault.trim();
169
+ // Strip outer parentheses if balanced
170
+ while (expr.startsWith("(") && expr.endsWith(")")) {
171
+ const inner = expr.slice(1, -1).trim();
172
+ let depth = 0;
173
+ let balanced = true;
174
+ for (const ch of inner) {
175
+ if (ch === "(") depth += 1;
176
+ else if (ch === ")") {
177
+ depth -= 1;
178
+ if (depth < 0) {
179
+ balanced = false;
180
+ break;
181
+ }
182
+ }
183
+ }
184
+ if (balanced && depth === 0) {
185
+ expr = inner;
186
+ } else {
187
+ break;
188
+ }
189
+ }
190
+ // Strip Postgres type casts like ::text, ::jsonb, ::character varying
191
+ expr = expr.replace(/::[a-z_]+(?:\s+[a-z_]+)*/gi, "").trim();
192
+
193
+ // Normalize boolean/integer flag defaults (0/1 <-> false/true)
194
+ const lowerType = rawType.trim().toLowerCase();
195
+ if (
196
+ lowerType === "boolean" ||
197
+ lowerType === "integer" ||
198
+ lowerType === "bigint"
199
+ ) {
200
+ if (expr.toLowerCase() === "false" || expr === "'0'") return "0";
201
+ if (expr.toLowerCase() === "true" || expr === "'1'") return "1";
202
+ }
203
+
204
+ // Normalize current-timestamp defaults
205
+ if (
206
+ /^datetime\(\s*'now'\s*\)$/i.test(expr) ||
207
+ /^current_timestamp$/i.test(expr) ||
208
+ /^now\(\)$/i.test(expr)
209
+ ) {
210
+ return "CURRENT_TIMESTAMP";
211
+ }
212
+
213
+ return expr;
214
+ }
215
+
216
+ /**
217
+ * Normalizes a partial-index `WHERE` predicate or `CHECK` constraint expression
218
+ * across SQLite and Postgres while preserving column names, operators, and
219
+ * literal values so any semantic predicate change is caught.
220
+ */
221
+ export function normalizeSqlPredicate(
222
+ rawPredicate: string | null | undefined,
223
+ ): string | null {
224
+ if (!rawPredicate) return null;
225
+ let s = rawPredicate.trim();
226
+ // Remove leading WHERE or CHECK keyword if present
227
+ s = s.replace(/^(?:WHERE|CHECK)\s+/i, "").trim();
228
+
229
+ // Strip Postgres type casts such as ::text, ::jsonb, ::integer, ::bigint
230
+ s = s.replace(/::[a-z_]+(?:\s+[a-z_]+)*(?:\[\])?/gi, "");
231
+
232
+ // Convert Postgres `col = ANY (ARRAY['a', 'b'])` into `col IN ('a', 'b')`
233
+ s = s.replace(
234
+ /([a-zA-Z0-9_.]+)\s*=\s*ANY\s*\(\s*ARRAY\[([^\]]+)\]\s*\)/gi,
235
+ (_match, col: string, list: string) => `${col} IN (${list})`,
236
+ );
237
+
238
+ // Normalize <> to !=
239
+ s = s.replace(/<>/g, "!=");
240
+
241
+ // Strip redundant parentheses around simple comparisons like (col IS NOT NULL)
242
+ const stripBalancedParens = (input: string): string => {
243
+ let cur = input.trim();
244
+ while (cur.startsWith("(") && cur.endsWith(")")) {
245
+ const inner = cur.slice(1, -1).trim();
246
+ let depth = 0;
247
+ let inQuotes = false;
248
+ let balanced = true;
249
+ for (let i = 0; i < inner.length; i += 1) {
250
+ const ch = inner[i];
251
+ if (ch === "'") inQuotes = !inQuotes;
252
+ else if (!inQuotes) {
253
+ if (ch === "(") depth += 1;
254
+ else if (ch === ")") {
255
+ depth -= 1;
256
+ if (depth < 0) {
257
+ balanced = false;
258
+ break;
259
+ }
260
+ }
261
+ }
262
+ }
263
+ if (balanced && depth === 0) {
264
+ cur = inner;
265
+ } else {
266
+ break;
267
+ }
268
+ }
269
+ return cur;
270
+ };
271
+
272
+ s = stripBalancedParens(s);
273
+ // Also strip parentheses around simple `(a IS NOT NULL)` / `(a != '[]')` clauses inside AND chains
274
+ s = s.replace(/\(([^()]+)\)/g, (full, inner: string) => {
275
+ if (/^\s*'[a-zA-Z0-9_'\s,]+'\s*$/.test(inner) || inner.includes(",")) {
276
+ // Keep `IN ('a', 'b')` list parentheses intact
277
+ return full;
278
+ }
279
+ return inner.trim();
280
+ });
281
+
282
+ // Normalize SQL keywords outside single-quoted literals to uppercase and collapse whitespace
283
+ const parts = s.split(/('(?:''|[^'])*')/g);
284
+ s = parts
285
+ .map((part, idx) => {
286
+ if (idx % 2 === 1) return part;
287
+ return part
288
+ .replace(/\s+/g, " ")
289
+ .replace(/\b(is|not|null|and|or|in|true|false)\b/gi, (kw) =>
290
+ kw.toUpperCase(),
291
+ );
292
+ })
293
+ .join("")
294
+ .replace(/\s+/g, " ")
295
+ .replace(/\(\s+/g, "(")
296
+ .replace(/\s+\)/g, ")")
297
+ .trim();
298
+
299
+ return s;
300
+ }
301
+
302
+ function parseIndexColumnsFromSql(
303
+ indexSql: string,
304
+ engine: "sqlite" | "postgres",
305
+ ): { columns: string[]; where: string | null } {
306
+ const cleaned = indexSql.replace(/\s+/g, " ").trim();
307
+ let colsPart = "";
308
+ let restAfterCols = "";
309
+
310
+ const onMatch =
311
+ engine === "postgres"
312
+ ? /USING\s+[a-z0-9_]+\s*\(/i.exec(cleaned)
313
+ : /ON\s+[a-zA-Z0-9_."]+\s*\(/i.exec(cleaned);
314
+
315
+ if (onMatch && onMatch.index !== undefined) {
316
+ const start = onMatch.index + onMatch[0].length;
317
+ let depth = 1;
318
+ let inQuotes = false;
319
+ let end = start;
320
+ for (; end < cleaned.length; end += 1) {
321
+ const ch = cleaned[end];
322
+ if (ch === "'") inQuotes = !inQuotes;
323
+ else if (!inQuotes) {
324
+ if (ch === "(") depth += 1;
325
+ else if (ch === ")") {
326
+ depth -= 1;
327
+ if (depth === 0) break;
328
+ }
329
+ }
330
+ }
331
+ colsPart = cleaned.slice(start, end);
332
+ restAfterCols = cleaned.slice(end + 1).trim();
333
+ }
334
+
335
+ const columns = colsPart
336
+ .split(",")
337
+ .map((c) => {
338
+ const tokens = c.trim().replace(/"/g, "").split(/\s+/);
339
+ const dir =
340
+ tokens.length > 1 && tokens[tokens.length - 1].toUpperCase() === "DESC"
341
+ ? "DESC"
342
+ : "ASC";
343
+ const colExpr =
344
+ tokens.length > 1 &&
345
+ (tokens[tokens.length - 1].toUpperCase() === "ASC" ||
346
+ tokens[tokens.length - 1].toUpperCase() === "DESC")
347
+ ? tokens.slice(0, -1).join(" ")
348
+ : tokens.join(" ");
349
+ return `${colExpr} ${dir}`;
350
+ })
351
+ .filter(Boolean);
352
+
353
+ const whereMatch = /^WHERE\s+(.+)$/i.exec(restAfterCols);
354
+ const where = whereMatch ? normalizeSqlPredicate(whereMatch[1]) : null;
355
+ return { columns, where };
356
+ }
357
+
358
+ function extractSqliteCheckConstraints(tableSql: string | null): string[] {
359
+ if (!tableSql) return [];
360
+ const checks: string[] = [];
361
+ const upper = tableSql.toUpperCase();
362
+ let pos = 0;
363
+ while (pos < tableSql.length) {
364
+ const match = /\bCHECK\s*\(/gi;
365
+ match.lastIndex = pos;
366
+ const found = match.exec(upper);
367
+ if (!found || found.index === undefined) break;
368
+ const start = found.index + found[0].length;
369
+ let depth = 1;
370
+ let inQuotes = false;
371
+ let end = start;
372
+ for (; end < tableSql.length; end += 1) {
373
+ const ch = tableSql[end];
374
+ if (ch === "'") inQuotes = !inQuotes;
375
+ else if (!inQuotes) {
376
+ if (ch === "(") depth += 1;
377
+ else if (ch === ")") {
378
+ depth -= 1;
379
+ if (depth === 0) break;
380
+ }
381
+ }
382
+ }
383
+ const normalized = normalizeSqlPredicate(tableSql.slice(start, end));
384
+ if (normalized) checks.push(normalized);
385
+ pos = end + 1;
386
+ }
387
+ return checks.sort();
388
+ }
389
+
390
+ export function introspectSqliteSchema(
391
+ db: DatabaseSync,
392
+ ): NormalizedDatabaseSchema {
393
+ const tableRows = db
394
+ .prepare(
395
+ "SELECT name, sql FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
396
+ )
397
+ .all() as Array<{ name: string; sql: string | null }>;
398
+
399
+ const tables: Record<string, NormalizedTableSchema> = {};
400
+
401
+ for (const { name: tableName, sql: tableSql } of tableRows) {
402
+ const colRows = db
403
+ .prepare(`PRAGMA table_info(${tableName})`)
404
+ .all() as Array<{
405
+ cid: number;
406
+ name: string;
407
+ type: string;
408
+ notnull: number;
409
+ dflt_value: string | null;
410
+ pk: number;
411
+ }>;
412
+
413
+ const columns: Record<string, NormalizedColumn> = {};
414
+ for (const c of colRows) {
415
+ columns[c.name] = {
416
+ name: c.name,
417
+ rawType: c.type,
418
+ logicalType: normalizeSqliteType(c.type),
419
+ notNull: c.notnull === 1 || c.pk > 0,
420
+ defaultValue: normalizeDefaultValue(c.dflt_value, c.type),
421
+ };
422
+ }
423
+
424
+ const primaryKey = colRows
425
+ .filter((c) => c.pk > 0)
426
+ .sort((a, b) => a.pk - b.pk)
427
+ .map((c) => c.name);
428
+
429
+ const indexList = db
430
+ .prepare(`PRAGMA index_list(${tableName})`)
431
+ .all() as Array<{
432
+ seq: number;
433
+ name: string;
434
+ unique: number;
435
+ origin: string;
436
+ partial: number;
437
+ }>;
438
+
439
+ const uniqueConstraints: string[][] = [];
440
+ for (const idx of indexList) {
441
+ if (idx.origin === "u") {
442
+ const info = db
443
+ .prepare(`PRAGMA index_info(${idx.name})`)
444
+ .all() as Array<{ seqno: number; cid: number; name: string }>;
445
+ uniqueConstraints.push(
446
+ info.sort((a, b) => a.seqno - b.seqno).map((x) => x.name),
447
+ );
448
+ }
449
+ }
450
+ uniqueConstraints.sort((a, b) => a.join(",").localeCompare(b.join(",")));
451
+
452
+ const fkRows = db
453
+ .prepare(`PRAGMA foreign_key_list(${tableName})`)
454
+ .all() as Array<{
455
+ id: number;
456
+ seq: number;
457
+ table: string;
458
+ from: string;
459
+ to: string;
460
+ on_update: string;
461
+ on_delete: string;
462
+ }>;
463
+ const fkById = new Map<number, typeof fkRows>();
464
+ for (const row of fkRows) {
465
+ const group = fkById.get(row.id) ?? [];
466
+ group.push(row);
467
+ fkById.set(row.id, group);
468
+ }
469
+ const foreignKeys: NormalizedForeignKey[] = [...fkById.values()]
470
+ .map((group) => {
471
+ const sorted = group.sort((a, b) => a.seq - b.seq);
472
+ return {
473
+ columns: sorted.map((r) => r.from),
474
+ refTable: sorted[0].table,
475
+ refColumns: sorted.map((r) => r.to),
476
+ onDelete: (sorted[0].on_delete || "NO ACTION").toUpperCase(),
477
+ onUpdate: (sorted[0].on_update || "NO ACTION").toUpperCase(),
478
+ };
479
+ })
480
+ .sort((a, b) =>
481
+ `${a.columns.join(",")}->${a.refTable}`.localeCompare(
482
+ `${b.columns.join(",")}->${b.refTable}`,
483
+ ),
484
+ );
485
+
486
+ const checkConstraints = extractSqliteCheckConstraints(tableSql);
487
+
488
+ const explicitIdxRows = db
489
+ .prepare(
490
+ "SELECT name, sql FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND sql IS NOT NULL ORDER BY name",
491
+ )
492
+ .all(tableName) as Array<{ name: string; sql: string }>;
493
+
494
+ const indexes: Record<string, NormalizedIndex> = {};
495
+ for (const idxRow of explicitIdxRows) {
496
+ const listEntry = indexList.find((x) => x.name === idxRow.name);
497
+ const parsed = parseIndexColumnsFromSql(idxRow.sql, "sqlite");
498
+ indexes[idxRow.name] = {
499
+ name: idxRow.name,
500
+ unique:
501
+ listEntry !== undefined
502
+ ? listEntry.unique === 1
503
+ : /^CREATE\s+UNIQUE\s+INDEX/i.test(idxRow.sql.trim()),
504
+ method: "btree",
505
+ columns: parsed.columns,
506
+ where: parsed.where,
507
+ };
508
+ }
509
+
510
+ tables[tableName] = {
511
+ name: tableName,
512
+ columns,
513
+ primaryKey,
514
+ uniqueConstraints,
515
+ foreignKeys,
516
+ checkConstraints,
517
+ indexes,
518
+ };
519
+ }
520
+
521
+ return { engine: "sqlite", tables };
522
+ }
523
+
524
+ export async function introspectPostgresSchema(
525
+ client: PgQueryClient,
526
+ schemaName = "public",
527
+ ): Promise<NormalizedDatabaseSchema> {
528
+ const tableRes = await client.query<{ table_name: string }>(
529
+ `SELECT table_name
530
+ FROM information_schema.tables
531
+ WHERE table_schema = $1 AND table_type = 'BASE TABLE'
532
+ ORDER BY table_name`,
533
+ [schemaName],
534
+ );
535
+
536
+ const tables: Record<string, NormalizedTableSchema> = {};
537
+
538
+ for (const { table_name: tableName } of tableRes.rows) {
539
+ const colRes = await client.query<{
540
+ column_name: string;
541
+ data_type: string;
542
+ is_nullable: string;
543
+ column_default: string | null;
544
+ }>(
545
+ `SELECT column_name, data_type, is_nullable, column_default
546
+ FROM information_schema.columns
547
+ WHERE table_schema = $1 AND table_name = $2
548
+ ORDER BY ordinal_position`,
549
+ [schemaName, tableName],
550
+ );
551
+
552
+ const columns: Record<string, NormalizedColumn> = {};
553
+ for (const c of colRes.rows) {
554
+ columns[c.column_name] = {
555
+ name: c.column_name,
556
+ rawType: c.data_type,
557
+ logicalType: normalizePostgresType(c.data_type),
558
+ notNull: c.is_nullable === "NO",
559
+ defaultValue: normalizeDefaultValue(c.column_default, c.data_type),
560
+ };
561
+ }
562
+
563
+ const pkRes = await client.query<{ attname: string }>(
564
+ `SELECT a.attname
565
+ FROM pg_index i
566
+ JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
567
+ JOIN pg_class t ON t.oid = i.indrelid
568
+ JOIN pg_namespace n ON n.oid = t.relnamespace
569
+ WHERE n.nspname = $1 AND t.relname = $2 AND i.indisprimary
570
+ ORDER BY array_position(i.indkey, a.attnum)`,
571
+ [schemaName, tableName],
572
+ );
573
+ const primaryKey = pkRes.rows.map((r) => r.attname);
574
+
575
+ const constraintRes = await client.query<{
576
+ contype: string;
577
+ def: string;
578
+ }>(
579
+ `SELECT c.contype, pg_get_constraintdef(c.oid) AS def
580
+ FROM pg_constraint c
581
+ JOIN pg_class t ON t.oid = c.conrelid
582
+ JOIN pg_namespace n ON n.oid = t.relnamespace
583
+ WHERE n.nspname = $1 AND t.relname = $2 AND c.contype IN ('u', 'f', 'c')
584
+ ORDER BY c.conname`,
585
+ [schemaName, tableName],
586
+ );
587
+
588
+ const uniqueConstraints: string[][] = [];
589
+ const foreignKeys: NormalizedForeignKey[] = [];
590
+ const checkConstraints: string[] = [];
591
+
592
+ for (const row of constraintRes.rows) {
593
+ if (row.contype === "u") {
594
+ const m = /^UNIQUE\s*\(([^)]+)\)/i.exec(row.def.trim());
595
+ if (m) {
596
+ uniqueConstraints.push(
597
+ m[1].split(",").map((s) => s.trim().replace(/"/g, "")),
598
+ );
599
+ }
600
+ } else if (row.contype === "f") {
601
+ const m =
602
+ /^FOREIGN KEY\s*\(([^)]+)\)\s*REFERENCES\s+([a-zA-Z0-9_."]+)\(([^)]+)\)(.*)$/i.exec(
603
+ row.def.trim(),
604
+ );
605
+ if (m) {
606
+ const cols = m[1].split(",").map((s) => s.trim().replace(/"/g, ""));
607
+ const refTable = m[2].replace(/^[^.]+\./, "").replace(/"/g, "");
608
+ const refCols = m[3]
609
+ .split(",")
610
+ .map((s) => s.trim().replace(/"/g, ""));
611
+ const tail = m[4] ?? "";
612
+ const onDelete =
613
+ /ON DELETE\s+(CASCADE|SET NULL|SET DEFAULT|RESTRICT|NO ACTION)/i
614
+ .exec(tail)?.[1]
615
+ ?.toUpperCase() ?? "NO ACTION";
616
+ const onUpdate =
617
+ /ON UPDATE\s+(CASCADE|SET NULL|SET DEFAULT|RESTRICT|NO ACTION)/i
618
+ .exec(tail)?.[1]
619
+ ?.toUpperCase() ?? "NO ACTION";
620
+ foreignKeys.push({
621
+ columns: cols,
622
+ refTable,
623
+ refColumns: refCols,
624
+ onDelete,
625
+ onUpdate,
626
+ });
627
+ }
628
+ } else if (row.contype === "c") {
629
+ const norm = normalizeSqlPredicate(row.def);
630
+ if (norm) checkConstraints.push(norm);
631
+ }
632
+ }
633
+
634
+ uniqueConstraints.sort((a, b) => a.join(",").localeCompare(b.join(",")));
635
+ foreignKeys.sort((a, b) =>
636
+ `${a.columns.join(",")}->${a.refTable}`.localeCompare(
637
+ `${b.columns.join(",")}->${b.refTable}`,
638
+ ),
639
+ );
640
+ checkConstraints.sort();
641
+
642
+ const idxRes = await client.query<{
643
+ name: string;
644
+ sql: string;
645
+ is_unique: boolean;
646
+ amname: string;
647
+ }>(
648
+ `SELECT i.relname AS name,
649
+ pg_get_indexdef(ix.indexrelid) AS sql,
650
+ ix.indisunique AS is_unique,
651
+ am.amname AS amname
652
+ FROM pg_index ix
653
+ JOIN pg_class t ON t.oid = ix.indrelid
654
+ JOIN pg_class i ON i.oid = ix.indexrelid
655
+ JOIN pg_am am ON am.oid = i.relam
656
+ JOIN pg_namespace n ON n.oid = t.relnamespace
657
+ LEFT JOIN pg_constraint c ON c.conindid = ix.indexrelid
658
+ WHERE n.nspname = $1 AND t.relname = $2 AND c.oid IS NULL
659
+ ORDER BY i.relname`,
660
+ [schemaName, tableName],
661
+ );
662
+
663
+ const indexes: Record<string, NormalizedIndex> = {};
664
+ for (const idxRow of idxRes.rows) {
665
+ const parsed = parseIndexColumnsFromSql(idxRow.sql, "postgres");
666
+ indexes[idxRow.name] = {
667
+ name: idxRow.name,
668
+ unique: Boolean(idxRow.is_unique),
669
+ method: idxRow.amname,
670
+ columns: parsed.columns,
671
+ where: parsed.where,
672
+ };
673
+ }
674
+
675
+ tables[tableName] = {
676
+ name: tableName,
677
+ columns,
678
+ primaryKey,
679
+ uniqueConstraints,
680
+ foreignKeys,
681
+ checkConstraints,
682
+ indexes,
683
+ };
684
+ }
685
+
686
+ return { engine: "postgres", tables };
687
+ }
688
+
689
+ export function compareDatabaseSchemas(
690
+ sqliteSchema: NormalizedDatabaseSchema,
691
+ postgresSchema: NormalizedDatabaseSchema,
692
+ ): SchemaParityReport {
693
+ const differences: SchemaParityDifference[] = [];
694
+ const allTableNames = new Set([
695
+ ...Object.keys(sqliteSchema.tables),
696
+ ...Object.keys(postgresSchema.tables),
697
+ ]);
698
+
699
+ for (const tableName of [...allTableNames].sort()) {
700
+ const sTable = sqliteSchema.tables[tableName];
701
+ const pTable = postgresSchema.tables[tableName];
702
+ if (!sTable) {
703
+ differences.push({
704
+ table: tableName,
705
+ kind: "missing_table",
706
+ detail: `Table "${tableName}" exists on Postgres but is missing on SQLite`,
707
+ });
708
+ continue;
709
+ }
710
+ if (!pTable) {
711
+ differences.push({
712
+ table: tableName,
713
+ kind: "missing_table",
714
+ detail: `Table "${tableName}" exists on SQLite but is missing on Postgres`,
715
+ });
716
+ continue;
717
+ }
718
+
719
+ // Compare columns
720
+ const allColNames = new Set([
721
+ ...Object.keys(sTable.columns),
722
+ ...Object.keys(pTable.columns),
723
+ ]);
724
+ for (const colName of [...allColNames].sort()) {
725
+ const sCol = sTable.columns[colName];
726
+ const pCol = pTable.columns[colName];
727
+ if (!sCol) {
728
+ differences.push({
729
+ table: tableName,
730
+ kind: "missing_column",
731
+ detail: `Column "${tableName}.${colName}" exists on Postgres (${pCol.rawType}) but is missing on SQLite`,
732
+ });
733
+ continue;
734
+ }
735
+ if (!pCol) {
736
+ differences.push({
737
+ table: tableName,
738
+ kind: "missing_column",
739
+ detail: `Column "${tableName}.${colName}" exists on SQLite (${sCol.rawType}) but is missing on Postgres`,
740
+ });
741
+ continue;
742
+ }
743
+
744
+ const fullColKey = `${tableName}.${colName}`;
745
+ const isDocumentedTimestamptz =
746
+ DOCUMENTED_TIMESTAMPTZ_COLUMNS.has(fullColKey) &&
747
+ sCol.logicalType === "text" &&
748
+ pCol.logicalType === "timestamptz";
749
+
750
+ if (!isDocumentedTimestamptz && sCol.logicalType !== pCol.logicalType) {
751
+ differences.push({
752
+ table: tableName,
753
+ kind: "column_type",
754
+ detail: `Column "${fullColKey}" type mismatch: SQLite ${sCol.rawType} (${sCol.logicalType}) vs Postgres ${pCol.rawType} (${pCol.logicalType})`,
755
+ });
756
+ }
757
+
758
+ if (sCol.notNull !== pCol.notNull) {
759
+ differences.push({
760
+ table: tableName,
761
+ kind: "column_nullability",
762
+ detail: `Column "${fullColKey}" nullability mismatch: SQLite notNull=${sCol.notNull} vs Postgres notNull=${pCol.notNull}`,
763
+ });
764
+ }
765
+
766
+ if (sCol.defaultValue !== pCol.defaultValue) {
767
+ differences.push({
768
+ table: tableName,
769
+ kind: "column_default",
770
+ detail: `Column "${fullColKey}" default mismatch: SQLite ${JSON.stringify(sCol.defaultValue)} vs Postgres ${JSON.stringify(pCol.defaultValue)}`,
771
+ });
772
+ }
773
+ }
774
+
775
+ // Compare Primary Key
776
+ if (
777
+ JSON.stringify(sTable.primaryKey) !== JSON.stringify(pTable.primaryKey)
778
+ ) {
779
+ differences.push({
780
+ table: tableName,
781
+ kind: "primary_key",
782
+ detail: `Primary key mismatch on "${tableName}": SQLite (${sTable.primaryKey.join(", ")}) vs Postgres (${pTable.primaryKey.join(", ")})`,
783
+ });
784
+ }
785
+
786
+ // Compare Unique Constraints
787
+ if (
788
+ JSON.stringify(sTable.uniqueConstraints) !==
789
+ JSON.stringify(pTable.uniqueConstraints)
790
+ ) {
791
+ differences.push({
792
+ table: tableName,
793
+ kind: "unique_constraint",
794
+ detail: `Unique constraints mismatch on "${tableName}": SQLite ${JSON.stringify(sTable.uniqueConstraints)} vs Postgres ${JSON.stringify(pTable.uniqueConstraints)}`,
795
+ });
796
+ }
797
+
798
+ // Compare Foreign Keys
799
+ if (
800
+ JSON.stringify(sTable.foreignKeys) !== JSON.stringify(pTable.foreignKeys)
801
+ ) {
802
+ differences.push({
803
+ table: tableName,
804
+ kind: "foreign_key",
805
+ detail: `Foreign keys mismatch on "${tableName}": SQLite ${JSON.stringify(sTable.foreignKeys)} vs Postgres ${JSON.stringify(pTable.foreignKeys)}`,
806
+ });
807
+ }
808
+
809
+ // Compare Check Constraints
810
+ if (
811
+ JSON.stringify(sTable.checkConstraints) !==
812
+ JSON.stringify(pTable.checkConstraints)
813
+ ) {
814
+ differences.push({
815
+ table: tableName,
816
+ kind: "check_constraint",
817
+ detail: `Check constraints mismatch on "${tableName}": SQLite ${JSON.stringify(sTable.checkConstraints)} vs Postgres ${JSON.stringify(pTable.checkConstraints)}`,
818
+ });
819
+ }
820
+
821
+ // Compare Indexes (excluding documented Postgres-only GIN JSONB indexes)
822
+ const allIndexNames = new Set([
823
+ ...Object.keys(sTable.indexes),
824
+ ...Object.keys(pTable.indexes),
825
+ ]);
826
+ for (const idxName of [...allIndexNames].sort()) {
827
+ const fullIdxKey = `${tableName}.${idxName}`;
828
+ const sIdx = sTable.indexes[idxName];
829
+ const pIdx = pTable.indexes[idxName];
830
+ if (!sIdx && pIdx) {
831
+ if (
832
+ pIdx.method === "gin" &&
833
+ DOCUMENTED_POSTGRES_GIN_INDEXES.has(fullIdxKey)
834
+ ) {
835
+ continue;
836
+ }
837
+ differences.push({
838
+ table: tableName,
839
+ kind: "missing_index",
840
+ detail: `Index "${fullIdxKey}" exists on Postgres (${pIdx.method}) but is missing on SQLite`,
841
+ });
842
+ continue;
843
+ }
844
+ if (sIdx && !pIdx) {
845
+ differences.push({
846
+ table: tableName,
847
+ kind: "missing_index",
848
+ detail: `Index "${fullIdxKey}" exists on SQLite (${sIdx.columns.join(", ")}) but is missing on Postgres`,
849
+ });
850
+ continue;
851
+ }
852
+ if (sIdx && pIdx) {
853
+ if (sIdx.unique !== pIdx.unique) {
854
+ differences.push({
855
+ table: tableName,
856
+ kind: "index_unique",
857
+ detail: `Index "${fullIdxKey}" uniqueness mismatch: SQLite unique=${sIdx.unique} vs Postgres unique=${pIdx.unique}`,
858
+ });
859
+ }
860
+ if (JSON.stringify(sIdx.columns) !== JSON.stringify(pIdx.columns)) {
861
+ differences.push({
862
+ table: tableName,
863
+ kind: "index_columns",
864
+ detail: `Index "${fullIdxKey}" columns mismatch: SQLite (${sIdx.columns.join(", ")}) vs Postgres (${pIdx.columns.join(", ")})`,
865
+ });
866
+ }
867
+ if (sIdx.where !== pIdx.where) {
868
+ differences.push({
869
+ table: tableName,
870
+ kind: "index_where",
871
+ detail: `Index "${fullIdxKey}" partial WHERE mismatch: SQLite ${JSON.stringify(sIdx.where)} vs Postgres ${JSON.stringify(pIdx.where)}`,
872
+ });
873
+ }
874
+ }
875
+ }
876
+ }
877
+
878
+ return {
879
+ ok: differences.length === 0,
880
+ tableCount: allTableNames.size,
881
+ differences,
882
+ };
883
+ }