@happyvertical/smrt-core 0.51.28 → 0.51.30

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 (79) hide show
  1. package/AGENTS.md +20 -0
  2. package/README.md +47 -0
  3. package/agents/build-knowledge.md +16 -0
  4. package/agents/change-feed.md +95 -4
  5. package/agents/change-signals.md +6 -1
  6. package/agents/run-once.md +268 -0
  7. package/agents/schema-paths.md +19 -5
  8. package/dist/browser.js +4 -3
  9. package/dist/change-feed-authz.d.ts +141 -0
  10. package/dist/change-feed-authz.d.ts.map +1 -0
  11. package/dist/change-feed-authz.js +260 -0
  12. package/dist/change-feed-authz.js.map +1 -0
  13. package/dist/change-feed.d.ts +15 -0
  14. package/dist/change-feed.d.ts.map +1 -1
  15. package/dist/change-feed.js +4 -0
  16. package/dist/change-feed.js.map +1 -1
  17. package/dist/errors.d.ts +28 -0
  18. package/dist/errors.d.ts.map +1 -1
  19. package/dist/errors.js +36 -1
  20. package/dist/errors.js.map +1 -1
  21. package/dist/generators/changes-route.d.ts.map +1 -1
  22. package/dist/generators/changes-route.js +13 -3
  23. package/dist/generators/changes-route.js.map +1 -1
  24. package/dist/generators/events-route.d.ts +35 -4
  25. package/dist/generators/events-route.d.ts.map +1 -1
  26. package/dist/generators/events-route.js +78 -14
  27. package/dist/generators/events-route.js.map +1 -1
  28. package/dist/index.d.ts +2 -0
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +6 -3
  31. package/dist/knowledge-graph.d.ts.map +1 -1
  32. package/dist/knowledge-graph.js +1 -1
  33. package/dist/knowledge-graph.js.map +1 -1
  34. package/dist/manifest/static-manifest.js +1 -1
  35. package/dist/manifest/static-manifest.js.map +1 -1
  36. package/dist/manifest/store.js +1 -1
  37. package/dist/manifest.json +1 -1
  38. package/dist/migrations/differ.d.ts +43 -1
  39. package/dist/migrations/differ.d.ts.map +1 -1
  40. package/dist/migrations/differ.js +69 -0
  41. package/dist/migrations/differ.js.map +1 -1
  42. package/dist/run-once.d.ts +80 -0
  43. package/dist/run-once.d.ts.map +1 -0
  44. package/dist/run-once.js +311 -0
  45. package/dist/run-once.js.map +1 -0
  46. package/dist/schema/bounded-concurrency.d.ts +31 -0
  47. package/dist/schema/bounded-concurrency.d.ts.map +1 -0
  48. package/dist/schema/bounded-concurrency.js +43 -0
  49. package/dist/schema/bounded-concurrency.js.map +1 -0
  50. package/dist/schema/foreign-key-orphan-report.d.ts +21 -0
  51. package/dist/schema/foreign-key-orphan-report.d.ts.map +1 -1
  52. package/dist/schema/foreign-key-orphan-report.js +90 -55
  53. package/dist/schema/foreign-key-orphan-report.js.map +1 -1
  54. package/dist/smrt-knowledge.json +20 -14
  55. package/dist/system/index.d.ts +1 -0
  56. package/dist/system/index.d.ts.map +1 -1
  57. package/dist/system/index.js +3 -2
  58. package/dist/system/retention.d.ts +53 -4
  59. package/dist/system/retention.d.ts.map +1 -1
  60. package/dist/system/retention.js +67 -14
  61. package/dist/system/retention.js.map +1 -1
  62. package/dist/system/schema.d.ts +33 -1
  63. package/dist/system/schema.d.ts.map +1 -1
  64. package/dist/system/schema.js +60 -3
  65. package/dist/system/schema.js.map +1 -1
  66. package/dist/vite-plugin/changes-route.d.ts.map +1 -1
  67. package/dist/vite-plugin/changes-route.js +21 -5
  68. package/dist/vite-plugin/changes-route.js.map +1 -1
  69. package/dist/vite-plugin/events-route.d.ts.map +1 -1
  70. package/dist/vite-plugin/events-route.js +9 -0
  71. package/dist/vite-plugin/events-route.js.map +1 -1
  72. package/dist/vite-plugin/index.d.ts.map +1 -1
  73. package/dist/vite-plugin/index.js +14 -0
  74. package/dist/vite-plugin/index.js.map +1 -1
  75. package/dist/vite-plugin/worker-registration.d.ts +34 -0
  76. package/dist/vite-plugin/worker-registration.d.ts.map +1 -0
  77. package/dist/vite-plugin/worker-registration.js +108 -0
  78. package/dist/vite-plugin/worker-registration.js.map +1 -0
  79. package/package.json +10 -10
@@ -1,5 +1,6 @@
1
1
  import { renderForeignKeyOrphanDetector, schemaForeignKeysForEngine } from "./foreign-key-ddl.js";
2
2
  import { detectEngine } from "./ddl/index.js";
3
+ import { mapWithConcurrency } from "./bounded-concurrency.js";
3
4
  //#region src/schema/foreign-key-orphan-report.ts
4
5
  /**
5
6
  * Probe every manifest-declared foreign key for live orphan rows and report
@@ -12,7 +13,11 @@ import { detectEngine } from "./ddl/index.js";
12
13
  */
13
14
  async function collectForeignKeyOrphanCounts(db, schemas, options = {}) {
14
15
  const engine = resolveEngine(db, options.engineHint);
16
+ const isPostgres = engine === "postgres";
17
+ const introspectionConcurrency = options.introspectionConcurrency ?? (isPostgres ? 8 : 1);
18
+ const probeConcurrency = options.probeConcurrency ?? (isPostgres ? 4 : 1);
15
19
  const liveTables = await listLiveTables(db, engine);
20
+ const liveSchemaCache = new Map(options.liveSchemas ?? []);
16
21
  const fetchLiveSchema = async (tableName) => {
17
22
  try {
18
23
  return await db.getTableSchema?.(tableName);
@@ -20,15 +25,8 @@ async function collectForeignKeyOrphanCounts(db, schemas, options = {}) {
20
25
  return;
21
26
  }
22
27
  };
23
- const liveSchemaCache = /* @__PURE__ */ new Map();
24
- const getLiveSchema = async (tableName) => {
25
- if (liveSchemaCache.has(tableName)) return liveSchemaCache.get(tableName);
26
- const liveSchema = await fetchLiveSchema(tableName);
27
- liveSchemaCache.set(tableName, liveSchema);
28
- return liveSchema;
29
- };
30
- const counts = [];
31
- const skipped = [];
28
+ const plan = [];
29
+ const tablesToIntrospect = /* @__PURE__ */ new Set();
32
30
  for (const [childTable, schema] of Object.entries(schemas)) {
33
31
  const foreignKeys = schemaForeignKeysForEngine(schema, engine);
34
32
  for (const foreignKey of foreignKeys) {
@@ -40,58 +38,95 @@ async function collectForeignKeyOrphanCounts(db, schemas, options = {}) {
40
38
  parentColumn: foreignKey.referencesColumn
41
39
  };
42
40
  if (!liveTables.has(childTable)) {
43
- skipped.push({
44
- ...base,
45
- kind: "missing_table",
46
- reason: `Child table \`${childTable}\` does not exist in the live database.`
41
+ plan.push({
42
+ kind: "skip",
43
+ skip: {
44
+ ...base,
45
+ kind: "missing_table",
46
+ reason: `Child table \`${childTable}\` does not exist in the live database.`
47
+ }
47
48
  });
48
49
  continue;
49
50
  }
50
51
  if (!liveTables.has(parentTable)) {
51
- skipped.push({
52
- ...base,
53
- kind: "missing_table",
54
- reason: `Parent table \`${parentTable}\` does not exist in the live database.`
52
+ plan.push({
53
+ kind: "skip",
54
+ skip: {
55
+ ...base,
56
+ kind: "missing_table",
57
+ reason: `Parent table \`${parentTable}\` does not exist in the live database.`
58
+ }
55
59
  });
56
60
  continue;
57
61
  }
58
62
  const childColumnDefinition = schema.columns[foreignKey.column];
59
- const liveChildSchema = await getLiveSchema(childTable);
60
- const manifestNullable = childColumnDefinition?.notNull !== true;
61
- const liveNotNull = liveChildSchema?.columns[foreignKey.column]?.notNull === true;
62
- const nullable = manifestNullable && !liveNotNull;
63
63
  const parentColumnDefinition = schemas[parentTable]?.columns[foreignKey.referencesColumn];
64
- const { uuidComparison, uuidCastSide } = await resolveUuidCastSide(db, {
65
- engine,
66
- declaredUuidComparison: childColumnDefinition?.type === "UUID" && (parentColumnDefinition === void 0 || parentColumnDefinition.type === "UUID"),
67
- childTable,
68
- childColumn: foreignKey.column,
69
- parentTable,
70
- parentColumn: foreignKey.referencesColumn
71
- });
72
- const sql = renderForeignKeyOrphanDetector(childTable, foreignKey, {
73
- engine,
74
- countOnly: true,
75
- uuidComparison,
76
- uuidCastSide
64
+ const declaredUuidComparison = childColumnDefinition?.type === "UUID" && (parentColumnDefinition === void 0 || parentColumnDefinition.type === "UUID");
65
+ tablesToIntrospect.add(childTable);
66
+ if (declaredUuidComparison && isPostgres && typeof db.getTableSchema === "function") tablesToIntrospect.add(parentTable);
67
+ plan.push({
68
+ kind: "probe",
69
+ probe: {
70
+ base,
71
+ childTable,
72
+ foreignKey,
73
+ manifestNullable: childColumnDefinition?.notNull !== true,
74
+ declaredUuidComparison
75
+ }
77
76
  });
78
- try {
79
- const raw = normalizeQueryRows(await db.query(sql))[0]?.orphan_count;
80
- const orphanCount = Number(raw);
81
- counts.push({
77
+ }
78
+ }
79
+ if (typeof db.getTableSchema === "function") {
80
+ const pending = [...tablesToIntrospect].filter((tableName) => !liveSchemaCache.has(tableName));
81
+ const fetched = await mapWithConcurrency(pending, introspectionConcurrency, fetchLiveSchema);
82
+ pending.forEach((tableName, index) => {
83
+ liveSchemaCache.set(tableName, fetched[index]);
84
+ });
85
+ }
86
+ const readLiveSchema = (tableName) => liveSchemaCache.get(tableName);
87
+ const outcomes = await mapWithConcurrency(plan, probeConcurrency, async (entry) => {
88
+ if (entry.kind === "skip") return entry;
89
+ const { base, childTable, foreignKey, manifestNullable, declaredUuidComparison } = entry.probe;
90
+ const liveNotNull = readLiveSchema(childTable)?.columns[foreignKey.column]?.notNull === true;
91
+ const nullable = manifestNullable && !liveNotNull;
92
+ const { uuidComparison, uuidCastSide } = resolveUuidCastSide(db, {
93
+ engine,
94
+ declaredUuidComparison,
95
+ childType: liveColumnType(readLiveSchema(childTable), foreignKey.column),
96
+ parentType: liveColumnType(readLiveSchema(base.parentTable), foreignKey.referencesColumn)
97
+ });
98
+ const sql = renderForeignKeyOrphanDetector(childTable, foreignKey, {
99
+ engine,
100
+ countOnly: true,
101
+ uuidComparison,
102
+ uuidCastSide
103
+ });
104
+ try {
105
+ const raw = normalizeQueryRows(await db.query(sql))[0]?.orphan_count;
106
+ const orphanCount = Number(raw);
107
+ return {
108
+ kind: "count",
109
+ count: {
82
110
  ...base,
83
111
  orphanCount: Number.isFinite(orphanCount) ? orphanCount : 0,
84
112
  nullable
85
- });
86
- } catch (error) {
87
- skipped.push({
113
+ }
114
+ };
115
+ } catch (error) {
116
+ return {
117
+ kind: "skip",
118
+ skip: {
88
119
  ...base,
89
120
  kind: "probe_failed",
90
121
  reason: `Could not probe for orphan rows: ${error instanceof Error ? error.message : String(error)}`
91
- });
92
- }
122
+ }
123
+ };
93
124
  }
94
- }
125
+ });
126
+ const counts = [];
127
+ const skipped = [];
128
+ for (const outcome of outcomes) if (outcome.kind === "count") counts.push(outcome.count);
129
+ else skipped.push(outcome.skip);
95
130
  counts.sort((a, b) => b.orphanCount - a.orphanCount);
96
131
  return {
97
132
  engine,
@@ -107,12 +142,10 @@ async function collectForeignKeyOrphanCounts(db, schemas, options = {}) {
107
142
  * same relationship the same way. Only PostgreSQL casts at all
108
143
  * (`foreignKeyOrphanParts()` ignores `uuidComparison` on every other engine).
109
144
  */
110
- async function resolveUuidCastSide(db, options) {
111
- const { engine, declaredUuidComparison, childTable, childColumn, parentTable, parentColumn } = options;
145
+ function resolveUuidCastSide(db, options) {
146
+ const { engine, declaredUuidComparison, childType, parentType } = options;
112
147
  if (!declaredUuidComparison || engine !== "postgres") return { uuidComparison: false };
113
148
  if (typeof db.getTableSchema !== "function") return { uuidComparison: true };
114
- const childType = await readLiveColumnType(db, childTable, childColumn);
115
- const parentType = await readLiveColumnType(db, parentTable, parentColumn);
116
149
  if (!childType || !parentType) return { uuidComparison: true };
117
150
  const childIsUuid = isUuidType(childType);
118
151
  if (childIsUuid === isUuidType(parentType)) return { uuidComparison: false };
@@ -124,13 +157,15 @@ async function resolveUuidCastSide(db, options) {
124
157
  function isUuidType(type) {
125
158
  return type.toUpperCase().includes("UUID");
126
159
  }
127
- async function readLiveColumnType(db, tableName, columnName) {
128
- try {
129
- const type = (await db.getTableSchema?.(tableName))?.columns?.[columnName]?.type;
130
- return type === void 0 || type === null ? void 0 : String(type);
131
- } catch {
132
- return;
133
- }
160
+ /**
161
+ * A live column's type from an already-read table schema. A missing table,
162
+ * column, or failed introspection (cached as `undefined`) yields `undefined`,
163
+ * and the caller falls back to the manifest-declared signal — the COUNT(*)
164
+ * probe itself still runs and can report its own failure.
165
+ */
166
+ function liveColumnType(schema, columnName) {
167
+ const type = schema?.columns?.[columnName]?.type;
168
+ return type === void 0 || type === null ? void 0 : String(type);
134
169
  }
135
170
  function resolveEngine(db, engineHint) {
136
171
  if (typeof db.exportTable === "function") return "json";
@@ -1 +1 @@
1
- {"version":3,"file":"foreign-key-orphan-report.js","names":[],"sources":["../../src/schema/foreign-key-orphan-report.ts"],"sourcesContent":["/**\n * Per-foreign-key orphan count report (#2753)\n *\n * `foreign-key-ddl.ts` already builds the orphan probe SQL and the suggested\n * repair the migration differ uses to gate `ADD CONSTRAINT` — deliberately\n * refusing to repair anything, since only the relationship's owner knows\n * whether an orphan should be reassigned, nulled, or deleted. What is\n * missing is the counting: `renderForeignKeyOrphanDetector({ limitOne: true })`\n * makes the probe a gate, not a report.\n *\n * This module runs the same probe as a `COUNT(*)` aggregate (via\n * `renderForeignKeyOrphanDetector({ countOnly: true })`, so the exact\n * FROM/JOIN/WHERE predicate is never duplicated) for every manifest-declared\n * foreign key, and reports child/parent table and column, the orphan count,\n * and whether the child column is nullable — so the two dispositions the\n * repair-SQL builder already distinguishes (manual repair vs. null-out) are\n * visible up front.\n *\n * Strictly read-only and diagnostic: it never repairs, and a relationship\n * whose child or parent table does not exist live is skipped and reported\n * separately rather than failing the whole report.\n */\n\nimport type { DatabaseInterface } from '@happyvertical/sql';\nimport { detectEngine } from './ddl/index.js';\nimport type { DatabaseEngine } from './ddl/types.js';\nimport {\n type ForeignKeyUuidCastSide,\n renderForeignKeyOrphanDetector,\n schemaForeignKeysForEngine,\n} from './foreign-key-ddl.js';\nimport type { SchemaDefinition } from './types.js';\n\n/** One manifest foreign key's live orphan count. */\nexport interface ForeignKeyOrphanCount {\n childTable: string;\n childColumn: string;\n parentTable: string;\n parentColumn: string;\n /** Number of live child rows whose reference does not resolve. */\n orphanCount: number;\n /** Whether the child column allows NULL (a null-out repair is possible). */\n nullable: boolean;\n}\n\n/** A manifest foreign key the report could not probe, and why. */\nexport interface ForeignKeyOrphanSkipped {\n childTable: string;\n childColumn: string;\n parentTable: string;\n parentColumn: string;\n reason: string;\n /**\n * `missing_table` is benign and expected (a manifest table not yet\n * migrated, or filtered out of this database). `probe_failed` means the\n * `COUNT(*)` query itself errored — a real signal (permissions, a\n * malformed live column, a genuine SQL failure) that a caller should\n * surface distinctly rather than silently treat as \"no orphans found\".\n */\n kind: 'missing_table' | 'probe_failed';\n}\n\n/** Aggregate result of one orphan-count run. */\nexport interface ForeignKeyOrphanCountReport {\n engine: DatabaseEngine;\n /**\n * Every probed foreign key with a live orphan count, sorted by\n * `orphanCount` descending so the largest problems surface first.\n * Includes relationships with zero orphans.\n */\n counts: ForeignKeyOrphanCount[];\n /** Foreign keys skipped because a table did not exist live, or the probe failed. */\n skipped: ForeignKeyOrphanSkipped[];\n}\n\nexport interface CollectForeignKeyOrphanCountsOptions {\n /** Explicit engine hint for adapters whose URL is empty or ambiguous. */\n engineHint?: string;\n}\n\n/**\n * Probe every manifest-declared foreign key for live orphan rows and report\n * a count per relationship. Read-only: never repairs, never fails the whole\n * report for one bad relationship.\n *\n * @param db Live database connection.\n * @param schemas Manifest schemas, keyed by table name — typically\n * `ObjectRegistry.getAllSchemasAsDefinitions()`.\n */\nexport async function collectForeignKeyOrphanCounts(\n db: DatabaseInterface,\n schemas: Record<string, SchemaDefinition>,\n options: CollectForeignKeyOrphanCountsOptions = {},\n): Promise<ForeignKeyOrphanCountReport> {\n const engine = resolveEngine(db, options.engineHint);\n const liveTables = await listLiveTables(db, engine);\n // Cache per table: several foreign keys can share a child table, and\n // `db.getTableSchema` is a live introspection round-trip. Both shipped\n // adapters throw on a failed schema lookup rather than returning `null`\n // (review finding, #2748: an unguarded call here aborted the whole\n // report, one bad child table taking down every other relationship's\n // count) — swallow the same way `readLiveColumnType()` already does\n // below: this introspection failing is not the orphan probe itself\n // failing, so the relationship still gets counted (falling back to\n // manifest-only nullability) or lands in `skipped` via the probe's own\n // `catch`.\n const fetchLiveSchema = async (tableName: string) => {\n try {\n return await db.getTableSchema?.(tableName);\n } catch {\n return undefined;\n }\n };\n const liveSchemaCache = new Map<\n string,\n Awaited<ReturnType<typeof fetchLiveSchema>>\n >();\n const getLiveSchema = async (tableName: string) => {\n if (liveSchemaCache.has(tableName)) {\n return liveSchemaCache.get(tableName);\n }\n const liveSchema = await fetchLiveSchema(tableName);\n liveSchemaCache.set(tableName, liveSchema);\n return liveSchema;\n };\n\n const counts: ForeignKeyOrphanCount[] = [];\n const skipped: ForeignKeyOrphanSkipped[] = [];\n\n for (const [childTable, schema] of Object.entries(schemas)) {\n const foreignKeys = schemaForeignKeysForEngine(schema, engine);\n\n for (const foreignKey of foreignKeys) {\n const parentTable = foreignKey.referencesTable;\n const base = {\n childTable,\n childColumn: foreignKey.column,\n parentTable,\n parentColumn: foreignKey.referencesColumn,\n };\n\n if (!liveTables.has(childTable)) {\n skipped.push({\n ...base,\n kind: 'missing_table',\n reason: `Child table \\`${childTable}\\` does not exist in the live database.`,\n });\n continue;\n }\n if (!liveTables.has(parentTable)) {\n skipped.push({\n ...base,\n kind: 'missing_table',\n reason: `Parent table \\`${parentTable}\\` does not exist in the live database.`,\n });\n continue;\n }\n\n const childColumnDefinition = schema.columns[foreignKey.column];\n // Nullable only when BOTH the manifest and the live column agree\n // (review finding, #2748): a manifest relaxed to nullable while the\n // live column is still physically NOT NULL (relaxation pending, not\n // yet applied) is exactly the drift `db:migrate --null-orphans`\n // refuses to null out — this report must say the same thing, or an\n // operator reading `db:orphans`'s \"null-out possible\" summary and\n // then running `--null-orphans` hits an unconditional refusal for a\n // relationship this report told them was nullable. Mirrors\n // `SchemaComparer.getForeignKeyOrphanOptions()` in\n // `migrations/differ.ts`.\n const liveChildSchema = await getLiveSchema(childTable);\n const manifestNullable = childColumnDefinition?.notNull !== true;\n const liveNotNull =\n liveChildSchema?.columns[foreignKey.column]?.notNull === true;\n const nullable = manifestNullable && !liveNotNull;\n const parentColumnDefinition =\n schemas[parentTable]?.columns[foreignKey.referencesColumn];\n const declaredUuidComparison =\n childColumnDefinition?.type === 'UUID' &&\n (parentColumnDefinition === undefined ||\n parentColumnDefinition.type === 'UUID');\n\n // The manifest declaring UUID on both sides is not proof the live\n // columns are actually native uuid yet — #2608 tolerates a legacy\n // component that is still `text` on every side until it converges. Mirror\n // the migration gate's live-type cast-side selection\n // (`SchemaComparer.getForeignKeyOrphanOptions` in migrations/differ.ts)\n // instead of guessing from the manifest alone, or a legacy text/text or\n // text/uuid relationship either errors (`operator does not exist: text\n // = uuid`) or silently mismatches instead of being counted.\n const { uuidComparison, uuidCastSide } = await resolveUuidCastSide(db, {\n engine,\n declaredUuidComparison,\n childTable,\n childColumn: foreignKey.column,\n parentTable,\n parentColumn: foreignKey.referencesColumn,\n });\n\n const sql = renderForeignKeyOrphanDetector(childTable, foreignKey, {\n engine,\n countOnly: true,\n uuidComparison,\n uuidCastSide,\n });\n\n try {\n const result = await db.query(sql);\n const rows = normalizeQueryRows(result);\n const raw = rows[0]?.orphan_count;\n const orphanCount = Number(raw);\n counts.push({\n ...base,\n orphanCount: Number.isFinite(orphanCount) ? orphanCount : 0,\n nullable,\n });\n } catch (error) {\n skipped.push({\n ...base,\n kind: 'probe_failed',\n reason: `Could not probe for orphan rows: ${error instanceof Error ? error.message : String(error)}`,\n });\n }\n }\n }\n\n counts.sort((a, b) => b.orphanCount - a.orphanCount);\n\n return { engine, counts, skipped };\n}\n\n/**\n * Resolve whether to guard-cast the probe's join predicate, and which side,\n * from the LIVE column types rather than the manifest's declared types —\n * mirroring `SchemaComparer.getForeignKeyOrphanOptions()` in\n * `migrations/differ.ts` so the diagnostic and the migration gate treat the\n * same relationship the same way. Only PostgreSQL casts at all\n * (`foreignKeyOrphanParts()` ignores `uuidComparison` on every other engine).\n */\nasync function resolveUuidCastSide(\n db: DatabaseInterface,\n options: {\n engine: DatabaseEngine;\n declaredUuidComparison: boolean;\n childTable: string;\n childColumn: string;\n parentTable: string;\n parentColumn: string;\n },\n): Promise<{ uuidComparison: boolean; uuidCastSide?: ForeignKeyUuidCastSide }> {\n const {\n engine,\n declaredUuidComparison,\n childTable,\n childColumn,\n parentTable,\n parentColumn,\n } = options;\n if (!declaredUuidComparison || engine !== 'postgres') {\n return { uuidComparison: false };\n }\n if (typeof db.getTableSchema !== 'function') {\n // No live-type introspection available on this adapter; fall back to\n // the manifest-declared signal with the builder's own default cast side\n // ('child') rather than refusing to count anything.\n return { uuidComparison: true };\n }\n\n const childType = await readLiveColumnType(db, childTable, childColumn);\n const parentType = await readLiveColumnType(db, parentTable, parentColumn);\n if (!childType || !parentType) {\n return { uuidComparison: true };\n }\n\n const childIsUuid = isUuidType(childType);\n const parentIsUuid = isUuidType(parentType);\n if (childIsUuid === parentIsUuid) {\n // Both sides already agree (native uuid/uuid, or the #2608-tolerated\n // legacy text/text component) — compare directly, no guarded cast.\n return { uuidComparison: false };\n }\n return {\n uuidComparison: true,\n uuidCastSide: childIsUuid ? 'parent' : 'child',\n };\n}\n\nfunction isUuidType(type: string): boolean {\n return type.toUpperCase().includes('UUID');\n}\n\nasync function readLiveColumnType(\n db: DatabaseInterface,\n tableName: string,\n columnName: string,\n): Promise<string | undefined> {\n try {\n const schema = await db.getTableSchema?.(tableName);\n const type = schema?.columns?.[columnName]?.type;\n return type === undefined || type === null ? undefined : String(type);\n } catch {\n // Introspection failure here is not the probe failing — the caller\n // falls back to the manifest-declared signal, and the COUNT(*) probe\n // itself still runs and can report its own failure.\n return undefined;\n }\n}\n\nfunction resolveEngine(\n db: DatabaseInterface,\n engineHint?: string,\n): DatabaseEngine {\n if (typeof (db as { exportTable?: unknown }).exportTable === 'function') {\n return 'json';\n }\n const dbWithConfig = db as DatabaseInterface & { config?: { url?: string } };\n return detectEngine(db.url || dbWithConfig.config?.url || '', engineHint);\n}\n\n/**\n * Normalize a `db.query()` result to a row array. Adapters disagree on the\n * envelope: most return `{ rows }`, but some (per the same normalization in\n * `migrations/differ.ts`'s `getExistingTables()`) return a bare array. A\n * caller that only checks `.rows` reads every row as absent on the latter,\n * which for `listLiveTables()` means every relationship looks like it has a\n * `missing_table` — a silent false negative, not a thrown error.\n */\nfunction normalizeQueryRows(result: unknown): Record<string, unknown>[] {\n return (\n Array.isArray(result)\n ? result\n : ((result as { rows?: unknown[] })?.rows ?? [])\n ) as Record<string, unknown>[];\n}\n\nasync function listLiveTables(\n db: DatabaseInterface,\n engine: DatabaseEngine,\n): Promise<Set<string>> {\n const sql =\n engine === 'postgres'\n ? `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'`\n : `SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`;\n\n const result = await db.query(sql);\n const rows = normalizeQueryRows(result) as {\n name?: string;\n table_name?: string;\n }[];\n return new Set(\n rows.map((row) => row.name || row.table_name || '').filter(Boolean),\n );\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"foreign-key-orphan-report.js","names":[],"sources":["../../src/schema/foreign-key-orphan-report.ts"],"sourcesContent":["/**\n * Per-foreign-key orphan count report (#2753)\n *\n * `foreign-key-ddl.ts` already builds the orphan probe SQL and the suggested\n * repair the migration differ uses to gate `ADD CONSTRAINT` — deliberately\n * refusing to repair anything, since only the relationship's owner knows\n * whether an orphan should be reassigned, nulled, or deleted. What is\n * missing is the counting: `renderForeignKeyOrphanDetector({ limitOne: true })`\n * makes the probe a gate, not a report.\n *\n * This module runs the same probe as a `COUNT(*)` aggregate (via\n * `renderForeignKeyOrphanDetector({ countOnly: true })`, so the exact\n * FROM/JOIN/WHERE predicate is never duplicated) for every manifest-declared\n * foreign key, and reports child/parent table and column, the orphan count,\n * and whether the child column is nullable — so the two dispositions the\n * repair-SQL builder already distinguishes (manual repair vs. null-out) are\n * visible up front.\n *\n * Strictly read-only and diagnostic: it never repairs, and a relationship\n * whose child or parent table does not exist live is skipped and reported\n * separately rather than failing the whole report.\n */\n\nimport type { DatabaseInterface } from '@happyvertical/sql';\nimport {\n mapWithConcurrency,\n POSTGRES_INTROSPECTION_CONCURRENCY,\n POSTGRES_PROBE_CONCURRENCY,\n} from './bounded-concurrency.js';\nimport { detectEngine } from './ddl/index.js';\nimport type { DatabaseEngine } from './ddl/types.js';\nimport {\n type ForeignKeyUuidCastSide,\n renderForeignKeyOrphanDetector,\n schemaForeignKeysForEngine,\n} from './foreign-key-ddl.js';\nimport type { SchemaDefinition } from './types.js';\n\n/** One manifest foreign key's live orphan count. */\nexport interface ForeignKeyOrphanCount {\n childTable: string;\n childColumn: string;\n parentTable: string;\n parentColumn: string;\n /** Number of live child rows whose reference does not resolve. */\n orphanCount: number;\n /** Whether the child column allows NULL (a null-out repair is possible). */\n nullable: boolean;\n}\n\n/** A manifest foreign key the report could not probe, and why. */\nexport interface ForeignKeyOrphanSkipped {\n childTable: string;\n childColumn: string;\n parentTable: string;\n parentColumn: string;\n reason: string;\n /**\n * `missing_table` is benign and expected (a manifest table not yet\n * migrated, or filtered out of this database). `probe_failed` means the\n * `COUNT(*)` query itself errored — a real signal (permissions, a\n * malformed live column, a genuine SQL failure) that a caller should\n * surface distinctly rather than silently treat as \"no orphans found\".\n */\n kind: 'missing_table' | 'probe_failed';\n}\n\n/** Aggregate result of one orphan-count run. */\nexport interface ForeignKeyOrphanCountReport {\n engine: DatabaseEngine;\n /**\n * Every probed foreign key with a live orphan count, sorted by\n * `orphanCount` descending so the largest problems surface first.\n * Includes relationships with zero orphans.\n */\n counts: ForeignKeyOrphanCount[];\n /** Foreign keys skipped because a table did not exist live, or the probe failed. */\n skipped: ForeignKeyOrphanSkipped[];\n}\n\n/** Live table schema as returned by `db.getTableSchema()`. */\ntype LiveTableSchema = Awaited<\n ReturnType<NonNullable<DatabaseInterface['getTableSchema']>>\n>;\n\nexport interface CollectForeignKeyOrphanCountsOptions {\n /** Explicit engine hint for adapters whose URL is empty or ambiguous. */\n engineHint?: string;\n /**\n * Live table schemas already read in this command over the same\n * connection (e.g. `SchemaComparer.getLiveSchemaSnapshot()` after\n * `compare()`), used instead of re-introspecting those tables. Tables\n * absent from the map are introspected as before.\n */\n liveSchemas?: ReadonlyMap<string, LiveTableSchema | undefined>;\n /**\n * Maximum concurrent live-table introspections. Default:\n * {@link POSTGRES_INTROSPECTION_CONCURRENCY} on PostgreSQL, 1 elsewhere.\n */\n introspectionConcurrency?: number;\n /**\n * Maximum concurrent orphan `COUNT(*)` probes. These scan table data, so\n * the default is lower: {@link POSTGRES_PROBE_CONCURRENCY} on PostgreSQL,\n * 1 elsewhere.\n */\n probeConcurrency?: number;\n}\n\n/**\n * Probe every manifest-declared foreign key for live orphan rows and report\n * a count per relationship. Read-only: never repairs, never fails the whole\n * report for one bad relationship.\n *\n * @param db Live database connection.\n * @param schemas Manifest schemas, keyed by table name — typically\n * `ObjectRegistry.getAllSchemasAsDefinitions()`.\n */\nexport async function collectForeignKeyOrphanCounts(\n db: DatabaseInterface,\n schemas: Record<string, SchemaDefinition>,\n options: CollectForeignKeyOrphanCountsOptions = {},\n): Promise<ForeignKeyOrphanCountReport> {\n const engine = resolveEngine(db, options.engineHint);\n const isPostgres = engine === 'postgres';\n const introspectionConcurrency =\n options.introspectionConcurrency ??\n (isPostgres ? POSTGRES_INTROSPECTION_CONCURRENCY : 1);\n const probeConcurrency =\n options.probeConcurrency ?? (isPostgres ? POSTGRES_PROBE_CONCURRENCY : 1);\n const liveTables = await listLiveTables(db, engine);\n // One live-schema read per table for the whole report: several foreign\n // keys can share a child table, the uuid cast-side check reads both the\n // child and the parent, and `db.getTableSchema` is several catalog round\n // trips. Both shipped adapters throw on a failed schema lookup rather than\n // returning `null` (review finding, #2748: an unguarded call here aborted\n // the whole report, one bad child table taking down every other\n // relationship's count) — so a failed lookup is cached as `undefined`:\n // this introspection failing is not the orphan probe itself failing, so\n // the relationship still gets counted (falling back to manifest-only\n // nullability and the manifest-declared uuid signal) or lands in\n // `skipped` via the probe's own `catch`.\n const liveSchemaCache = new Map<string, LiveTableSchema | undefined>(\n options.liveSchemas ?? [],\n );\n const fetchLiveSchema = async (\n tableName: string,\n ): Promise<LiveTableSchema | undefined> => {\n try {\n return await db.getTableSchema?.(tableName);\n } catch {\n return undefined;\n }\n };\n\n interface PlannedProbe {\n base: Omit<ForeignKeyOrphanSkipped, 'reason' | 'kind'>;\n childTable: string;\n foreignKey: ReturnType<typeof schemaForeignKeysForEngine>[number];\n manifestNullable: boolean;\n declaredUuidComparison: boolean;\n }\n type PlanEntry =\n | { kind: 'skip'; skip: ForeignKeyOrphanSkipped }\n | { kind: 'probe'; probe: PlannedProbe };\n\n // Phase 1 (no queries): decide, in manifest order, which relationships can\n // be probed at all and which live schemas those probes need.\n const plan: PlanEntry[] = [];\n const tablesToIntrospect = new Set<string>();\n for (const [childTable, schema] of Object.entries(schemas)) {\n const foreignKeys = schemaForeignKeysForEngine(schema, engine);\n\n for (const foreignKey of foreignKeys) {\n const parentTable = foreignKey.referencesTable;\n const base = {\n childTable,\n childColumn: foreignKey.column,\n parentTable,\n parentColumn: foreignKey.referencesColumn,\n };\n\n if (!liveTables.has(childTable)) {\n plan.push({\n kind: 'skip',\n skip: {\n ...base,\n kind: 'missing_table',\n reason: `Child table \\`${childTable}\\` does not exist in the live database.`,\n },\n });\n continue;\n }\n if (!liveTables.has(parentTable)) {\n plan.push({\n kind: 'skip',\n skip: {\n ...base,\n kind: 'missing_table',\n reason: `Parent table \\`${parentTable}\\` does not exist in the live database.`,\n },\n });\n continue;\n }\n\n const childColumnDefinition = schema.columns[foreignKey.column];\n const parentColumnDefinition =\n schemas[parentTable]?.columns[foreignKey.referencesColumn];\n const declaredUuidComparison =\n childColumnDefinition?.type === 'UUID' &&\n (parentColumnDefinition === undefined ||\n parentColumnDefinition.type === 'UUID');\n\n tablesToIntrospect.add(childTable);\n if (\n declaredUuidComparison &&\n isPostgres &&\n typeof db.getTableSchema === 'function'\n ) {\n tablesToIntrospect.add(parentTable);\n }\n\n plan.push({\n kind: 'probe',\n probe: {\n base,\n childTable,\n foreignKey,\n manifestNullable: childColumnDefinition?.notNull !== true,\n declaredUuidComparison,\n },\n });\n }\n }\n\n // Phase 2: introspect every needed table once, with bounded concurrency.\n if (typeof db.getTableSchema === 'function') {\n const pending = [...tablesToIntrospect].filter(\n (tableName) => !liveSchemaCache.has(tableName),\n );\n const fetched = await mapWithConcurrency(\n pending,\n introspectionConcurrency,\n fetchLiveSchema,\n );\n pending.forEach((tableName, index) => {\n liveSchemaCache.set(tableName, fetched[index]);\n });\n }\n const readLiveSchema = (tableName: string): LiveTableSchema | undefined =>\n liveSchemaCache.get(tableName);\n\n // Phase 3: run the COUNT(*) probes with bounded concurrency, collecting\n // each outcome at its plan position so the report order is independent of\n // completion order.\n const outcomes = await mapWithConcurrency(\n plan,\n probeConcurrency,\n async (\n entry,\n ): Promise<\n | { kind: 'count'; count: ForeignKeyOrphanCount }\n | { kind: 'skip'; skip: ForeignKeyOrphanSkipped }\n > => {\n if (entry.kind === 'skip') return entry;\n const {\n base,\n childTable,\n foreignKey,\n manifestNullable,\n declaredUuidComparison,\n } = entry.probe;\n\n // Nullable only when BOTH the manifest and the live column agree\n // (review finding, #2748): a manifest relaxed to nullable while the\n // live column is still physically NOT NULL (relaxation pending, not\n // yet applied) is exactly the drift `db:migrate --null-orphans`\n // refuses to null out — this report must say the same thing, or an\n // operator reading `db:orphans`'s \"null-out possible\" summary and\n // then running `--null-orphans` hits an unconditional refusal for a\n // relationship this report told them was nullable. Mirrors\n // `SchemaComparer.getForeignKeyOrphanOptions()` in\n // `migrations/differ.ts`.\n const liveNotNull =\n readLiveSchema(childTable)?.columns[foreignKey.column]?.notNull ===\n true;\n const nullable = manifestNullable && !liveNotNull;\n\n // The manifest declaring UUID on both sides is not proof the live\n // columns are actually native uuid yet — #2608 tolerates a legacy\n // component that is still `text` on every side until it converges.\n // Mirror the migration gate's live-type cast-side selection\n // (`SchemaComparer.getForeignKeyOrphanOptions` in migrations/differ.ts)\n // instead of guessing from the manifest alone, or a legacy text/text or\n // text/uuid relationship either errors (`operator does not exist: text\n // = uuid`) or silently mismatches instead of being counted.\n const { uuidComparison, uuidCastSide } = resolveUuidCastSide(db, {\n engine,\n declaredUuidComparison,\n childType: liveColumnType(\n readLiveSchema(childTable),\n foreignKey.column,\n ),\n parentType: liveColumnType(\n readLiveSchema(base.parentTable),\n foreignKey.referencesColumn,\n ),\n });\n\n const sql = renderForeignKeyOrphanDetector(childTable, foreignKey, {\n engine,\n countOnly: true,\n uuidComparison,\n uuidCastSide,\n });\n\n try {\n const result = await db.query(sql);\n const rows = normalizeQueryRows(result);\n const raw = rows[0]?.orphan_count;\n const orphanCount = Number(raw);\n return {\n kind: 'count',\n count: {\n ...base,\n orphanCount: Number.isFinite(orphanCount) ? orphanCount : 0,\n nullable,\n },\n };\n } catch (error) {\n return {\n kind: 'skip',\n skip: {\n ...base,\n kind: 'probe_failed',\n reason: `Could not probe for orphan rows: ${error instanceof Error ? error.message : String(error)}`,\n },\n };\n }\n },\n );\n\n const counts: ForeignKeyOrphanCount[] = [];\n const skipped: ForeignKeyOrphanSkipped[] = [];\n for (const outcome of outcomes) {\n if (outcome.kind === 'count') counts.push(outcome.count);\n else skipped.push(outcome.skip);\n }\n\n counts.sort((a, b) => b.orphanCount - a.orphanCount);\n\n return { engine, counts, skipped };\n}\n\n/**\n * Resolve whether to guard-cast the probe's join predicate, and which side,\n * from the LIVE column types rather than the manifest's declared types —\n * mirroring `SchemaComparer.getForeignKeyOrphanOptions()` in\n * `migrations/differ.ts` so the diagnostic and the migration gate treat the\n * same relationship the same way. Only PostgreSQL casts at all\n * (`foreignKeyOrphanParts()` ignores `uuidComparison` on every other engine).\n */\nfunction resolveUuidCastSide(\n db: DatabaseInterface,\n options: {\n engine: DatabaseEngine;\n declaredUuidComparison: boolean;\n childType: string | undefined;\n parentType: string | undefined;\n },\n): { uuidComparison: boolean; uuidCastSide?: ForeignKeyUuidCastSide } {\n const { engine, declaredUuidComparison, childType, parentType } = options;\n if (!declaredUuidComparison || engine !== 'postgres') {\n return { uuidComparison: false };\n }\n if (typeof db.getTableSchema !== 'function') {\n // No live-type introspection available on this adapter; fall back to\n // the manifest-declared signal with the builder's own default cast side\n // ('child') rather than refusing to count anything.\n return { uuidComparison: true };\n }\n\n if (!childType || !parentType) {\n return { uuidComparison: true };\n }\n\n const childIsUuid = isUuidType(childType);\n const parentIsUuid = isUuidType(parentType);\n if (childIsUuid === parentIsUuid) {\n // Both sides already agree (native uuid/uuid, or the #2608-tolerated\n // legacy text/text component) — compare directly, no guarded cast.\n return { uuidComparison: false };\n }\n return {\n uuidComparison: true,\n uuidCastSide: childIsUuid ? 'parent' : 'child',\n };\n}\n\nfunction isUuidType(type: string): boolean {\n return type.toUpperCase().includes('UUID');\n}\n\n/**\n * A live column's type from an already-read table schema. A missing table,\n * column, or failed introspection (cached as `undefined`) yields `undefined`,\n * and the caller falls back to the manifest-declared signal — the COUNT(*)\n * probe itself still runs and can report its own failure.\n */\nfunction liveColumnType(\n schema: LiveTableSchema | undefined,\n columnName: string,\n): string | undefined {\n const type = schema?.columns?.[columnName]?.type;\n return type === undefined || type === null ? undefined : String(type);\n}\n\nfunction resolveEngine(\n db: DatabaseInterface,\n engineHint?: string,\n): DatabaseEngine {\n if (typeof (db as { exportTable?: unknown }).exportTable === 'function') {\n return 'json';\n }\n const dbWithConfig = db as DatabaseInterface & { config?: { url?: string } };\n return detectEngine(db.url || dbWithConfig.config?.url || '', engineHint);\n}\n\n/**\n * Normalize a `db.query()` result to a row array. Adapters disagree on the\n * envelope: most return `{ rows }`, but some (per the same normalization in\n * `migrations/differ.ts`'s `getExistingTables()`) return a bare array. A\n * caller that only checks `.rows` reads every row as absent on the latter,\n * which for `listLiveTables()` means every relationship looks like it has a\n * `missing_table` — a silent false negative, not a thrown error.\n */\nfunction normalizeQueryRows(result: unknown): Record<string, unknown>[] {\n return (\n Array.isArray(result)\n ? result\n : ((result as { rows?: unknown[] })?.rows ?? [])\n ) as Record<string, unknown>[];\n}\n\nasync function listLiveTables(\n db: DatabaseInterface,\n engine: DatabaseEngine,\n): Promise<Set<string>> {\n const sql =\n engine === 'postgres'\n ? `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'`\n : `SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`;\n\n const result = await db.query(sql);\n const rows = normalizeQueryRows(result) as {\n name?: string;\n table_name?: string;\n }[];\n return new Set(\n rows.map((row) => row.name || row.table_name || '').filter(Boolean),\n );\n}\n"],"mappings":"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"}