@happyvertical/smrt-core 0.51.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.
@@ -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":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"live-parity.d.ts","sourceRoot":"","sources":["../../src/schema/live-parity.ts"],"names":[],"mappings":"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"}
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,111 +365,6 @@ function renameCompatibility(declaredType, candidateType) {
366
365
  if (declaredType === "UUID" && candidateType === "TEXT") return "text-to-uuid";
367
366
  return null;
368
367
  }
369
- /**
370
- * Live-data probe, batched across every column named: does each hold any
371
- * non-null, non-empty value? One round trip regardless of column count
372
- * (#2874) — mirrors `SchemaComparer`'s `columnsHaveNonEmptyValueBatch` in
373
- * `migrations/differ.ts`, which this module's rename-pending detector
374
- * duplicates (both trace to #2752/#2767): one row of uncorrelated scalar
375
- * subqueries, `(SELECT 1 FROM t WHERE ... LIMIT 1) AS "col"`. Deliberately
376
- * not an aggregate (`MAX(CASE WHEN ...)`) over the whole table — an
377
- * aggregate forces a full scan per probed column even when the first row
378
- * already answers it, turning a healthy, mostly-populated large table into
379
- * a guaranteed full scan on every `db:status`/`db:diff` run (#2874 review
380
- * finding F1). Each subquery keeps the original `LIMIT 1` early exit; only
381
- * the round trip is batched.
382
- *
383
- * Falls back to {@link columnHasNonEmptyValueSingle} per column when the
384
- * batched statement itself fails, so one unresolvable column withholds
385
- * only its own result rather than discarding the whole table's detection
386
- * (#2874 review finding F2). A column absent from the returned map means
387
- * "could not be probed"; callers apply their own fail-closed default.
388
- */
389
- async function columnsHaveNonEmptyValueBatch(db, table, columns) {
390
- if (columns.length === 0) return /* @__PURE__ */ new Map();
391
- try {
392
- return await columnsHaveNonEmptyValueBatchQuery(db, table, columns);
393
- } catch {
394
- const hasData = /* @__PURE__ */ new Map();
395
- for (const column of columns) try {
396
- hasData.set(column, await columnHasNonEmptyValueSingle(db, table, column));
397
- } catch {}
398
- return hasData;
399
- }
400
- }
401
- async function columnsHaveNonEmptyValueBatchQuery(db, table, columns) {
402
- const quotedTable = quoteIdentifier(table);
403
- const selects = columns.map((column, index) => {
404
- const quotedColumn = quoteIdentifier(column);
405
- return `(SELECT 1 FROM ${quotedTable} WHERE ${quotedColumn} IS NOT NULL AND CAST(${quotedColumn} AS TEXT) <> '' LIMIT 1) AS c${index}`;
406
- });
407
- const row = (await db.query(`SELECT ${selects.join(", ")}`))?.rows?.[0] ?? {};
408
- const hasData = /* @__PURE__ */ new Map();
409
- columns.forEach((column, index) => {
410
- hasData.set(column, row[`c${index}`] != null);
411
- });
412
- return hasData;
413
- }
414
- /** Single-column fallback for {@link columnsHaveNonEmptyValueBatch}. */
415
- async function columnHasNonEmptyValueSingle(db, table, column) {
416
- const quotedTable = quoteIdentifier(table);
417
- const quotedColumn = quoteIdentifier(column);
418
- 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;
419
- }
420
- /**
421
- * Live-data probe, batched across every column named: are every one of
422
- * each column's non-empty values UUID-shaped ({@link CANONICAL_UUID_PATTERN})?
423
- * One round trip regardless of column count (#2874), mirroring
424
- * {@link columnsHaveNonEmptyValueBatch}: one row of uncorrelated scalar
425
- * subqueries, each `(SELECT 1 FROM t WHERE <non-empty> AND <invalid> LIMIT
426
- * 1)` — a value is absent exactly when no invalid row exists, so this also
427
- * short-circuits on the first invalid row rather than counting every one
428
- * (an early-exit improvement over the pre-#2874 per-column `count(*)`
429
- * probe, not just a batching change). PostgreSQL pushes the shape check
430
- * into its regex operator; SQLite (the only other engine this detector runs
431
- * against) has no regex operator, but its case-sensitive `GLOB` can still
432
- * express the fixed 36-character canonical shape (against `LOWER(...)`,
433
- * guarded by an exact `LENGTH(...) = 36`).
434
- *
435
- * Falls back to {@link allNonEmptyValuesUuidShapedSingle} per column on a
436
- * batch failure, same posture as {@link columnsHaveNonEmptyValueBatch}
437
- * (#2874 review finding F2).
438
- */
439
- async function columnsAllValuesUuidShapedBatch(db, engine, table, columns) {
440
- if (columns.length === 0) return /* @__PURE__ */ new Map();
441
- try {
442
- return await columnsAllValuesUuidShapedBatchQuery(db, engine, table, columns);
443
- } catch {
444
- const shaped = /* @__PURE__ */ new Map();
445
- for (const column of columns) try {
446
- shaped.set(column, await allNonEmptyValuesUuidShapedSingle(db, engine, table, column));
447
- } catch {}
448
- return shaped;
449
- }
450
- }
451
- async function columnsAllValuesUuidShapedBatchQuery(db, engine, table, columns) {
452
- const quotedTable = quoteIdentifier(table);
453
- const selects = columns.map((column, index) => {
454
- const quotedColumn = quoteIdentifier(column);
455
- const nonEmptyPredicate = `${quotedColumn} IS NOT NULL AND CAST(${quotedColumn} AS TEXT) <> ''`;
456
- const invalidPredicate = 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}')`;
457
- return `(SELECT 1 FROM ${quotedTable} WHERE ${nonEmptyPredicate} AND ${invalidPredicate} LIMIT 1) AS c${index}`;
458
- });
459
- const row = (await db.query(`SELECT ${selects.join(", ")}`))?.rows?.[0] ?? {};
460
- const shaped = /* @__PURE__ */ new Map();
461
- columns.forEach((column, index) => {
462
- shaped.set(column, row[`c${index}`] == null);
463
- });
464
- return shaped;
465
- }
466
- /** Single-column fallback for {@link columnsAllValuesUuidShapedBatch}. */
467
- async function allNonEmptyValuesUuidShapedSingle(db, engine, table, column) {
468
- const quotedTable = quoteIdentifier(table);
469
- const quotedColumn = quoteIdentifier(column);
470
- const nonEmptyPredicate = `${quotedColumn} IS NOT NULL AND CAST(${quotedColumn} AS TEXT) <> ''`;
471
- const invalidPredicate = 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}')`;
472
- return ((await db.query(`SELECT 1 AS invalid FROM ${quotedTable} WHERE ${nonEmptyPredicate} AND ${invalidPredicate} LIMIT 1`))?.rows?.length ?? 0) === 0;
473
- }
474
368
  function buildRenameDataPendingFinding(table, declaredColumn, candidates) {
475
369
  const single = candidates.length === 1;
476
370
  const candidateList = candidates.map((name) => `\`${name}\``).join(", ");