@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.
@@ -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,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 findings = [];
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
- let declaredHasData;
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 candidates = [];
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
- let candidateHasData;
430
- try {
431
- candidateHasData = await columnHasNonEmptyValue(db, table.name, extra.name);
432
- } catch {
433
- continue;
434
- }
435
- if (!candidateHasData) continue;
436
- if (compatibility === "text-to-uuid") {
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, column.name, candidates));
440
+ findings.push(buildRenameDataPendingFinding(table, declaredName, candidates));
450
441
  }
451
442
  return findings;
452
443
  }