@happyvertical/smrt-core 0.47.0 → 0.47.2
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.
- package/AGENTS.md +2 -0
- package/agents/generators.md +19 -5
- package/agents/registry-snapshot.md +28 -0
- package/agents/schema-paths.md +12 -0
- package/agents/system-diagnostics.md +43 -0
- package/dist/browser.js +4 -2
- package/dist/change-feed.d.ts +8 -0
- package/dist/change-feed.d.ts.map +1 -1
- package/dist/change-feed.js +1 -1
- package/dist/change-feed.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -2
- package/dist/manifest/static-manifest.js +1 -1
- package/dist/manifest/static-manifest.js.map +1 -1
- package/dist/manifest/store.js +1 -1
- package/dist/manifest.json +1 -1
- package/dist/migrations/differ.d.ts +78 -0
- package/dist/migrations/differ.d.ts.map +1 -1
- package/dist/migrations/differ.js +191 -1
- package/dist/migrations/differ.js.map +1 -1
- package/dist/migrations/generator.d.ts.map +1 -1
- package/dist/migrations/generator.js +2 -1
- package/dist/migrations/generator.js.map +1 -1
- package/dist/postgres-permissions.d.ts +3 -0
- package/dist/postgres-permissions.d.ts.map +1 -1
- package/dist/postgres-permissions.js +49 -6
- package/dist/postgres-permissions.js.map +1 -1
- package/dist/prebuild/index.d.ts.map +1 -1
- package/dist/prebuild/index.js +4 -3
- package/dist/prebuild/index.js.map +1 -1
- package/dist/registry/class-registration.d.ts.map +1 -1
- package/dist/registry/class-registration.js +3 -1
- package/dist/registry/class-registration.js.map +1 -1
- package/dist/registry/schema-builder.js +1 -1
- package/dist/registry.js +1 -1
- package/dist/scanner/manifest-generator.d.ts.map +1 -1
- package/dist/scanner/manifest-generator.js +22 -1
- package/dist/scanner/manifest-generator.js.map +1 -1
- package/dist/schema/foreign-key-ddl.d.ts +18 -0
- package/dist/schema/foreign-key-ddl.d.ts.map +1 -1
- package/dist/schema/foreign-key-ddl.js +15 -2
- package/dist/schema/foreign-key-ddl.js.map +1 -1
- package/dist/schema/foreign-key-orphan-report.d.ts +57 -0
- package/dist/schema/foreign-key-orphan-report.d.ts.map +1 -0
- package/dist/schema/foreign-key-orphan-report.js +142 -0
- package/dist/schema/foreign-key-orphan-report.js.map +1 -0
- package/dist/schema/index.d.ts +1 -0
- package/dist/schema/index.d.ts.map +1 -1
- package/dist/schema/index.js +2 -1
- package/dist/schema/live-parity.d.ts +8 -1
- package/dist/schema/live-parity.d.ts.map +1 -1
- package/dist/schema/live-parity.js +98 -2
- package/dist/schema/live-parity.js.map +1 -1
- package/dist/schema/types.d.ts +11 -1
- package/dist/schema/types.d.ts.map +1 -1
- package/dist/schema.js +2 -1
- package/dist/smrt-knowledge.json +22 -10
- package/dist/system/diagnostics.d.ts +299 -0
- package/dist/system/diagnostics.d.ts.map +1 -0
- package/dist/system/diagnostics.js +530 -0
- package/dist/system/diagnostics.js.map +1 -0
- package/dist/system/index.d.ts +2 -0
- package/dist/system/index.d.ts.map +1 -1
- package/dist/system/index.js +3 -1
- package/dist/system/registry-snapshot.d.ts +112 -0
- package/dist/system/registry-snapshot.d.ts.map +1 -0
- package/dist/system/registry-snapshot.js +171 -0
- package/dist/system/registry-snapshot.js.map +1 -0
- package/dist/testing/database.d.ts.map +1 -1
- package/dist/testing/database.js +10 -2
- package/dist/testing/database.js.map +1 -1
- package/dist/utils/scanner-module.d.ts +16 -0
- package/dist/utils/scanner-module.d.ts.map +1 -1
- package/dist/vite-plugin/index.d.ts +34 -1
- package/dist/vite-plugin/index.d.ts.map +1 -1
- package/dist/vite-plugin/index.js +85 -5
- package/dist/vite-plugin/index.js.map +1 -1
- package/dist/vite-plugin/web-collections.d.ts +6 -0
- package/dist/vite-plugin/web-collections.d.ts.map +1 -1
- package/dist/vite-plugin/web-collections.js +9 -4
- package/dist/vite-plugin/web-collections.js.map +1 -1
- package/dist/vite-plugin.js +2 -2
- package/package.json +10 -10
|
@@ -12,6 +12,13 @@ export declare function renderForeignKeyConstraintComment(tableName: string, con
|
|
|
12
12
|
export declare function renderForeignKeyOrphanDetector(tableName: string, foreignKey: ForeignKeyDefinition, options?: {
|
|
13
13
|
engine?: DatabaseEngine;
|
|
14
14
|
limitOne?: boolean;
|
|
15
|
+
/**
|
|
16
|
+
* Aggregate the same predicate as `COUNT(*)` instead of listing orphan
|
|
17
|
+
* keys. Used by the read-only per-foreign-key orphan count report
|
|
18
|
+
* (#2753) so the report shares the exact FROM/JOIN/WHERE clause the
|
|
19
|
+
* migration gate probes with, rather than a second hand-written copy.
|
|
20
|
+
*/
|
|
21
|
+
countOnly?: boolean;
|
|
15
22
|
uuidComparison?: boolean;
|
|
16
23
|
uuidCastSide?: ForeignKeyUuidCastSide;
|
|
17
24
|
}): string;
|
|
@@ -28,6 +35,17 @@ export declare function renderForeignKeyOrphanRepair(tableName: string, foreignK
|
|
|
28
35
|
* all test the same shape (#2608).
|
|
29
36
|
*/
|
|
30
37
|
export declare const CANONICAL_UUID_PATTERN = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$";
|
|
38
|
+
/**
|
|
39
|
+
* The same canonical UUID shape as {@link CANONICAL_UUID_PATTERN}, expressed
|
|
40
|
+
* as a SQLite `GLOB` pattern instead of a regex — SQLite has no built-in
|
|
41
|
+
* regex operator, so a shape probe there cannot push a regex predicate down
|
|
42
|
+
* to the server. `GLOB` is case-sensitive, so callers must `LOWER()` the
|
|
43
|
+
* value first; combine with a `LENGTH(...) = 36` check (`GLOB` alone allows
|
|
44
|
+
* a shorter/longer value to still match a middle substring) so this can run
|
|
45
|
+
* as a whole-table server-side aggregate instead of fetching every row into
|
|
46
|
+
* JS to test in a loop (#2767 review).
|
|
47
|
+
*/
|
|
48
|
+
export declare const CANONICAL_UUID_SQLITE_GLOB_PATTERN: string;
|
|
31
49
|
export declare function schemaForeignKeys(schema: Pick<SchemaDefinition, 'columns'> & Partial<Pick<SchemaDefinition, 'foreignKeys'>>): ForeignKeyDefinition[];
|
|
32
50
|
/** Stable identity for one physical relationship, independent of allocation. */
|
|
33
51
|
export declare function foreignKeyRelationshipKey(foreignKey: Pick<ForeignKeyDefinition, 'column' | 'referencesColumn' | 'referencesTable'>): string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"foreign-key-ddl.d.ts","sourceRoot":"","sources":["../../src/schema/foreign-key-ddl.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAIrD,OAAO,KAAK,EAAE,oBAAoB,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAEzE,MAAM,MAAM,sBAAsB,GAAG,OAAO,GAAG,QAAQ,GAAG,MAAM,GAAG,MAAM,CAAC;AAE1E,wBAAgB,wBAAwB,CACtC,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,oBAAoB,GAC/B,MAAM,CAIR;AAED,wBAAgB,0BAA0B,CACxC,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,oBAAoB,GAC/B,MAAM,CAmBR;AAED,4EAA4E;AAC5E,wBAAgB,8BAA8B,CAC5C,SAAS,EAAE,MAAM,EACjB,cAAc,EAAE,MAAM,GACrB,MAAM,CAER;AAED,yDAAyD;AACzD,wBAAgB,kCAAkC,CAChD,SAAS,EAAE,MAAM,EACjB,cAAc,EAAE,MAAM,GACrB,MAAM,CAER;AAED,mFAAmF;AACnF,wBAAgB,iCAAiC,CAC/C,SAAS,EAAE,MAAM,EACjB,cAAc,EAAE,MAAM,EACtB,OAAO,EAAE,MAAM,GACd,MAAM,CAER;AAED,wBAAgB,8BAA8B,CAC5C,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,oBAAoB,EAChC,OAAO,GAAE;IACP,MAAM,CAAC,EAAE,cAAc,CAAC;IACxB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,YAAY,CAAC,EAAE,sBAAsB,CAAC;CAClC,GACL,MAAM,
|
|
1
|
+
{"version":3,"file":"foreign-key-ddl.d.ts","sourceRoot":"","sources":["../../src/schema/foreign-key-ddl.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAIrD,OAAO,KAAK,EAAE,oBAAoB,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAEzE,MAAM,MAAM,sBAAsB,GAAG,OAAO,GAAG,QAAQ,GAAG,MAAM,GAAG,MAAM,CAAC;AAE1E,wBAAgB,wBAAwB,CACtC,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,oBAAoB,GAC/B,MAAM,CAIR;AAED,wBAAgB,0BAA0B,CACxC,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,oBAAoB,GAC/B,MAAM,CAmBR;AAED,4EAA4E;AAC5E,wBAAgB,8BAA8B,CAC5C,SAAS,EAAE,MAAM,EACjB,cAAc,EAAE,MAAM,GACrB,MAAM,CAER;AAED,yDAAyD;AACzD,wBAAgB,kCAAkC,CAChD,SAAS,EAAE,MAAM,EACjB,cAAc,EAAE,MAAM,GACrB,MAAM,CAER;AAED,mFAAmF;AACnF,wBAAgB,iCAAiC,CAC/C,SAAS,EAAE,MAAM,EACjB,cAAc,EAAE,MAAM,EACtB,OAAO,EAAE,MAAM,GACd,MAAM,CAER;AAED,wBAAgB,8BAA8B,CAC5C,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,oBAAoB,EAChC,OAAO,GAAE;IACP,MAAM,CAAC,EAAE,cAAc,CAAC;IACxB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;;;OAKG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,YAAY,CAAC,EAAE,sBAAsB,CAAC;CAClC,GACL,MAAM,CAmBR;AAED,wBAAgB,4BAA4B,CAC1C,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,oBAAoB,EAChC,OAAO,GAAE;IACP,MAAM,CAAC,EAAE,cAAc,CAAC;IACxB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,YAAY,CAAC,EAAE,sBAAsB,CAAC;IACtC,QAAQ,CAAC,EAAE,OAAO,CAAC;CACf,GACL,MAAM,CAuBR;AAID;;;;;GAKG;AACH,eAAO,MAAM,sBAAsB,mEAC+B,CAAC;AAEnE;;;;;;;;;GASG;AACH,eAAO,MAAM,kCAAkC,QAKqD,CAAC;AAsCrG,wBAAgB,iBAAiB,CAC/B,MAAM,EAAE,IAAI,CAAC,gBAAgB,EAAE,SAAS,CAAC,GACvC,OAAO,CAAC,IAAI,CAAC,gBAAgB,EAAE,aAAa,CAAC,CAAC,GAC/C,oBAAoB,EAAE,CAmBxB;AAED,gFAAgF;AAChF,wBAAgB,yBAAyB,CACvC,UAAU,EAAE,IAAI,CACd,oBAAoB,EACpB,QAAQ,GAAG,kBAAkB,GAAG,iBAAiB,CAClD,GACA,MAAM,CAMR;AAED,yEAAyE;AACzE,wBAAgB,0BAA0B,CACxC,MAAM,EAAE,IAAI,CAAC,gBAAgB,EAAE,SAAS,CAAC,GACvC,OAAO,CAAC,IAAI,CAAC,gBAAgB,EAAE,aAAa,CAAC,CAAC,EAChD,MAAM,EAAE,cAAc,GACrB,oBAAoB,EAAE,CAuBxB;AAED,+EAA+E;AAC/E,wBAAgB,2BAA2B,CACzC,MAAM,EAAE,IAAI,CAAC,gBAAgB,EAAE,SAAS,CAAC,GACvC,OAAO,CAAC,IAAI,CAAC,gBAAgB,EAAE,cAAc,GAAG,aAAa,CAAC,CAAC,EACjE,MAAM,EAAE,cAAc,GACrB,MAAM,EAAE,CA0BV"}
|
|
@@ -35,7 +35,9 @@ function renderForeignKeyConstraintComment(tableName, constraintName, comment) {
|
|
|
35
35
|
}
|
|
36
36
|
function renderForeignKeyOrphanDetector(tableName, foreignKey, options = {}) {
|
|
37
37
|
const parts = foreignKeyOrphanParts(tableName, foreignKey, options.engine, options.uuidComparison, options.uuidCastSide);
|
|
38
|
-
|
|
38
|
+
const fromClause = `${parts.childTable} LEFT JOIN ${parts.parentTable} ON ${parts.joinPredicate} WHERE ${parts.childColumn} IS NOT NULL AND ${parts.parentColumn} IS NULL`;
|
|
39
|
+
if (options.countOnly) return `SELECT COUNT(*) AS orphan_count FROM ${fromClause}`;
|
|
40
|
+
return `SELECT ${parts.childColumn} AS orphan_key FROM ${fromClause}` + (options.limitOne ? " LIMIT 1" : "");
|
|
39
41
|
}
|
|
40
42
|
function renderForeignKeyOrphanRepair(tableName, foreignKey, options = {}) {
|
|
41
43
|
const parts = foreignKeyOrphanParts(tableName, foreignKey, options.engine, options.uuidComparison, options.uuidCastSide);
|
|
@@ -52,6 +54,17 @@ var FOREIGN_KEY_PARENT_ALIAS = "smrt_fk_parent";
|
|
|
52
54
|
* all test the same shape (#2608).
|
|
53
55
|
*/
|
|
54
56
|
var CANONICAL_UUID_PATTERN = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$";
|
|
57
|
+
/**
|
|
58
|
+
* The same canonical UUID shape as {@link CANONICAL_UUID_PATTERN}, expressed
|
|
59
|
+
* as a SQLite `GLOB` pattern instead of a regex — SQLite has no built-in
|
|
60
|
+
* regex operator, so a shape probe there cannot push a regex predicate down
|
|
61
|
+
* to the server. `GLOB` is case-sensitive, so callers must `LOWER()` the
|
|
62
|
+
* value first; combine with a `LENGTH(...) = 36` check (`GLOB` alone allows
|
|
63
|
+
* a shorter/longer value to still match a middle substring) so this can run
|
|
64
|
+
* as a whole-table server-side aggregate instead of fetching every row into
|
|
65
|
+
* JS to test in a loop (#2767 review).
|
|
66
|
+
*/
|
|
67
|
+
var CANONICAL_UUID_SQLITE_GLOB_PATTERN = "[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]";
|
|
55
68
|
function foreignKeyOrphanParts(tableName, foreignKey, engine = "postgres", uuidComparison = false, uuidCastSide) {
|
|
56
69
|
const childAlias = quoteIdentifier(FOREIGN_KEY_CHILD_ALIAS);
|
|
57
70
|
const parentAlias = quoteIdentifier(FOREIGN_KEY_PARENT_ALIAS);
|
|
@@ -116,6 +129,6 @@ function schemaDependenciesForEngine(schema, engine) {
|
|
|
116
129
|
return Array.from(/* @__PURE__ */ new Set([...(schema.dependencies ?? []).filter((dependency) => !disabledTargets.has(dependency) || enabledTargets.has(dependency)), ...enabledTargets]));
|
|
117
130
|
}
|
|
118
131
|
//#endregion
|
|
119
|
-
export { CANONICAL_UUID_PATTERN, foreignKeyConstraintName, foreignKeyRelationshipKey, renderForeignKeyConstraint, renderForeignKeyConstraintComment, renderForeignKeyConstraintDrop, renderForeignKeyConstraintValidate, renderForeignKeyOrphanDetector, renderForeignKeyOrphanRepair, schemaDependenciesForEngine, schemaForeignKeys, schemaForeignKeysForEngine };
|
|
132
|
+
export { CANONICAL_UUID_PATTERN, CANONICAL_UUID_SQLITE_GLOB_PATTERN, foreignKeyConstraintName, foreignKeyRelationshipKey, renderForeignKeyConstraint, renderForeignKeyConstraintComment, renderForeignKeyConstraintDrop, renderForeignKeyConstraintValidate, renderForeignKeyOrphanDetector, renderForeignKeyOrphanRepair, schemaDependenciesForEngine, schemaForeignKeys, schemaForeignKeysForEngine };
|
|
120
133
|
|
|
121
134
|
//# sourceMappingURL=foreign-key-ddl.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"foreign-key-ddl.js","names":[],"sources":["../../src/schema/foreign-key-ddl.ts"],"sourcesContent":["import type { DatabaseEngine } from './ddl/types.js';\nimport { requireForeignKeyAction } from './foreign-key-policy.js';\nimport { shortenIdentifier } from './index-utils.js';\nimport { quoteIdentifier, quoteStringLiteral } from './sql-identifiers.js';\nimport type { ForeignKeyDefinition, SchemaDefinition } from './types.js';\n\nexport type ForeignKeyUuidCastSide = 'child' | 'parent' | 'both' | 'none';\n\nexport function foreignKeyConstraintName(\n tableName: string,\n foreignKey: ForeignKeyDefinition,\n): string {\n return shortenIdentifier(\n `${tableName}_${foreignKey.column}_${foreignKey.referencesTable}_${foreignKey.referencesColumn}_fkey`,\n );\n}\n\nexport function renderForeignKeyConstraint(\n tableName: string,\n foreignKey: ForeignKeyDefinition,\n): string {\n const action = (value: unknown, clause: 'DELETE' | 'UPDATE') => {\n if (value === undefined) return undefined;\n return requireForeignKeyAction(\n value,\n `${tableName}.${foreignKey.column} ON ${clause}`,\n );\n };\n const onDelete = action(foreignKey.onDelete, 'DELETE');\n const onUpdate = action(foreignKey.onUpdate, 'UPDATE');\n const name = foreignKeyConstraintName(tableName, foreignKey);\n const parts = [\n `CONSTRAINT ${quoteIdentifier(name)}`,\n `FOREIGN KEY (${quoteIdentifier(foreignKey.column)})`,\n `REFERENCES ${quoteIdentifier(foreignKey.referencesTable)} (${quoteIdentifier(foreignKey.referencesColumn)})`,\n ];\n if (onDelete) parts.push(`ON DELETE ${onDelete}`);\n if (onUpdate) parts.push(`ON UPDATE ${onUpdate}`);\n return parts.join(' ');\n}\n\n/** Render a PostgreSQL DROP CONSTRAINT statement for a catalog-owned FK. */\nexport function renderForeignKeyConstraintDrop(\n tableName: string,\n constraintName: string,\n): string {\n return `ALTER TABLE ${quoteIdentifier(tableName)} DROP CONSTRAINT ${quoteIdentifier(constraintName)};`;\n}\n\n/** Render a PostgreSQL VALIDATE CONSTRAINT statement. */\nexport function renderForeignKeyConstraintValidate(\n tableName: string,\n constraintName: string,\n): string {\n return `ALTER TABLE ${quoteIdentifier(tableName)} VALIDATE CONSTRAINT ${quoteIdentifier(constraintName)};`;\n}\n\n/** Render a PostgreSQL constraint comment used to identify framework ownership. */\nexport function renderForeignKeyConstraintComment(\n tableName: string,\n constraintName: string,\n comment: string,\n): string {\n return `COMMENT ON CONSTRAINT ${quoteIdentifier(constraintName)} ON ${quoteIdentifier(tableName)} IS ${quoteStringLiteral(comment)};`;\n}\n\nexport function renderForeignKeyOrphanDetector(\n tableName: string,\n foreignKey: ForeignKeyDefinition,\n options: {\n engine?: DatabaseEngine;\n limitOne?: boolean;\n uuidComparison?: boolean;\n uuidCastSide?: ForeignKeyUuidCastSide;\n } = {},\n): string {\n const parts = foreignKeyOrphanParts(\n tableName,\n foreignKey,\n options.engine,\n options.uuidComparison,\n options.uuidCastSide,\n );\n return (\n `SELECT ${parts.childColumn} AS orphan_key FROM ${parts.childTable} ` +\n `LEFT JOIN ${parts.parentTable} ON ${parts.joinPredicate} ` +\n `WHERE ${parts.childColumn} IS NOT NULL AND ${parts.parentColumn} IS NULL` +\n (options.limitOne ? ' LIMIT 1' : '')\n );\n}\n\nexport function renderForeignKeyOrphanRepair(\n tableName: string,\n foreignKey: ForeignKeyDefinition,\n options: {\n engine?: DatabaseEngine;\n uuidComparison?: boolean;\n uuidCastSide?: ForeignKeyUuidCastSide;\n nullable?: boolean;\n } = {},\n): string {\n const parts = foreignKeyOrphanParts(\n tableName,\n foreignKey,\n options.engine,\n options.uuidComparison,\n options.uuidCastSide,\n );\n const orphanPredicate =\n `${parts.childColumn} IS NOT NULL AND NOT EXISTS (` +\n `SELECT 1 FROM ${parts.parentTable} WHERE ${parts.joinPredicate})`;\n if (options.nullable === false) {\n return (\n `-- Manual repair required: ${quoteIdentifier(tableName)}.${quoteIdentifier(foreignKey.column)} is NOT NULL. ` +\n `Reassign each orphaned reference to a valid ${quoteIdentifier(foreignKey.referencesTable)}.${quoteIdentifier(foreignKey.referencesColumn)} value, ` +\n 'or deliberately delete rows only after preserving any required child data.'\n );\n }\n return (\n `UPDATE ${quoteIdentifier(tableName)} AS ${parts.childAlias} ` +\n `SET ${quoteIdentifier(foreignKey.column)} = NULL ` +\n `WHERE ${orphanPredicate}`\n );\n}\n\nconst FOREIGN_KEY_CHILD_ALIAS = 'smrt_fk_child';\nconst FOREIGN_KEY_PARENT_ALIAS = 'smrt_fk_parent';\n/**\n * Canonical UUID text shape used by every PostgreSQL guard that has to decide\n * whether a `text` column can be reinterpreted as `uuid`. Exported so the\n * orphan probe, the FK provisioning guard, and the uuid convergence planner\n * all test the same shape (#2608).\n */\nexport const CANONICAL_UUID_PATTERN =\n '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$';\n\nfunction foreignKeyOrphanParts(\n tableName: string,\n foreignKey: ForeignKeyDefinition,\n engine: DatabaseEngine = 'postgres',\n uuidComparison = false,\n uuidCastSide?: ForeignKeyUuidCastSide,\n) {\n const childAlias = quoteIdentifier(FOREIGN_KEY_CHILD_ALIAS);\n const parentAlias = quoteIdentifier(FOREIGN_KEY_PARENT_ALIAS);\n const childColumn = `${childAlias}.${quoteIdentifier(foreignKey.column)}`;\n const parentColumn = `${parentAlias}.${quoteIdentifier(foreignKey.referencesColumn)}`;\n const guardedUuidValue = (column: string) =>\n `CASE WHEN ${column}::text ~* '${CANONICAL_UUID_PATTERN}' THEN ${column}::uuid ELSE NULL END`;\n const effectiveUuidCastSide =\n engine === 'postgres' && uuidComparison\n ? (uuidCastSide ?? 'child')\n : undefined;\n const childValue =\n effectiveUuidCastSide === 'child' || effectiveUuidCastSide === 'both'\n ? guardedUuidValue(childColumn)\n : childColumn;\n const parentValue =\n effectiveUuidCastSide === 'parent' || effectiveUuidCastSide === 'both'\n ? guardedUuidValue(parentColumn)\n : parentColumn;\n\n return {\n childAlias,\n childColumn,\n childTable: `${quoteIdentifier(tableName)} AS ${childAlias}`,\n joinPredicate: `${parentValue} = ${childValue}`,\n parentColumn,\n parentTable: `${quoteIdentifier(foreignKey.referencesTable)} AS ${parentAlias}`,\n };\n}\n\nexport function schemaForeignKeys(\n schema: Pick<SchemaDefinition, 'columns'> &\n Partial<Pick<SchemaDefinition, 'foreignKeys'>>,\n): ForeignKeyDefinition[] {\n if (schema.foreignKeys && schema.foreignKeys.length > 0) {\n return schema.foreignKeys;\n }\n return Object.entries(schema.columns).flatMap(([column, definition]) => {\n if (!definition.foreignKey) return [];\n return [\n {\n column,\n referencesTable: definition.foreignKey.table,\n referencesColumn: definition.foreignKey.column,\n onDelete: definition.foreignKey.onDelete,\n onUpdate: definition.foreignKey.onUpdate,\n ...(definition.foreignKey.engines\n ? { engines: definition.foreignKey.engines }\n : {}),\n },\n ];\n });\n}\n\n/** Stable identity for one physical relationship, independent of allocation. */\nexport function foreignKeyRelationshipKey(\n foreignKey: Pick<\n ForeignKeyDefinition,\n 'column' | 'referencesColumn' | 'referencesTable'\n >,\n): string {\n return JSON.stringify([\n foreignKey.column,\n foreignKey.referencesTable,\n foreignKey.referencesColumn,\n ]);\n}\n\n/** Return only physical constraints explicitly enabled for an engine. */\nexport function schemaForeignKeysForEngine(\n schema: Pick<SchemaDefinition, 'columns'> &\n Partial<Pick<SchemaDefinition, 'foreignKeys'>>,\n engine: DatabaseEngine,\n): ForeignKeyDefinition[] {\n return schemaForeignKeys(schema).filter((foreignKey) => {\n if (foreignKey.engines === undefined) return true;\n\n const validEngines = new Set<DatabaseEngine>([\n 'postgres',\n 'sqlite',\n 'duckdb',\n 'json',\n ]);\n if (\n foreignKey.engines.length === 0 ||\n foreignKey.engines.some(\n (declaredEngine) => !validEngines.has(declaredEngine),\n )\n ) {\n throw new Error(\n `[DDL:${engine}] Foreign key ${foreignKey.column} declares an invalid physical constraint engine allowlist. Use one or more of: postgres, sqlite, duckdb, json.`,\n );\n }\n\n return foreignKey.engines.includes(engine);\n });\n}\n\n/** Return declared schema dependencies after excluding engine-disabled FKs. */\nexport function schemaDependenciesForEngine(\n schema: Pick<SchemaDefinition, 'columns'> &\n Partial<Pick<SchemaDefinition, 'dependencies' | 'foreignKeys'>>,\n engine: DatabaseEngine,\n): string[] {\n const foreignKeys = schemaForeignKeys(schema);\n const enabledForeignKeys = schemaForeignKeysForEngine(schema, engine);\n const enabledKeys = new Set(\n enabledForeignKeys.map(foreignKeyRelationshipKey),\n );\n const enabledTargets = new Set(\n enabledForeignKeys.map((foreignKey) => foreignKey.referencesTable),\n );\n const disabledTargets = new Set(\n foreignKeys\n .filter(\n (foreignKey) => !enabledKeys.has(foreignKeyRelationshipKey(foreignKey)),\n )\n .map((foreignKey) => foreignKey.referencesTable),\n );\n\n return Array.from(\n new Set([\n ...(schema.dependencies ?? []).filter(\n (dependency) =>\n !disabledTargets.has(dependency) || enabledTargets.has(dependency),\n ),\n ...enabledTargets,\n ]),\n );\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"foreign-key-ddl.js","names":[],"sources":["../../src/schema/foreign-key-ddl.ts"],"sourcesContent":["import type { DatabaseEngine } from './ddl/types.js';\nimport { requireForeignKeyAction } from './foreign-key-policy.js';\nimport { shortenIdentifier } from './index-utils.js';\nimport { quoteIdentifier, quoteStringLiteral } from './sql-identifiers.js';\nimport type { ForeignKeyDefinition, SchemaDefinition } from './types.js';\n\nexport type ForeignKeyUuidCastSide = 'child' | 'parent' | 'both' | 'none';\n\nexport function foreignKeyConstraintName(\n tableName: string,\n foreignKey: ForeignKeyDefinition,\n): string {\n return shortenIdentifier(\n `${tableName}_${foreignKey.column}_${foreignKey.referencesTable}_${foreignKey.referencesColumn}_fkey`,\n );\n}\n\nexport function renderForeignKeyConstraint(\n tableName: string,\n foreignKey: ForeignKeyDefinition,\n): string {\n const action = (value: unknown, clause: 'DELETE' | 'UPDATE') => {\n if (value === undefined) return undefined;\n return requireForeignKeyAction(\n value,\n `${tableName}.${foreignKey.column} ON ${clause}`,\n );\n };\n const onDelete = action(foreignKey.onDelete, 'DELETE');\n const onUpdate = action(foreignKey.onUpdate, 'UPDATE');\n const name = foreignKeyConstraintName(tableName, foreignKey);\n const parts = [\n `CONSTRAINT ${quoteIdentifier(name)}`,\n `FOREIGN KEY (${quoteIdentifier(foreignKey.column)})`,\n `REFERENCES ${quoteIdentifier(foreignKey.referencesTable)} (${quoteIdentifier(foreignKey.referencesColumn)})`,\n ];\n if (onDelete) parts.push(`ON DELETE ${onDelete}`);\n if (onUpdate) parts.push(`ON UPDATE ${onUpdate}`);\n return parts.join(' ');\n}\n\n/** Render a PostgreSQL DROP CONSTRAINT statement for a catalog-owned FK. */\nexport function renderForeignKeyConstraintDrop(\n tableName: string,\n constraintName: string,\n): string {\n return `ALTER TABLE ${quoteIdentifier(tableName)} DROP CONSTRAINT ${quoteIdentifier(constraintName)};`;\n}\n\n/** Render a PostgreSQL VALIDATE CONSTRAINT statement. */\nexport function renderForeignKeyConstraintValidate(\n tableName: string,\n constraintName: string,\n): string {\n return `ALTER TABLE ${quoteIdentifier(tableName)} VALIDATE CONSTRAINT ${quoteIdentifier(constraintName)};`;\n}\n\n/** Render a PostgreSQL constraint comment used to identify framework ownership. */\nexport function renderForeignKeyConstraintComment(\n tableName: string,\n constraintName: string,\n comment: string,\n): string {\n return `COMMENT ON CONSTRAINT ${quoteIdentifier(constraintName)} ON ${quoteIdentifier(tableName)} IS ${quoteStringLiteral(comment)};`;\n}\n\nexport function renderForeignKeyOrphanDetector(\n tableName: string,\n foreignKey: ForeignKeyDefinition,\n options: {\n engine?: DatabaseEngine;\n limitOne?: boolean;\n /**\n * Aggregate the same predicate as `COUNT(*)` instead of listing orphan\n * keys. Used by the read-only per-foreign-key orphan count report\n * (#2753) so the report shares the exact FROM/JOIN/WHERE clause the\n * migration gate probes with, rather than a second hand-written copy.\n */\n countOnly?: boolean;\n uuidComparison?: boolean;\n uuidCastSide?: ForeignKeyUuidCastSide;\n } = {},\n): string {\n const parts = foreignKeyOrphanParts(\n tableName,\n foreignKey,\n options.engine,\n options.uuidComparison,\n options.uuidCastSide,\n );\n const fromClause =\n `${parts.childTable} ` +\n `LEFT JOIN ${parts.parentTable} ON ${parts.joinPredicate} ` +\n `WHERE ${parts.childColumn} IS NOT NULL AND ${parts.parentColumn} IS NULL`;\n if (options.countOnly) {\n return `SELECT COUNT(*) AS orphan_count FROM ${fromClause}`;\n }\n return (\n `SELECT ${parts.childColumn} AS orphan_key FROM ${fromClause}` +\n (options.limitOne ? ' LIMIT 1' : '')\n );\n}\n\nexport function renderForeignKeyOrphanRepair(\n tableName: string,\n foreignKey: ForeignKeyDefinition,\n options: {\n engine?: DatabaseEngine;\n uuidComparison?: boolean;\n uuidCastSide?: ForeignKeyUuidCastSide;\n nullable?: boolean;\n } = {},\n): string {\n const parts = foreignKeyOrphanParts(\n tableName,\n foreignKey,\n options.engine,\n options.uuidComparison,\n options.uuidCastSide,\n );\n const orphanPredicate =\n `${parts.childColumn} IS NOT NULL AND NOT EXISTS (` +\n `SELECT 1 FROM ${parts.parentTable} WHERE ${parts.joinPredicate})`;\n if (options.nullable === false) {\n return (\n `-- Manual repair required: ${quoteIdentifier(tableName)}.${quoteIdentifier(foreignKey.column)} is NOT NULL. ` +\n `Reassign each orphaned reference to a valid ${quoteIdentifier(foreignKey.referencesTable)}.${quoteIdentifier(foreignKey.referencesColumn)} value, ` +\n 'or deliberately delete rows only after preserving any required child data.'\n );\n }\n return (\n `UPDATE ${quoteIdentifier(tableName)} AS ${parts.childAlias} ` +\n `SET ${quoteIdentifier(foreignKey.column)} = NULL ` +\n `WHERE ${orphanPredicate}`\n );\n}\n\nconst FOREIGN_KEY_CHILD_ALIAS = 'smrt_fk_child';\nconst FOREIGN_KEY_PARENT_ALIAS = 'smrt_fk_parent';\n/**\n * Canonical UUID text shape used by every PostgreSQL guard that has to decide\n * whether a `text` column can be reinterpreted as `uuid`. Exported so the\n * orphan probe, the FK provisioning guard, and the uuid convergence planner\n * all test the same shape (#2608).\n */\nexport const CANONICAL_UUID_PATTERN =\n '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$';\n\n/**\n * The same canonical UUID shape as {@link CANONICAL_UUID_PATTERN}, expressed\n * as a SQLite `GLOB` pattern instead of a regex — SQLite has no built-in\n * regex operator, so a shape probe there cannot push a regex predicate down\n * to the server. `GLOB` is case-sensitive, so callers must `LOWER()` the\n * value first; combine with a `LENGTH(...) = 36` check (`GLOB` alone allows\n * a shorter/longer value to still match a middle substring) so this can run\n * as a whole-table server-side aggregate instead of fetching every row into\n * JS to test in a loop (#2767 review).\n */\nexport const CANONICAL_UUID_SQLITE_GLOB_PATTERN =\n '[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]-' +\n '[0-9a-f][0-9a-f][0-9a-f][0-9a-f]-' +\n '[0-9a-f][0-9a-f][0-9a-f][0-9a-f]-' +\n '[0-9a-f][0-9a-f][0-9a-f][0-9a-f]-' +\n '[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]';\n\nfunction foreignKeyOrphanParts(\n tableName: string,\n foreignKey: ForeignKeyDefinition,\n engine: DatabaseEngine = 'postgres',\n uuidComparison = false,\n uuidCastSide?: ForeignKeyUuidCastSide,\n) {\n const childAlias = quoteIdentifier(FOREIGN_KEY_CHILD_ALIAS);\n const parentAlias = quoteIdentifier(FOREIGN_KEY_PARENT_ALIAS);\n const childColumn = `${childAlias}.${quoteIdentifier(foreignKey.column)}`;\n const parentColumn = `${parentAlias}.${quoteIdentifier(foreignKey.referencesColumn)}`;\n const guardedUuidValue = (column: string) =>\n `CASE WHEN ${column}::text ~* '${CANONICAL_UUID_PATTERN}' THEN ${column}::uuid ELSE NULL END`;\n const effectiveUuidCastSide =\n engine === 'postgres' && uuidComparison\n ? (uuidCastSide ?? 'child')\n : undefined;\n const childValue =\n effectiveUuidCastSide === 'child' || effectiveUuidCastSide === 'both'\n ? guardedUuidValue(childColumn)\n : childColumn;\n const parentValue =\n effectiveUuidCastSide === 'parent' || effectiveUuidCastSide === 'both'\n ? guardedUuidValue(parentColumn)\n : parentColumn;\n\n return {\n childAlias,\n childColumn,\n childTable: `${quoteIdentifier(tableName)} AS ${childAlias}`,\n joinPredicate: `${parentValue} = ${childValue}`,\n parentColumn,\n parentTable: `${quoteIdentifier(foreignKey.referencesTable)} AS ${parentAlias}`,\n };\n}\n\nexport function schemaForeignKeys(\n schema: Pick<SchemaDefinition, 'columns'> &\n Partial<Pick<SchemaDefinition, 'foreignKeys'>>,\n): ForeignKeyDefinition[] {\n if (schema.foreignKeys && schema.foreignKeys.length > 0) {\n return schema.foreignKeys;\n }\n return Object.entries(schema.columns).flatMap(([column, definition]) => {\n if (!definition.foreignKey) return [];\n return [\n {\n column,\n referencesTable: definition.foreignKey.table,\n referencesColumn: definition.foreignKey.column,\n onDelete: definition.foreignKey.onDelete,\n onUpdate: definition.foreignKey.onUpdate,\n ...(definition.foreignKey.engines\n ? { engines: definition.foreignKey.engines }\n : {}),\n },\n ];\n });\n}\n\n/** Stable identity for one physical relationship, independent of allocation. */\nexport function foreignKeyRelationshipKey(\n foreignKey: Pick<\n ForeignKeyDefinition,\n 'column' | 'referencesColumn' | 'referencesTable'\n >,\n): string {\n return JSON.stringify([\n foreignKey.column,\n foreignKey.referencesTable,\n foreignKey.referencesColumn,\n ]);\n}\n\n/** Return only physical constraints explicitly enabled for an engine. */\nexport function schemaForeignKeysForEngine(\n schema: Pick<SchemaDefinition, 'columns'> &\n Partial<Pick<SchemaDefinition, 'foreignKeys'>>,\n engine: DatabaseEngine,\n): ForeignKeyDefinition[] {\n return schemaForeignKeys(schema).filter((foreignKey) => {\n if (foreignKey.engines === undefined) return true;\n\n const validEngines = new Set<DatabaseEngine>([\n 'postgres',\n 'sqlite',\n 'duckdb',\n 'json',\n ]);\n if (\n foreignKey.engines.length === 0 ||\n foreignKey.engines.some(\n (declaredEngine) => !validEngines.has(declaredEngine),\n )\n ) {\n throw new Error(\n `[DDL:${engine}] Foreign key ${foreignKey.column} declares an invalid physical constraint engine allowlist. Use one or more of: postgres, sqlite, duckdb, json.`,\n );\n }\n\n return foreignKey.engines.includes(engine);\n });\n}\n\n/** Return declared schema dependencies after excluding engine-disabled FKs. */\nexport function schemaDependenciesForEngine(\n schema: Pick<SchemaDefinition, 'columns'> &\n Partial<Pick<SchemaDefinition, 'dependencies' | 'foreignKeys'>>,\n engine: DatabaseEngine,\n): string[] {\n const foreignKeys = schemaForeignKeys(schema);\n const enabledForeignKeys = schemaForeignKeysForEngine(schema, engine);\n const enabledKeys = new Set(\n enabledForeignKeys.map(foreignKeyRelationshipKey),\n );\n const enabledTargets = new Set(\n enabledForeignKeys.map((foreignKey) => foreignKey.referencesTable),\n );\n const disabledTargets = new Set(\n foreignKeys\n .filter(\n (foreignKey) => !enabledKeys.has(foreignKeyRelationshipKey(foreignKey)),\n )\n .map((foreignKey) => foreignKey.referencesTable),\n );\n\n return Array.from(\n new Set([\n ...(schema.dependencies ?? []).filter(\n (dependency) =>\n !disabledTargets.has(dependency) || enabledTargets.has(dependency),\n ),\n ...enabledTargets,\n ]),\n );\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { DatabaseInterface } from '@happyvertical/sql';
|
|
2
|
+
import { DatabaseEngine } from './ddl/types.js';
|
|
3
|
+
import { SchemaDefinition } from './types.js';
|
|
4
|
+
/** One manifest foreign key's live orphan count. */
|
|
5
|
+
export interface ForeignKeyOrphanCount {
|
|
6
|
+
childTable: string;
|
|
7
|
+
childColumn: string;
|
|
8
|
+
parentTable: string;
|
|
9
|
+
parentColumn: string;
|
|
10
|
+
/** Number of live child rows whose reference does not resolve. */
|
|
11
|
+
orphanCount: number;
|
|
12
|
+
/** Whether the child column allows NULL (a null-out repair is possible). */
|
|
13
|
+
nullable: boolean;
|
|
14
|
+
}
|
|
15
|
+
/** A manifest foreign key the report could not probe, and why. */
|
|
16
|
+
export interface ForeignKeyOrphanSkipped {
|
|
17
|
+
childTable: string;
|
|
18
|
+
childColumn: string;
|
|
19
|
+
parentTable: string;
|
|
20
|
+
parentColumn: string;
|
|
21
|
+
reason: string;
|
|
22
|
+
/**
|
|
23
|
+
* `missing_table` is benign and expected (a manifest table not yet
|
|
24
|
+
* migrated, or filtered out of this database). `probe_failed` means the
|
|
25
|
+
* `COUNT(*)` query itself errored — a real signal (permissions, a
|
|
26
|
+
* malformed live column, a genuine SQL failure) that a caller should
|
|
27
|
+
* surface distinctly rather than silently treat as "no orphans found".
|
|
28
|
+
*/
|
|
29
|
+
kind: 'missing_table' | 'probe_failed';
|
|
30
|
+
}
|
|
31
|
+
/** Aggregate result of one orphan-count run. */
|
|
32
|
+
export interface ForeignKeyOrphanCountReport {
|
|
33
|
+
engine: DatabaseEngine;
|
|
34
|
+
/**
|
|
35
|
+
* Every probed foreign key with a live orphan count, sorted by
|
|
36
|
+
* `orphanCount` descending so the largest problems surface first.
|
|
37
|
+
* Includes relationships with zero orphans.
|
|
38
|
+
*/
|
|
39
|
+
counts: ForeignKeyOrphanCount[];
|
|
40
|
+
/** Foreign keys skipped because a table did not exist live, or the probe failed. */
|
|
41
|
+
skipped: ForeignKeyOrphanSkipped[];
|
|
42
|
+
}
|
|
43
|
+
export interface CollectForeignKeyOrphanCountsOptions {
|
|
44
|
+
/** Explicit engine hint for adapters whose URL is empty or ambiguous. */
|
|
45
|
+
engineHint?: string;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Probe every manifest-declared foreign key for live orphan rows and report
|
|
49
|
+
* a count per relationship. Read-only: never repairs, never fails the whole
|
|
50
|
+
* report for one bad relationship.
|
|
51
|
+
*
|
|
52
|
+
* @param db Live database connection.
|
|
53
|
+
* @param schemas Manifest schemas, keyed by table name — typically
|
|
54
|
+
* `ObjectRegistry.getAllSchemasAsDefinitions()`.
|
|
55
|
+
*/
|
|
56
|
+
export declare function collectForeignKeyOrphanCounts(db: DatabaseInterface, schemas: Record<string, SchemaDefinition>, options?: CollectForeignKeyOrphanCountsOptions): Promise<ForeignKeyOrphanCountReport>;
|
|
57
|
+
//# sourceMappingURL=foreign-key-orphan-report.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"foreign-key-orphan-report.d.ts","sourceRoot":"","sources":["../../src/schema/foreign-key-orphan-report.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAE5D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAMrD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAEnD,oDAAoD;AACpD,MAAM,WAAW,qBAAqB;IACpC,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC;IACrB,kEAAkE;IAClE,WAAW,EAAE,MAAM,CAAC;IACpB,4EAA4E;IAC5E,QAAQ,EAAE,OAAO,CAAC;CACnB;AAED,kEAAkE;AAClE,MAAM,WAAW,uBAAuB;IACtC,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC;IACrB,MAAM,EAAE,MAAM,CAAC;IACf;;;;;;OAMG;IACH,IAAI,EAAE,eAAe,GAAG,cAAc,CAAC;CACxC;AAED,gDAAgD;AAChD,MAAM,WAAW,2BAA2B;IAC1C,MAAM,EAAE,cAAc,CAAC;IACvB;;;;OAIG;IACH,MAAM,EAAE,qBAAqB,EAAE,CAAC;IAChC,oFAAoF;IACpF,OAAO,EAAE,uBAAuB,EAAE,CAAC;CACpC;AAED,MAAM,WAAW,oCAAoC;IACnD,yEAAyE;IACzE,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED;;;;;;;;GAQG;AACH,wBAAsB,6BAA6B,CACjD,EAAE,EAAE,iBAAiB,EACrB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,EACzC,OAAO,GAAE,oCAAyC,GACjD,OAAO,CAAC,2BAA2B,CAAC,CA4FtC"}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { renderForeignKeyOrphanDetector, schemaForeignKeysForEngine } from "./foreign-key-ddl.js";
|
|
2
|
+
import { detectEngine } from "./ddl/index.js";
|
|
3
|
+
//#region src/schema/foreign-key-orphan-report.ts
|
|
4
|
+
/**
|
|
5
|
+
* Probe every manifest-declared foreign key for live orphan rows and report
|
|
6
|
+
* a count per relationship. Read-only: never repairs, never fails the whole
|
|
7
|
+
* report for one bad relationship.
|
|
8
|
+
*
|
|
9
|
+
* @param db Live database connection.
|
|
10
|
+
* @param schemas Manifest schemas, keyed by table name — typically
|
|
11
|
+
* `ObjectRegistry.getAllSchemasAsDefinitions()`.
|
|
12
|
+
*/
|
|
13
|
+
async function collectForeignKeyOrphanCounts(db, schemas, options = {}) {
|
|
14
|
+
const engine = resolveEngine(db, options.engineHint);
|
|
15
|
+
const liveTables = await listLiveTables(db, engine);
|
|
16
|
+
const counts = [];
|
|
17
|
+
const skipped = [];
|
|
18
|
+
for (const [childTable, schema] of Object.entries(schemas)) {
|
|
19
|
+
const foreignKeys = schemaForeignKeysForEngine(schema, engine);
|
|
20
|
+
for (const foreignKey of foreignKeys) {
|
|
21
|
+
const parentTable = foreignKey.referencesTable;
|
|
22
|
+
const base = {
|
|
23
|
+
childTable,
|
|
24
|
+
childColumn: foreignKey.column,
|
|
25
|
+
parentTable,
|
|
26
|
+
parentColumn: foreignKey.referencesColumn
|
|
27
|
+
};
|
|
28
|
+
if (!liveTables.has(childTable)) {
|
|
29
|
+
skipped.push({
|
|
30
|
+
...base,
|
|
31
|
+
kind: "missing_table",
|
|
32
|
+
reason: `Child table \`${childTable}\` does not exist in the live database.`
|
|
33
|
+
});
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (!liveTables.has(parentTable)) {
|
|
37
|
+
skipped.push({
|
|
38
|
+
...base,
|
|
39
|
+
kind: "missing_table",
|
|
40
|
+
reason: `Parent table \`${parentTable}\` does not exist in the live database.`
|
|
41
|
+
});
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
const childColumnDefinition = schema.columns[foreignKey.column];
|
|
45
|
+
const nullable = childColumnDefinition?.notNull !== true;
|
|
46
|
+
const parentColumnDefinition = schemas[parentTable]?.columns[foreignKey.referencesColumn];
|
|
47
|
+
const { uuidComparison, uuidCastSide } = await resolveUuidCastSide(db, {
|
|
48
|
+
engine,
|
|
49
|
+
declaredUuidComparison: childColumnDefinition?.type === "UUID" && (parentColumnDefinition === void 0 || parentColumnDefinition.type === "UUID"),
|
|
50
|
+
childTable,
|
|
51
|
+
childColumn: foreignKey.column,
|
|
52
|
+
parentTable,
|
|
53
|
+
parentColumn: foreignKey.referencesColumn
|
|
54
|
+
});
|
|
55
|
+
const sql = renderForeignKeyOrphanDetector(childTable, foreignKey, {
|
|
56
|
+
engine,
|
|
57
|
+
countOnly: true,
|
|
58
|
+
uuidComparison,
|
|
59
|
+
uuidCastSide
|
|
60
|
+
});
|
|
61
|
+
try {
|
|
62
|
+
const raw = normalizeQueryRows(await db.query(sql))[0]?.orphan_count;
|
|
63
|
+
const orphanCount = Number(raw);
|
|
64
|
+
counts.push({
|
|
65
|
+
...base,
|
|
66
|
+
orphanCount: Number.isFinite(orphanCount) ? orphanCount : 0,
|
|
67
|
+
nullable
|
|
68
|
+
});
|
|
69
|
+
} catch (error) {
|
|
70
|
+
skipped.push({
|
|
71
|
+
...base,
|
|
72
|
+
kind: "probe_failed",
|
|
73
|
+
reason: `Could not probe for orphan rows: ${error instanceof Error ? error.message : String(error)}`
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
counts.sort((a, b) => b.orphanCount - a.orphanCount);
|
|
79
|
+
return {
|
|
80
|
+
engine,
|
|
81
|
+
counts,
|
|
82
|
+
skipped
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Resolve whether to guard-cast the probe's join predicate, and which side,
|
|
87
|
+
* from the LIVE column types rather than the manifest's declared types —
|
|
88
|
+
* mirroring `SchemaComparer.getForeignKeyOrphanOptions()` in
|
|
89
|
+
* `migrations/differ.ts` so the diagnostic and the migration gate treat the
|
|
90
|
+
* same relationship the same way. Only PostgreSQL casts at all
|
|
91
|
+
* (`foreignKeyOrphanParts()` ignores `uuidComparison` on every other engine).
|
|
92
|
+
*/
|
|
93
|
+
async function resolveUuidCastSide(db, options) {
|
|
94
|
+
const { engine, declaredUuidComparison, childTable, childColumn, parentTable, parentColumn } = options;
|
|
95
|
+
if (!declaredUuidComparison || engine !== "postgres") return { uuidComparison: false };
|
|
96
|
+
if (typeof db.getTableSchema !== "function") return { uuidComparison: true };
|
|
97
|
+
const childType = await readLiveColumnType(db, childTable, childColumn);
|
|
98
|
+
const parentType = await readLiveColumnType(db, parentTable, parentColumn);
|
|
99
|
+
if (!childType || !parentType) return { uuidComparison: true };
|
|
100
|
+
const childIsUuid = isUuidType(childType);
|
|
101
|
+
if (childIsUuid === isUuidType(parentType)) return { uuidComparison: false };
|
|
102
|
+
return {
|
|
103
|
+
uuidComparison: true,
|
|
104
|
+
uuidCastSide: childIsUuid ? "parent" : "child"
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
function isUuidType(type) {
|
|
108
|
+
return type.toUpperCase().includes("UUID");
|
|
109
|
+
}
|
|
110
|
+
async function readLiveColumnType(db, tableName, columnName) {
|
|
111
|
+
try {
|
|
112
|
+
const type = (await db.getTableSchema?.(tableName))?.columns?.[columnName]?.type;
|
|
113
|
+
return type === void 0 || type === null ? void 0 : String(type);
|
|
114
|
+
} catch {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function resolveEngine(db, engineHint) {
|
|
119
|
+
if (typeof db.exportTable === "function") return "json";
|
|
120
|
+
const dbWithConfig = db;
|
|
121
|
+
return detectEngine(db.url || dbWithConfig.config?.url || "", engineHint);
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Normalize a `db.query()` result to a row array. Adapters disagree on the
|
|
125
|
+
* envelope: most return `{ rows }`, but some (per the same normalization in
|
|
126
|
+
* `migrations/differ.ts`'s `getExistingTables()`) return a bare array. A
|
|
127
|
+
* caller that only checks `.rows` reads every row as absent on the latter,
|
|
128
|
+
* which for `listLiveTables()` means every relationship looks like it has a
|
|
129
|
+
* `missing_table` — a silent false negative, not a thrown error.
|
|
130
|
+
*/
|
|
131
|
+
function normalizeQueryRows(result) {
|
|
132
|
+
return Array.isArray(result) ? result : result?.rows ?? [];
|
|
133
|
+
}
|
|
134
|
+
async function listLiveTables(db, engine) {
|
|
135
|
+
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_%'`;
|
|
136
|
+
const rows = normalizeQueryRows(await db.query(sql));
|
|
137
|
+
return new Set(rows.map((row) => row.name || row.table_name || "").filter(Boolean));
|
|
138
|
+
}
|
|
139
|
+
//#endregion
|
|
140
|
+
export { collectForeignKeyOrphanCounts };
|
|
141
|
+
|
|
142
|
+
//# sourceMappingURL=foreign-key-orphan-report.js.map
|
|
@@ -0,0 +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\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 const nullable = childColumnDefinition?.notNull !== true;\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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"}
|
package/dist/schema/index.d.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
export { SchemaCodeGenerator } from './code-generator.js';
|
|
5
5
|
export * from './ddl/index.js';
|
|
6
6
|
export { foreignKeyConstraintName, renderForeignKeyConstraint, renderForeignKeyConstraintComment, renderForeignKeyConstraintDrop, renderForeignKeyConstraintValidate, schemaForeignKeys, schemaForeignKeysForEngine, } from './foreign-key-ddl.js';
|
|
7
|
+
export { type CollectForeignKeyOrphanCountsOptions, collectForeignKeyOrphanCounts, type ForeignKeyOrphanCount, type ForeignKeyOrphanCountReport, type ForeignKeyOrphanSkipped, } from './foreign-key-orphan-report.js';
|
|
7
8
|
export { type ForeignKeyCreationPlan, planForeignKeyCreation, } from './foreign-key-planner.js';
|
|
8
9
|
export { normalizeForeignKeyAction, resolveForeignKeyDeleteAction, } from './foreign-key-policy.js';
|
|
9
10
|
export { SchemaGenerator } from './generator.js';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/schema/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;AAE1D,cAAc,gBAAgB,CAAC;AAC/B,OAAO,EACL,wBAAwB,EACxB,0BAA0B,EAC1B,iCAAiC,EACjC,8BAA8B,EAC9B,kCAAkC,EAClC,iBAAiB,EACjB,0BAA0B,GAC3B,MAAM,sBAAsB,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/schema/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;AAE1D,cAAc,gBAAgB,CAAC;AAC/B,OAAO,EACL,wBAAwB,EACxB,0BAA0B,EAC1B,iCAAiC,EACjC,8BAA8B,EAC9B,kCAAkC,EAClC,iBAAiB,EACjB,0BAA0B,GAC3B,MAAM,sBAAsB,CAAC;AAE9B,OAAO,EACL,KAAK,oCAAoC,EACzC,6BAA6B,EAC7B,KAAK,qBAAqB,EAC1B,KAAK,2BAA2B,EAChC,KAAK,uBAAuB,GAC7B,MAAM,gCAAgC,CAAC;AACxC,OAAO,EACL,KAAK,sBAAsB,EAC3B,sBAAsB,GACvB,MAAM,0BAA0B,CAAC;AAClC,OAAO,EACL,yBAAyB,EACzB,6BAA6B,GAC9B,MAAM,yBAAyB,CAAC;AACjC,OAAO,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;AAEjD,OAAO,EACL,KAAK,mBAAmB,EACxB,qBAAqB,EACrB,KAAK,mBAAmB,EACxB,KAAK,iBAAiB,EACtB,KAAK,qBAAqB,EAC1B,KAAK,kBAAkB,EACvB,qBAAqB,EACrB,KAAK,uBAAuB,EAC5B,KAAK,sBAAsB,EAC3B,gBAAgB,EAChB,oBAAoB,GACrB,MAAM,kBAAkB,CAAC;AAG1B,OAAO,EACL,KAAK,sBAAsB,EAC3B,qBAAqB,EACrB,KAAK,kBAAkB,EACvB,KAAK,iBAAiB,EACtB,KAAK,kBAAkB,EACvB,4BAA4B,EAC5B,4BAA4B,EAC5B,0BAA0B,EAC1B,yBAAyB,EACzB,4BAA4B,GAC7B,MAAM,sBAAsB,CAAC;AAC9B,YAAY,EACV,eAAe,EACf,gBAAgB,EAChB,iBAAiB,GAClB,MAAM,wBAAwB,CAAC;AAChC,OAAO,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC1D,OAAO,EAAE,mBAAmB,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACzE,OAAO,EACL,oBAAoB,EACpB,gBAAgB,EAChB,kBAAkB,EAClB,KAAK,sBAAsB,EAC3B,KAAK,qBAAqB,EAC1B,KAAK,gBAAgB,GACtB,MAAM,0BAA0B,CAAC;AAGlC,YAAY,EACV,gBAAgB,EAChB,oBAAoB,EACpB,eAAe,EACf,gBAAgB,EAChB,cAAc,EACd,eAAe,EACf,iBAAiB,GAClB,MAAM,YAAY,CAAC;AACpB,cAAc,YAAY,CAAC"}
|
package/dist/schema/index.js
CHANGED
|
@@ -11,7 +11,8 @@ import { SchemaGenerator } from "./generator.js";
|
|
|
11
11
|
import { planForeignKeyCreation } from "./foreign-key-planner.js";
|
|
12
12
|
import { SchemaManager, createSchemaManager } from "./schema-manager.js";
|
|
13
13
|
import { collectManifestTables, manifestColumnsToDefinitions, manifestIndexesToDefinitions, manifestSchemaToDefinition, mergeSchemaDefinitionInto, renderCollectedManifestTable } from "./manifest-schema.js";
|
|
14
|
+
import { collectForeignKeyOrphanCounts } from "./foreign-key-orphan-report.js";
|
|
14
15
|
import { LiveSchemaParityError, checkLiveSchemaParity, normalizeSqlType, parseIndexDefColumns } from "./live-parity.js";
|
|
15
16
|
import { SchemaCodeGenerator } from "./code-generator.js";
|
|
16
17
|
import { SchemaAggregator } from "./schema-aggregator.js";
|
|
17
|
-
export { BaseDDLStrategy, DuckDBStrategy, JsonDuckDBStrategy, LiveSchemaParityError, PostgresStrategy, SQLiteStrategy, SYSTEM_TABLE_NAMES, SchemaAggregator, SchemaCodeGenerator, SchemaGenerator, SchemaManager, checkLiveSchemaParity, collectManifestTables, createSchemaManager, detectEngine, foreignKeyConstraintName, generateDDLForEngine, generateMultiEngineDDL, getAllStrategies, getDDLStrategy, getSystemTableShapes, manifestColumnsToDefinitions, manifestIndexesToDefinitions, manifestSchemaToDefinition, mergeSchemaDefinitionInto, normalizeForeignKeyAction, normalizeSqlType, parseIndexDefColumns, parseTableShapes, planForeignKeyCreation, renderCollectedManifestTable, renderForeignKeyConstraint, renderForeignKeyConstraintComment, renderForeignKeyConstraintDrop, renderForeignKeyConstraintValidate, resolveForeignKeyDeleteAction, schemaForeignKeys, schemaForeignKeysForEngine };
|
|
18
|
+
export { BaseDDLStrategy, DuckDBStrategy, JsonDuckDBStrategy, LiveSchemaParityError, PostgresStrategy, SQLiteStrategy, SYSTEM_TABLE_NAMES, SchemaAggregator, SchemaCodeGenerator, SchemaGenerator, SchemaManager, checkLiveSchemaParity, collectForeignKeyOrphanCounts, collectManifestTables, createSchemaManager, detectEngine, foreignKeyConstraintName, generateDDLForEngine, generateMultiEngineDDL, getAllStrategies, getDDLStrategy, getSystemTableShapes, manifestColumnsToDefinitions, manifestIndexesToDefinitions, manifestSchemaToDefinition, mergeSchemaDefinitionInto, normalizeForeignKeyAction, normalizeSqlType, parseIndexDefColumns, parseTableShapes, planForeignKeyCreation, renderCollectedManifestTable, renderForeignKeyConstraint, renderForeignKeyConstraintComment, renderForeignKeyConstraintDrop, renderForeignKeyConstraintValidate, resolveForeignKeyDeleteAction, schemaForeignKeys, schemaForeignKeysForEngine };
|
|
@@ -6,7 +6,14 @@ export type LiveParitySeverity = 'error' | 'warning' | 'info';
|
|
|
6
6
|
/** The class of drift a finding describes. */
|
|
7
7
|
export type LiveParityFindingKind = 'missing_table' | 'extra_table' | 'missing_column' | 'extra_column' | 'column_type_drift' | 'column_nullability_drift' | 'missing_index' | 'extra_index' | 'unindexed_reference' | 'conflict_target_unindexed' | 'conflict_target_not_unique' | 'unique_constraint_missing' | 'invalid_index'
|
|
8
8
|
/** Pre-#2373 PostgreSQL/DuckDB int4 column needing opt-in widening. */
|
|
9
|
-
| 'legacy_integer_width'
|
|
9
|
+
| 'legacy_integer_width'
|
|
10
|
+
/**
|
|
11
|
+
* A declared column exists live and holds no data while an undeclared
|
|
12
|
+
* column of a compatible type does (#2752) — the shape a framework field
|
|
13
|
+
* rename leaves behind when `db:migrate` adds the new column additively
|
|
14
|
+
* and never moves the old data into it.
|
|
15
|
+
*/
|
|
16
|
+
| 'rename_data_pending';
|
|
10
17
|
/** One difference between the live database and the expected shape. */
|
|
11
18
|
export interface LiveParityFinding {
|
|
12
19
|
kind: LiveParityFindingKind;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"live-parity.d.ts","sourceRoot":"","sources":["../../src/schema/live-parity.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAO5D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;
|
|
1
|
+
{"version":3,"file":"live-parity.d.ts","sourceRoot":"","sources":["../../src/schema/live-parity.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { quoteIdentifier } from "./sql-identifiers.js";
|
|
2
|
+
import { CANONICAL_UUID_PATTERN, CANONICAL_UUID_SQLITE_GLOB_PATTERN } from "./foreign-key-ddl.js";
|
|
1
3
|
import { detectEngine, getDDLStrategy } from "./ddl/index.js";
|
|
2
4
|
import { RETIRED_SYSTEM_TABLES } from "../system/schema.js";
|
|
3
5
|
import { getSystemTableShapes } from "./system-table-shapes.js";
|
|
@@ -50,6 +52,7 @@ async function checkLiveSchemaParity(options) {
|
|
|
50
52
|
tablesChecked++;
|
|
51
53
|
const liveColumns = await readLiveColumns(db, table.name);
|
|
52
54
|
findings.push(...compareColumns(table, liveColumns, engine));
|
|
55
|
+
findings.push(...await detectRenameDataPending(db, engine, table, liveColumns));
|
|
53
56
|
if (indexCatalog) {
|
|
54
57
|
const liveIndexes = await indexCatalog.forTable(table.name);
|
|
55
58
|
findings.push(...compareIndexes(table, liveColumns, liveIndexes));
|
|
@@ -278,6 +281,101 @@ function normalizeSqlType(type) {
|
|
|
278
281
|
if (/^(JSON|JSONB)$/.test(upper)) return "JSON";
|
|
279
282
|
return upper;
|
|
280
283
|
}
|
|
284
|
+
/**
|
|
285
|
+
* Whether an undeclared live column could be the pre-rename home of a
|
|
286
|
+
* declared column's data. "Compatible" per #2752: identical normalized
|
|
287
|
+
* type, or the undeclared column is `TEXT` while the declared column is
|
|
288
|
+
* `UUID` (gated separately on data shape by the caller).
|
|
289
|
+
*/
|
|
290
|
+
function renameCompatibility(declaredType, candidateType) {
|
|
291
|
+
if (declaredType === candidateType) return "same-type";
|
|
292
|
+
if (declaredType === "UUID" && candidateType === "TEXT") return "text-to-uuid";
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
async function columnHasNonEmptyValue(db, table, column) {
|
|
296
|
+
const quotedTable = quoteIdentifier(table);
|
|
297
|
+
const quotedColumn = quoteIdentifier(column);
|
|
298
|
+
return ((await db.query(`SELECT 1 AS present FROM ${quotedTable} WHERE ${quotedColumn} IS NOT NULL AND CAST(${quotedColumn} AS TEXT) <> '' LIMIT 1`))?.rows?.length ?? 0) > 0;
|
|
299
|
+
}
|
|
300
|
+
async function allNonEmptyValuesUuidShaped(db, engine, table, column) {
|
|
301
|
+
const quotedTable = quoteIdentifier(table);
|
|
302
|
+
const quotedColumn = quoteIdentifier(column);
|
|
303
|
+
const nonEmptyPredicate = `${quotedColumn} IS NOT NULL AND CAST(${quotedColumn} AS TEXT) <> ''`;
|
|
304
|
+
if (engine === "postgres") {
|
|
305
|
+
const row = (await db.query(`SELECT count(*) AS invalid_count FROM ${quotedTable} WHERE ${nonEmptyPredicate} AND CAST(${quotedColumn} AS TEXT) !~* '${CANONICAL_UUID_PATTERN}'`))?.rows?.[0];
|
|
306
|
+
return Number(row?.invalid_count ?? 0) === 0;
|
|
307
|
+
}
|
|
308
|
+
const row = (await db.query(`SELECT count(*) AS invalid_count FROM ${quotedTable} WHERE ${nonEmptyPredicate} AND NOT (LENGTH(CAST(${quotedColumn} AS TEXT)) = 36 AND LOWER(CAST(${quotedColumn} AS TEXT)) GLOB '${CANONICAL_UUID_SQLITE_GLOB_PATTERN}')`))?.rows?.[0];
|
|
309
|
+
return Number(row?.invalid_count ?? 0) === 0;
|
|
310
|
+
}
|
|
311
|
+
function buildRenameDataPendingFinding(table, declaredColumn, candidates) {
|
|
312
|
+
const single = candidates.length === 1;
|
|
313
|
+
const candidateList = candidates.map((name) => `\`${name}\``).join(", ");
|
|
314
|
+
return {
|
|
315
|
+
kind: "rename_data_pending",
|
|
316
|
+
severity: "warning",
|
|
317
|
+
table: table.name,
|
|
318
|
+
target: declaredColumn,
|
|
319
|
+
origin: table.origin,
|
|
320
|
+
message: single ? `Column \`${table.name}.${declaredColumn}\` is declared but empty, while undeclared column \`${table.name}.${candidates[0]}\` holds data of a compatible type. Data appears to still live in \`${candidates[0]}\` after a field rename.` : `Column \`${table.name}.${declaredColumn}\` is declared but empty, while ${candidates.length} undeclared columns hold data of a compatible type (${candidateList}). Data may still live in one of them after a field rename, but which one is ambiguous.`,
|
|
321
|
+
recommendation: single ? `Run \`smrt db:diff\` for the suggested backfill SQL, review it, then copy data from \`${candidates[0]}\` into \`${declaredColumn}\` and drop \`${candidates[0]}\`.` : "Identify which undeclared column actually holds the pre-rename data before backfilling; SMRT will not guess among multiple candidates.",
|
|
322
|
+
details: { candidates }
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Detect a pending rename backfill (#2752): a manifest-declared column that
|
|
327
|
+
* exists live but holds no data, paired with an undeclared live column of a
|
|
328
|
+
* compatible type that does hold data. This is the shape a framework field
|
|
329
|
+
* rename leaves behind when `db:migrate` adds the new column additively and
|
|
330
|
+
* never moves data into it, nor drops the old column.
|
|
331
|
+
*
|
|
332
|
+
* Advisory only (`warning`): this module never repairs, and when several
|
|
333
|
+
* undeclared columns qualify it lists them all rather than guessing.
|
|
334
|
+
*/
|
|
335
|
+
async function detectRenameDataPending(db, engine, table, liveColumns) {
|
|
336
|
+
if (engine !== "postgres" && engine !== "sqlite") return [];
|
|
337
|
+
const expectedNames = new Set(table.columns.map((column) => column.name));
|
|
338
|
+
const extraColumns = [...liveColumns.values()].filter((live) => !expectedNames.has(live.name));
|
|
339
|
+
if (extraColumns.length === 0) return [];
|
|
340
|
+
const findings = [];
|
|
341
|
+
for (const column of table.columns) {
|
|
342
|
+
if (!liveColumns.get(column.name)) continue;
|
|
343
|
+
let declaredHasData;
|
|
344
|
+
try {
|
|
345
|
+
declaredHasData = await columnHasNonEmptyValue(db, table.name, column.name);
|
|
346
|
+
} catch {
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
if (declaredHasData) continue;
|
|
350
|
+
const declaredType = normalizeSqlType(column.type);
|
|
351
|
+
const candidates = [];
|
|
352
|
+
for (const extra of extraColumns) {
|
|
353
|
+
const compatibility = renameCompatibility(declaredType, normalizeSqlType(extra.type));
|
|
354
|
+
if (!compatibility) continue;
|
|
355
|
+
let candidateHasData;
|
|
356
|
+
try {
|
|
357
|
+
candidateHasData = await columnHasNonEmptyValue(db, table.name, extra.name);
|
|
358
|
+
} catch {
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
if (!candidateHasData) continue;
|
|
362
|
+
if (compatibility === "text-to-uuid") {
|
|
363
|
+
let shaped;
|
|
364
|
+
try {
|
|
365
|
+
shaped = await allNonEmptyValuesUuidShaped(db, engine, table.name, extra.name);
|
|
366
|
+
} catch {
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
if (!shaped) continue;
|
|
370
|
+
}
|
|
371
|
+
candidates.push(extra.name);
|
|
372
|
+
}
|
|
373
|
+
if (candidates.length === 0) continue;
|
|
374
|
+
candidates.sort();
|
|
375
|
+
findings.push(buildRenameDataPendingFinding(table, column.name, candidates));
|
|
376
|
+
}
|
|
377
|
+
return findings;
|
|
378
|
+
}
|
|
281
379
|
function compareIndexes(table, liveColumns, liveIndexes) {
|
|
282
380
|
const findings = [];
|
|
283
381
|
const byName = new Map(liveIndexes.map((index) => [index.name, index]));
|
|
@@ -368,13 +466,11 @@ function compareIndexes(table, liveColumns, liveIndexes) {
|
|
|
368
466
|
details: { referenceKind: column.reference }
|
|
369
467
|
});
|
|
370
468
|
}
|
|
371
|
-
const policyLeadColumns = new Set(table.columns.filter((column) => column.reference && column.reference !== "id").map((column) => column.name));
|
|
372
469
|
const declaredNames = new Set(table.indexes.map((index) => index.name));
|
|
373
470
|
const declaredSignatures = new Set(table.indexes.map((index) => indexSignature(index.columns, index.unique)));
|
|
374
471
|
for (const index of liveIndexes) {
|
|
375
472
|
if (index.primary) continue;
|
|
376
473
|
if (isConstraintOwnedIndexName(index.name)) continue;
|
|
377
|
-
if (policyLeadColumns.has(index.columns[0])) continue;
|
|
378
474
|
if (declaredNames.has(index.name)) continue;
|
|
379
475
|
if (declaredSignatures.has(indexSignature(index.columns, index.unique))) continue;
|
|
380
476
|
if (table.conflictTargets.some((target) => sameColumnSet(index.columns, target.columns))) continue;
|