@happyvertical/smrt-core 0.50.0 → 0.51.1
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 +1 -0
- package/dist/index.js +2 -1
- package/dist/knowledge-graph.d.ts +95 -0
- package/dist/knowledge-graph.d.ts.map +1 -0
- package/dist/knowledge-graph.js +268 -0
- package/dist/knowledge-graph.js.map +1 -0
- package/dist/knowledge.d.ts +1 -0
- package/dist/knowledge.d.ts.map +1 -1
- package/dist/knowledge.js +57 -4
- package/dist/knowledge.js.map +1 -1
- 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 +127 -21
- package/dist/migrations/differ.d.ts.map +1 -1
- package/dist/migrations/differ.js +379 -84
- package/dist/migrations/differ.js.map +1 -1
- package/dist/schema/column-data-probes.d.ts +38 -0
- package/dist/schema/column-data-probes.d.ts.map +1 -0
- package/dist/schema/column-data-probes.js +106 -0
- package/dist/schema/column-data-probes.js.map +1 -0
- package/dist/schema/live-parity.d.ts.map +1 -1
- package/dist/schema/live-parity.js +37 -46
- package/dist/schema/live-parity.js.map +1 -1
- package/dist/smrt-knowledge.json +12 -7
- package/package.json +4 -4
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { DatabaseInterface } from '@happyvertical/sql';
|
|
2
|
+
import { DatabaseEngine } from './ddl/types.js';
|
|
3
|
+
/**
|
|
4
|
+
* SQL predicate: a quoted column holds a non-null, non-empty value. Shared
|
|
5
|
+
* by every probe below (single-table batch, single-column fallback, and
|
|
6
|
+
* `migrations/differ.ts`'s cross-table batch, #2878) so the emptiness rule
|
|
7
|
+
* can never drift between them the way #2874's regression drifted between
|
|
8
|
+
* `differ.ts` and `live-parity.ts` before this module existed.
|
|
9
|
+
*/
|
|
10
|
+
export declare function nonEmptyValuePredicate(quotedColumn: string): string;
|
|
11
|
+
/**
|
|
12
|
+
* SQL predicate: a quoted column's value does *not* look UUID-shaped
|
|
13
|
+
* ({@link CANONICAL_UUID_PATTERN}), engine-aware (PostgreSQL regex vs.
|
|
14
|
+
* SQLite `GLOB`). Shared for the same reason as {@link nonEmptyValuePredicate}.
|
|
15
|
+
*/
|
|
16
|
+
export declare function uuidInvalidShapePredicate(engine: DatabaseEngine, quotedColumn: string): string;
|
|
17
|
+
/**
|
|
18
|
+
* Live-data probe, batched across every column named: does each hold any
|
|
19
|
+
* non-null, non-empty value? One round trip regardless of column count,
|
|
20
|
+
* one row of uncorrelated scalar subqueries,
|
|
21
|
+
* `(SELECT 1 FROM t WHERE ... LIMIT 1) AS c<N>`, portable across PostgreSQL
|
|
22
|
+
* and SQLite.
|
|
23
|
+
*/
|
|
24
|
+
export declare function columnsHaveNonEmptyValueBatch(db: DatabaseInterface, table: string, columns: string[]): Promise<Map<string, boolean>>;
|
|
25
|
+
/**
|
|
26
|
+
* Live-data probe, batched across every column named: are all of a
|
|
27
|
+
* column's non-empty values UUID-shaped ({@link CANONICAL_UUID_PATTERN})?
|
|
28
|
+
* One round trip regardless of column count, mirroring
|
|
29
|
+
* {@link columnsHaveNonEmptyValueBatch}: a value is absent from the result
|
|
30
|
+
* exactly when no invalid row exists, so this also short-circuits on the
|
|
31
|
+
* first invalid row rather than counting every one. PostgreSQL pushes the
|
|
32
|
+
* shape check into its regex operator; SQLite has no regex operator, but
|
|
33
|
+
* its case-sensitive `GLOB` can still express the fixed 36-character
|
|
34
|
+
* canonical shape ({@link CANONICAL_UUID_SQLITE_GLOB_PATTERN} against
|
|
35
|
+
* `LOWER(...)`, guarded by an exact `LENGTH(...) = 36` check).
|
|
36
|
+
*/
|
|
37
|
+
export declare function columnsAllValuesUuidShapedBatch(db: DatabaseInterface, engine: DatabaseEngine, table: string, columns: string[]): Promise<Map<string, boolean>>;
|
|
38
|
+
//# sourceMappingURL=column-data-probes.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"column-data-probes.d.ts","sourceRoot":"","sources":["../../src/schema/column-data-probes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAC5D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAOrD;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,CAEnE;AAED;;;;GAIG;AACH,wBAAgB,yBAAyB,CACvC,MAAM,EAAE,cAAc,EACtB,YAAY,EAAE,MAAM,GACnB,MAAM,CAKR;AAED;;;;;;GAMG;AACH,wBAAsB,6BAA6B,CACjD,EAAE,EAAE,iBAAiB,EACrB,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,MAAM,EAAE,GAChB,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAkB/B;AA6CD;;;;;;;;;;;GAWG;AACH,wBAAsB,+BAA+B,CACnD,EAAE,EAAE,iBAAiB,EACrB,MAAM,EAAE,cAAc,EACtB,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,MAAM,EAAE,GAChB,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAuB/B"}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { quoteIdentifier } from "./sql-identifiers.js";
|
|
2
|
+
import { CANONICAL_UUID_PATTERN, CANONICAL_UUID_SQLITE_GLOB_PATTERN } from "./foreign-key-ddl.js";
|
|
3
|
+
//#region src/schema/column-data-probes.ts
|
|
4
|
+
/**
|
|
5
|
+
* SQL predicate: a quoted column holds a non-null, non-empty value. Shared
|
|
6
|
+
* by every probe below (single-table batch, single-column fallback, and
|
|
7
|
+
* `migrations/differ.ts`'s cross-table batch, #2878) so the emptiness rule
|
|
8
|
+
* can never drift between them the way #2874's regression drifted between
|
|
9
|
+
* `differ.ts` and `live-parity.ts` before this module existed.
|
|
10
|
+
*/
|
|
11
|
+
function nonEmptyValuePredicate(quotedColumn) {
|
|
12
|
+
return `${quotedColumn} IS NOT NULL AND CAST(${quotedColumn} AS TEXT) <> ''`;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* SQL predicate: a quoted column's value does *not* look UUID-shaped
|
|
16
|
+
* ({@link CANONICAL_UUID_PATTERN}), engine-aware (PostgreSQL regex vs.
|
|
17
|
+
* SQLite `GLOB`). Shared for the same reason as {@link nonEmptyValuePredicate}.
|
|
18
|
+
*/
|
|
19
|
+
function uuidInvalidShapePredicate(engine, quotedColumn) {
|
|
20
|
+
return engine === "postgres" ? `CAST(${quotedColumn} AS TEXT) !~* '${CANONICAL_UUID_PATTERN}'` : `NOT (LENGTH(CAST(${quotedColumn} AS TEXT)) = 36 AND LOWER(CAST(${quotedColumn} AS TEXT)) GLOB '${CANONICAL_UUID_SQLITE_GLOB_PATTERN}')`;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Live-data probe, batched across every column named: does each hold any
|
|
24
|
+
* non-null, non-empty value? One round trip regardless of column count,
|
|
25
|
+
* one row of uncorrelated scalar subqueries,
|
|
26
|
+
* `(SELECT 1 FROM t WHERE ... LIMIT 1) AS c<N>`, portable across PostgreSQL
|
|
27
|
+
* and SQLite.
|
|
28
|
+
*/
|
|
29
|
+
async function columnsHaveNonEmptyValueBatch(db, table, columns) {
|
|
30
|
+
if (columns.length === 0) return /* @__PURE__ */ new Map();
|
|
31
|
+
try {
|
|
32
|
+
return await columnsHaveNonEmptyValueBatchQuery(db, table, columns);
|
|
33
|
+
} catch {
|
|
34
|
+
const hasData = /* @__PURE__ */ new Map();
|
|
35
|
+
for (const column of columns) try {
|
|
36
|
+
hasData.set(column, await columnHasNonEmptyValueSingle(db, table, column));
|
|
37
|
+
} catch {}
|
|
38
|
+
return hasData;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
async function columnsHaveNonEmptyValueBatchQuery(db, table, columns) {
|
|
42
|
+
const quotedTable = quoteIdentifier(table);
|
|
43
|
+
const selects = columns.map((column, index) => {
|
|
44
|
+
const quotedColumn = quoteIdentifier(column);
|
|
45
|
+
return `(SELECT 1 FROM ${quotedTable} WHERE ${nonEmptyValuePredicate(quotedColumn)} LIMIT 1) AS c${index}`;
|
|
46
|
+
});
|
|
47
|
+
const row = (await db.query(`SELECT ${selects.join(", ")}`))?.rows?.[0] ?? {};
|
|
48
|
+
const hasData = /* @__PURE__ */ new Map();
|
|
49
|
+
columns.forEach((column, index) => {
|
|
50
|
+
hasData.set(column, row[`c${index}`] != null);
|
|
51
|
+
});
|
|
52
|
+
return hasData;
|
|
53
|
+
}
|
|
54
|
+
/** Single-column fallback for {@link columnsHaveNonEmptyValueBatch}. */
|
|
55
|
+
async function columnHasNonEmptyValueSingle(db, table, column) {
|
|
56
|
+
const quotedTable = quoteIdentifier(table);
|
|
57
|
+
const quotedColumn = quoteIdentifier(column);
|
|
58
|
+
return ((await db.query(`SELECT 1 AS present FROM ${quotedTable} WHERE ${nonEmptyValuePredicate(quotedColumn)} LIMIT 1`))?.rows?.length ?? 0) > 0;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Live-data probe, batched across every column named: are all of a
|
|
62
|
+
* column's non-empty values UUID-shaped ({@link CANONICAL_UUID_PATTERN})?
|
|
63
|
+
* One round trip regardless of column count, mirroring
|
|
64
|
+
* {@link columnsHaveNonEmptyValueBatch}: a value is absent from the result
|
|
65
|
+
* exactly when no invalid row exists, so this also short-circuits on the
|
|
66
|
+
* first invalid row rather than counting every one. PostgreSQL pushes the
|
|
67
|
+
* shape check into its regex operator; SQLite has no regex operator, but
|
|
68
|
+
* its case-sensitive `GLOB` can still express the fixed 36-character
|
|
69
|
+
* canonical shape ({@link CANONICAL_UUID_SQLITE_GLOB_PATTERN} against
|
|
70
|
+
* `LOWER(...)`, guarded by an exact `LENGTH(...) = 36` check).
|
|
71
|
+
*/
|
|
72
|
+
async function columnsAllValuesUuidShapedBatch(db, engine, table, columns) {
|
|
73
|
+
if (columns.length === 0) return /* @__PURE__ */ new Map();
|
|
74
|
+
try {
|
|
75
|
+
return await columnsAllValuesUuidShapedBatchQuery(db, engine, table, columns);
|
|
76
|
+
} catch {
|
|
77
|
+
const shaped = /* @__PURE__ */ new Map();
|
|
78
|
+
for (const column of columns) try {
|
|
79
|
+
shaped.set(column, await allNonEmptyValuesUuidShapedSingle(db, engine, table, column));
|
|
80
|
+
} catch {}
|
|
81
|
+
return shaped;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
async function columnsAllValuesUuidShapedBatchQuery(db, engine, table, columns) {
|
|
85
|
+
const quotedTable = quoteIdentifier(table);
|
|
86
|
+
const selects = columns.map((column, index) => {
|
|
87
|
+
const quotedColumn = quoteIdentifier(column);
|
|
88
|
+
return `(SELECT 1 FROM ${quotedTable} WHERE ${nonEmptyValuePredicate(quotedColumn)} AND ${uuidInvalidShapePredicate(engine, quotedColumn)} LIMIT 1) AS c${index}`;
|
|
89
|
+
});
|
|
90
|
+
const row = (await db.query(`SELECT ${selects.join(", ")}`))?.rows?.[0] ?? {};
|
|
91
|
+
const shaped = /* @__PURE__ */ new Map();
|
|
92
|
+
columns.forEach((column, index) => {
|
|
93
|
+
shaped.set(column, row[`c${index}`] == null);
|
|
94
|
+
});
|
|
95
|
+
return shaped;
|
|
96
|
+
}
|
|
97
|
+
/** Single-column fallback for {@link columnsAllValuesUuidShapedBatch}. */
|
|
98
|
+
async function allNonEmptyValuesUuidShapedSingle(db, engine, table, column) {
|
|
99
|
+
const quotedTable = quoteIdentifier(table);
|
|
100
|
+
const quotedColumn = quoteIdentifier(column);
|
|
101
|
+
return ((await db.query(`SELECT 1 AS invalid FROM ${quotedTable} WHERE ${nonEmptyValuePredicate(quotedColumn)} AND ${uuidInvalidShapePredicate(engine, quotedColumn)} LIMIT 1`))?.rows?.length ?? 0) === 0;
|
|
102
|
+
}
|
|
103
|
+
//#endregion
|
|
104
|
+
export { columnsAllValuesUuidShapedBatch, columnsHaveNonEmptyValueBatch, nonEmptyValuePredicate, uuidInvalidShapePredicate };
|
|
105
|
+
|
|
106
|
+
//# sourceMappingURL=column-data-probes.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"column-data-probes.js","names":[],"sources":["../../src/schema/column-data-probes.ts"],"sourcesContent":["/**\n * Shared live-data column probes (#2874, consolidated for #2878).\n *\n * `detectRenameDataPending()` exists in both `migrations/differ.ts` and\n * `schema/live-parity.ts` — tracked as its own maintenance hazard by #2878,\n * since the #2874 regression had to be fixed twice, in lockstep, in #2876\n * (once per copy). Until now the two batched live-data probes that function\n * depends on were duplicated right along with it: does a named column hold\n * any non-empty value, and does every non-empty value in a named column\n * look UUID-shaped? Both copies used the exact same shape —\n * uncorrelated scalar subqueries, one per column, each with its own\n * `LIMIT 1` early exit (#2874 review finding F1: never an aggregate over\n * the whole table, which would force a full scan per probed column even\n * when the very first row already answers it), and the same positional\n * `c<index>` aliasing to sidestep PostgreSQL's 63-byte identifier\n * truncation (#2874 review finding F2'). Extracted here so the two call\n * sites can never drift out of lockstep again.\n *\n * Both functions fall back to isolated per-column probing when the batched\n * statement itself fails, so one bad column (dropped concurrently, a `CAST`\n * the engine rejects) withholds only that column's result rather than the\n * whole table's (#2874 review finding F2). A column absent from the\n * returned map means \"could not be probed\" — callers apply their own\n * fail-closed default, not this module.\n */\nimport type { DatabaseInterface } from '@happyvertical/sql';\nimport type { DatabaseEngine } from './ddl/types.js';\nimport {\n CANONICAL_UUID_PATTERN,\n CANONICAL_UUID_SQLITE_GLOB_PATTERN,\n} from './foreign-key-ddl.js';\nimport { quoteIdentifier } from './sql-identifiers.js';\n\n/**\n * SQL predicate: a quoted column holds a non-null, non-empty value. Shared\n * by every probe below (single-table batch, single-column fallback, and\n * `migrations/differ.ts`'s cross-table batch, #2878) so the emptiness rule\n * can never drift between them the way #2874's regression drifted between\n * `differ.ts` and `live-parity.ts` before this module existed.\n */\nexport function nonEmptyValuePredicate(quotedColumn: string): string {\n return `${quotedColumn} IS NOT NULL AND CAST(${quotedColumn} AS TEXT) <> ''`;\n}\n\n/**\n * SQL predicate: a quoted column's value does *not* look UUID-shaped\n * ({@link CANONICAL_UUID_PATTERN}), engine-aware (PostgreSQL regex vs.\n * SQLite `GLOB`). Shared for the same reason as {@link nonEmptyValuePredicate}.\n */\nexport function uuidInvalidShapePredicate(\n engine: DatabaseEngine,\n quotedColumn: string,\n): string {\n return engine === 'postgres'\n ? `CAST(${quotedColumn} AS TEXT) !~* '${CANONICAL_UUID_PATTERN}'`\n : `NOT (LENGTH(CAST(${quotedColumn} AS TEXT)) = 36 ` +\n `AND LOWER(CAST(${quotedColumn} AS TEXT)) GLOB '${CANONICAL_UUID_SQLITE_GLOB_PATTERN}')`;\n}\n\n/**\n * Live-data probe, batched across every column named: does each hold any\n * non-null, non-empty value? One round trip regardless of column count,\n * one row of uncorrelated scalar subqueries,\n * `(SELECT 1 FROM t WHERE ... LIMIT 1) AS c<N>`, portable across PostgreSQL\n * and SQLite.\n */\nexport async function columnsHaveNonEmptyValueBatch(\n db: DatabaseInterface,\n table: string,\n columns: string[],\n): Promise<Map<string, boolean>> {\n if (columns.length === 0) return new Map();\n try {\n return await columnsHaveNonEmptyValueBatchQuery(db, table, columns);\n } catch {\n const hasData = new Map<string, boolean>();\n for (const column of columns) {\n try {\n hasData.set(\n column,\n await columnHasNonEmptyValueSingle(db, table, column),\n );\n } catch {\n // Left absent: the caller's own default applies (#2874 review F2).\n }\n }\n return hasData;\n }\n}\n\nasync function columnsHaveNonEmptyValueBatchQuery(\n db: DatabaseInterface,\n table: string,\n columns: string[],\n): Promise<Map<string, boolean>> {\n const quotedTable = quoteIdentifier(table);\n // Positional aliases (`c0`, `c1`, …), not the column name (#2874 review\n // finding F2'): PostgreSQL silently truncates a `name` identifier —\n // including a quoted alias — to 63 bytes, so a long column name, or two\n // columns sharing their first 63 bytes, would collide on the same output\n // key and mis-key a result. Positional aliases are immune to identifier\n // length and never collide with each other.\n const selects = columns.map((column, index) => {\n const quotedColumn = quoteIdentifier(column);\n return (\n `(SELECT 1 FROM ${quotedTable} WHERE ${nonEmptyValuePredicate(quotedColumn)} ` +\n `LIMIT 1) AS c${index}`\n );\n });\n const result = await db.query(`SELECT ${selects.join(', ')}`);\n const row = (result?.rows?.[0] ?? {}) as Record<string, unknown>;\n const hasData = new Map<string, boolean>();\n columns.forEach((column, index) => {\n hasData.set(column, row[`c${index}`] != null);\n });\n return hasData;\n}\n\n/** Single-column fallback for {@link columnsHaveNonEmptyValueBatch}. */\nasync function columnHasNonEmptyValueSingle(\n db: DatabaseInterface,\n table: string,\n column: string,\n): Promise<boolean> {\n const quotedTable = quoteIdentifier(table);\n const quotedColumn = quoteIdentifier(column);\n const result = await db.query(\n `SELECT 1 AS present FROM ${quotedTable} ` +\n `WHERE ${nonEmptyValuePredicate(quotedColumn)} LIMIT 1`,\n );\n return (result?.rows?.length ?? 0) > 0;\n}\n\n/**\n * Live-data probe, batched across every column named: are all of a\n * column's non-empty values UUID-shaped ({@link CANONICAL_UUID_PATTERN})?\n * One round trip regardless of column count, mirroring\n * {@link columnsHaveNonEmptyValueBatch}: a value is absent from the result\n * exactly when no invalid row exists, so this also short-circuits on the\n * first invalid row rather than counting every one. PostgreSQL pushes the\n * shape check into its regex operator; SQLite has no regex operator, but\n * its case-sensitive `GLOB` can still express the fixed 36-character\n * canonical shape ({@link CANONICAL_UUID_SQLITE_GLOB_PATTERN} against\n * `LOWER(...)`, guarded by an exact `LENGTH(...) = 36` check).\n */\nexport async function columnsAllValuesUuidShapedBatch(\n db: DatabaseInterface,\n engine: DatabaseEngine,\n table: string,\n columns: string[],\n): Promise<Map<string, boolean>> {\n if (columns.length === 0) return new Map();\n try {\n return await columnsAllValuesUuidShapedBatchQuery(\n db,\n engine,\n table,\n columns,\n );\n } catch {\n const shaped = new Map<string, boolean>();\n for (const column of columns) {\n try {\n shaped.set(\n column,\n await allNonEmptyValuesUuidShapedSingle(db, engine, table, column),\n );\n } catch {\n // Left absent: the caller's own default applies (#2874 review F2).\n }\n }\n return shaped;\n }\n}\n\nasync function columnsAllValuesUuidShapedBatchQuery(\n db: DatabaseInterface,\n engine: DatabaseEngine,\n table: string,\n columns: string[],\n): Promise<Map<string, boolean>> {\n const quotedTable = quoteIdentifier(table);\n // Positional aliases, not the column name (#2874 review finding F2') —\n // see {@link columnsHaveNonEmptyValueBatchQuery}.\n const selects = columns.map((column, index) => {\n const quotedColumn = quoteIdentifier(column);\n return (\n `(SELECT 1 FROM ${quotedTable} WHERE ${nonEmptyValuePredicate(quotedColumn)} ` +\n `AND ${uuidInvalidShapePredicate(engine, quotedColumn)} LIMIT 1) AS c${index}`\n );\n });\n const result = await db.query(`SELECT ${selects.join(', ')}`);\n const row = (result?.rows?.[0] ?? {}) as Record<string, unknown>;\n const shaped = new Map<string, boolean>();\n columns.forEach((column, index) => {\n shaped.set(column, row[`c${index}`] == null);\n });\n return shaped;\n}\n\n/** Single-column fallback for {@link columnsAllValuesUuidShapedBatch}. */\nasync function allNonEmptyValuesUuidShapedSingle(\n db: DatabaseInterface,\n engine: DatabaseEngine,\n table: string,\n column: string,\n): Promise<boolean> {\n const quotedTable = quoteIdentifier(table);\n const quotedColumn = quoteIdentifier(column);\n const result = await db.query(\n `SELECT 1 AS invalid FROM ${quotedTable} ` +\n `WHERE ${nonEmptyValuePredicate(quotedColumn)} ` +\n `AND ${uuidInvalidShapePredicate(engine, quotedColumn)} LIMIT 1`,\n );\n return (result?.rows?.length ?? 0) === 0;\n}\n"],"mappings":";;;;;;;;;;AAwCA,SAAgB,uBAAuB,cAA8B;CACnE,OAAO,GAAG,aAAa,wBAAwB,aAAa;AAC9D;;;;;;AAOA,SAAgB,0BACd,QACA,cACQ;CACR,OAAO,WAAW,aACd,QAAQ,aAAa,iBAAiB,uBAAuB,KAC7D,oBAAoB,aAAa,iCACb,aAAa,mBAAmB,mCAAmC;AAC7F;;;;;;;;AASA,eAAsB,8BACpB,IACA,OACA,SAC+B;CAC/B,IAAI,QAAQ,WAAW,GAAG,uBAAO,IAAI,IAAI;CACzC,IAAI;EACF,OAAO,MAAM,mCAAmC,IAAI,OAAO,OAAO;CACpE,QAAQ;EACN,MAAM,0BAAU,IAAI,IAAqB;EACzC,KAAK,MAAM,UAAU,SACnB,IAAI;GACF,QAAQ,IACN,QACA,MAAM,6BAA6B,IAAI,OAAO,MAAM,CACtD;EACF,QAAQ,CAER;EAEF,OAAO;CACT;AACF;AAEA,eAAe,mCACb,IACA,OACA,SAC+B;CAC/B,MAAM,cAAc,gBAAgB,KAAK;CAOzC,MAAM,UAAU,QAAQ,KAAK,QAAQ,UAAU;EAC7C,MAAM,eAAe,gBAAgB,MAAM;EAC3C,OACE,kBAAkB,YAAY,SAAS,uBAAuB,YAAY,EAAE,gBAC5D;CAEpB,CAAC;CAED,MAAM,OAAO,MADQ,GAAG,MAAM,UAAU,QAAQ,KAAK,IAAI,GAAG,EAAA,EACvC,OAAO,MAAM,CAAC;CACnC,MAAM,0BAAU,IAAI,IAAqB;CACzC,QAAQ,SAAS,QAAQ,UAAU;EACjC,QAAQ,IAAI,QAAQ,IAAI,IAAI,YAAY,IAAI;CAC9C,CAAC;CACD,OAAO;AACT;;AAGA,eAAe,6BACb,IACA,OACA,QACkB;CAClB,MAAM,cAAc,gBAAgB,KAAK;CACzC,MAAM,eAAe,gBAAgB,MAAM;CAK3C,SAAQ,MAJa,GAAG,MACtB,4BAA4B,YAAY,SAC7B,uBAAuB,YAAY,EAAE,SAClD,EAAA,EACgB,MAAM,UAAU,KAAK;AACvC;;;;;;;;;;;;;AAcA,eAAsB,gCACpB,IACA,QACA,OACA,SAC+B;CAC/B,IAAI,QAAQ,WAAW,GAAG,uBAAO,IAAI,IAAI;CACzC,IAAI;EACF,OAAO,MAAM,qCACX,IACA,QACA,OACA,OACF;CACF,QAAQ;EACN,MAAM,yBAAS,IAAI,IAAqB;EACxC,KAAK,MAAM,UAAU,SACnB,IAAI;GACF,OAAO,IACL,QACA,MAAM,kCAAkC,IAAI,QAAQ,OAAO,MAAM,CACnE;EACF,QAAQ,CAER;EAEF,OAAO;CACT;AACF;AAEA,eAAe,qCACb,IACA,QACA,OACA,SAC+B;CAC/B,MAAM,cAAc,gBAAgB,KAAK;CAGzC,MAAM,UAAU,QAAQ,KAAK,QAAQ,UAAU;EAC7C,MAAM,eAAe,gBAAgB,MAAM;EAC3C,OACE,kBAAkB,YAAY,SAAS,uBAAuB,YAAY,EAAE,OACrE,0BAA0B,QAAQ,YAAY,EAAE,gBAAgB;CAE3E,CAAC;CAED,MAAM,OAAO,MADQ,GAAG,MAAM,UAAU,QAAQ,KAAK,IAAI,GAAG,EAAA,EACvC,OAAO,MAAM,CAAC;CACnC,MAAM,yBAAS,IAAI,IAAqB;CACxC,QAAQ,SAAS,QAAQ,UAAU;EACjC,OAAO,IAAI,QAAQ,IAAI,IAAI,YAAY,IAAI;CAC7C,CAAC;CACD,OAAO;AACT;;AAGA,eAAe,kCACb,IACA,QACA,OACA,QACkB;CAClB,MAAM,cAAc,gBAAgB,KAAK;CACzC,MAAM,eAAe,gBAAgB,MAAM;CAM3C,SAAQ,MALa,GAAG,MACtB,4BAA4B,YAAY,SAC7B,uBAAuB,YAAY,EAAE,OACvC,0BAA0B,QAAQ,YAAY,EAAE,SAC3D,EAAA,EACgB,MAAM,UAAU,OAAO;AACzC"}
|
|
@@ -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;
|
|
1
|
+
{"version":3,"file":"live-parity.d.ts","sourceRoot":"","sources":["../../src/schema/live-parity.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import { quoteIdentifier } from "./sql-identifiers.js";
|
|
2
|
-
import { CANONICAL_UUID_PATTERN, CANONICAL_UUID_SQLITE_GLOB_PATTERN } from "./foreign-key-ddl.js";
|
|
3
1
|
import { detectEngine, getDDLStrategy } from "./ddl/index.js";
|
|
4
2
|
import { RETIRED_SYSTEM_TABLES } from "../system/schema.js";
|
|
5
3
|
import { getSystemTableShapes } from "./system-table-shapes.js";
|
|
4
|
+
import { columnsAllValuesUuidShapedBatch, columnsHaveNonEmptyValueBatch } from "./column-data-probes.js";
|
|
6
5
|
import { collectIntegerWidthTargets, preflightIntegerWidthWidening } from "../migrations/integer-width.js";
|
|
7
6
|
//#region src/schema/live-parity.ts
|
|
8
7
|
/**
|
|
@@ -366,22 +365,6 @@ function renameCompatibility(declaredType, candidateType) {
|
|
|
366
365
|
if (declaredType === "UUID" && candidateType === "TEXT") return "text-to-uuid";
|
|
367
366
|
return null;
|
|
368
367
|
}
|
|
369
|
-
async function columnHasNonEmptyValue(db, table, column) {
|
|
370
|
-
const quotedTable = quoteIdentifier(table);
|
|
371
|
-
const quotedColumn = quoteIdentifier(column);
|
|
372
|
-
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;
|
|
373
|
-
}
|
|
374
|
-
async function allNonEmptyValuesUuidShaped(db, engine, table, column) {
|
|
375
|
-
const quotedTable = quoteIdentifier(table);
|
|
376
|
-
const quotedColumn = quoteIdentifier(column);
|
|
377
|
-
const nonEmptyPredicate = `${quotedColumn} IS NOT NULL AND CAST(${quotedColumn} AS TEXT) <> ''`;
|
|
378
|
-
if (engine === "postgres") {
|
|
379
|
-
const row = (await db.query(`SELECT count(*) AS invalid_count FROM ${quotedTable} WHERE ${nonEmptyPredicate} AND CAST(${quotedColumn} AS TEXT) !~* '${CANONICAL_UUID_PATTERN}'`))?.rows?.[0];
|
|
380
|
-
return Number(row?.invalid_count ?? 0) === 0;
|
|
381
|
-
}
|
|
382
|
-
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];
|
|
383
|
-
return Number(row?.invalid_count ?? 0) === 0;
|
|
384
|
-
}
|
|
385
368
|
function buildRenameDataPendingFinding(table, declaredColumn, candidates) {
|
|
386
369
|
const single = candidates.length === 1;
|
|
387
370
|
const candidateList = candidates.map((name) => `\`${name}\``).join(", ");
|
|
@@ -411,42 +394,50 @@ async function detectRenameDataPending(db, engine, table, liveColumns) {
|
|
|
411
394
|
const expectedNames = new Set(table.columns.map((column) => column.name));
|
|
412
395
|
const extraColumns = [...liveColumns.values()].filter((live) => !expectedNames.has(live.name));
|
|
413
396
|
if (extraColumns.length === 0) return [];
|
|
414
|
-
const
|
|
397
|
+
const declaredCandidates = table.columns.filter((column) => liveColumns.has(column.name));
|
|
398
|
+
if (declaredCandidates.length === 0) return [];
|
|
399
|
+
const declaredHasData = await columnsHaveNonEmptyValueBatch(db, table.name, declaredCandidates.map((column) => column.name));
|
|
400
|
+
const emptyDeclaredCandidates = declaredCandidates.filter((column) => !(declaredHasData.get(column.name) ?? true));
|
|
401
|
+
if (emptyDeclaredCandidates.length === 0) return [];
|
|
402
|
+
const compatibleExtraColumns = extraColumns.filter((extra) => {
|
|
403
|
+
const extraNormalized = normalizeSqlType(extra.type);
|
|
404
|
+
return emptyDeclaredCandidates.some((column) => renameCompatibility(normalizeSqlType(column.type), extraNormalized));
|
|
405
|
+
});
|
|
406
|
+
if (compatibleExtraColumns.length === 0) return [];
|
|
407
|
+
const extraHasData = await columnsHaveNonEmptyValueBatch(db, table.name, compatibleExtraColumns.map((extra) => extra.name));
|
|
408
|
+
const hasData = new Map([...declaredHasData, ...extraHasData]);
|
|
409
|
+
const pending = [];
|
|
410
|
+
const shapeCheckExtras = /* @__PURE__ */ new Set();
|
|
415
411
|
for (const column of table.columns) {
|
|
416
412
|
if (!liveColumns.get(column.name)) continue;
|
|
417
|
-
|
|
418
|
-
try {
|
|
419
|
-
declaredHasData = await columnHasNonEmptyValue(db, table.name, column.name);
|
|
420
|
-
} catch {
|
|
421
|
-
continue;
|
|
422
|
-
}
|
|
423
|
-
if (declaredHasData) continue;
|
|
413
|
+
if (hasData.get(column.name) ?? true) continue;
|
|
424
414
|
const declaredType = normalizeSqlType(column.type);
|
|
425
|
-
const
|
|
426
|
-
for (const extra of extraColumns) {
|
|
415
|
+
for (const extra of compatibleExtraColumns) {
|
|
427
416
|
const compatibility = renameCompatibility(declaredType, normalizeSqlType(extra.type));
|
|
428
417
|
if (!compatibility) continue;
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
let shaped;
|
|
438
|
-
try {
|
|
439
|
-
shaped = await allNonEmptyValuesUuidShaped(db, engine, table.name, extra.name);
|
|
440
|
-
} catch {
|
|
441
|
-
continue;
|
|
442
|
-
}
|
|
443
|
-
if (!shaped) continue;
|
|
444
|
-
}
|
|
445
|
-
candidates.push(extra.name);
|
|
418
|
+
if (!(hasData.get(extra.name) ?? false)) continue;
|
|
419
|
+
const requiresShapeCheck = compatibility === "text-to-uuid";
|
|
420
|
+
if (requiresShapeCheck) shapeCheckExtras.add(extra.name);
|
|
421
|
+
pending.push({
|
|
422
|
+
declaredName: column.name,
|
|
423
|
+
extraName: extra.name,
|
|
424
|
+
requiresShapeCheck
|
|
425
|
+
});
|
|
446
426
|
}
|
|
427
|
+
}
|
|
428
|
+
const shaped = shapeCheckExtras.size > 0 ? await columnsAllValuesUuidShapedBatch(db, engine, table.name, [...shapeCheckExtras]) : /* @__PURE__ */ new Map();
|
|
429
|
+
const candidatesByColumn = /* @__PURE__ */ new Map();
|
|
430
|
+
for (const candidate of pending) {
|
|
431
|
+
if (candidate.requiresShapeCheck && !(shaped.get(candidate.extraName) ?? false)) continue;
|
|
432
|
+
const list = candidatesByColumn.get(candidate.declaredName) ?? [];
|
|
433
|
+
list.push(candidate.extraName);
|
|
434
|
+
candidatesByColumn.set(candidate.declaredName, list);
|
|
435
|
+
}
|
|
436
|
+
const findings = [];
|
|
437
|
+
for (const [declaredName, candidates] of candidatesByColumn) {
|
|
447
438
|
if (candidates.length === 0) continue;
|
|
448
439
|
candidates.sort();
|
|
449
|
-
findings.push(buildRenameDataPendingFinding(table,
|
|
440
|
+
findings.push(buildRenameDataPendingFinding(table, declaredName, candidates));
|
|
450
441
|
}
|
|
451
442
|
return findings;
|
|
452
443
|
}
|