@happyvertical/smrt-core 0.50.0 → 0.51.0

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.
@@ -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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"}
@@ -366,21 +366,110 @@ function renameCompatibility(declaredType, candidateType) {
366
366
  if (declaredType === "UUID" && candidateType === "TEXT") return "text-to-uuid";
367
367
  return null;
368
368
  }
369
- async function columnHasNonEmptyValue(db, table, column) {
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) {
370
416
  const quotedTable = quoteIdentifier(table);
371
417
  const quotedColumn = quoteIdentifier(column);
372
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;
373
419
  }
374
- async function allNonEmptyValuesUuidShaped(db, engine, table, column) {
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) {
375
468
  const quotedTable = quoteIdentifier(table);
376
469
  const quotedColumn = quoteIdentifier(column);
377
470
  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;
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;
384
473
  }
385
474
  function buildRenameDataPendingFinding(table, declaredColumn, candidates) {
386
475
  const single = candidates.length === 1;
@@ -411,42 +500,50 @@ async function detectRenameDataPending(db, engine, table, liveColumns) {
411
500
  const expectedNames = new Set(table.columns.map((column) => column.name));
412
501
  const extraColumns = [...liveColumns.values()].filter((live) => !expectedNames.has(live.name));
413
502
  if (extraColumns.length === 0) return [];
414
- const findings = [];
503
+ const declaredCandidates = table.columns.filter((column) => liveColumns.has(column.name));
504
+ if (declaredCandidates.length === 0) return [];
505
+ const declaredHasData = await columnsHaveNonEmptyValueBatch(db, table.name, declaredCandidates.map((column) => column.name));
506
+ const emptyDeclaredCandidates = declaredCandidates.filter((column) => !(declaredHasData.get(column.name) ?? true));
507
+ if (emptyDeclaredCandidates.length === 0) return [];
508
+ const compatibleExtraColumns = extraColumns.filter((extra) => {
509
+ const extraNormalized = normalizeSqlType(extra.type);
510
+ return emptyDeclaredCandidates.some((column) => renameCompatibility(normalizeSqlType(column.type), extraNormalized));
511
+ });
512
+ if (compatibleExtraColumns.length === 0) return [];
513
+ const extraHasData = await columnsHaveNonEmptyValueBatch(db, table.name, compatibleExtraColumns.map((extra) => extra.name));
514
+ const hasData = new Map([...declaredHasData, ...extraHasData]);
515
+ const pending = [];
516
+ const shapeCheckExtras = /* @__PURE__ */ new Set();
415
517
  for (const column of table.columns) {
416
518
  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;
519
+ if (hasData.get(column.name) ?? true) continue;
424
520
  const declaredType = normalizeSqlType(column.type);
425
- const candidates = [];
426
- for (const extra of extraColumns) {
521
+ for (const extra of compatibleExtraColumns) {
427
522
  const compatibility = renameCompatibility(declaredType, normalizeSqlType(extra.type));
428
523
  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);
524
+ if (!(hasData.get(extra.name) ?? false)) continue;
525
+ const requiresShapeCheck = compatibility === "text-to-uuid";
526
+ if (requiresShapeCheck) shapeCheckExtras.add(extra.name);
527
+ pending.push({
528
+ declaredName: column.name,
529
+ extraName: extra.name,
530
+ requiresShapeCheck
531
+ });
446
532
  }
533
+ }
534
+ const shaped = shapeCheckExtras.size > 0 ? await columnsAllValuesUuidShapedBatch(db, engine, table.name, [...shapeCheckExtras]) : /* @__PURE__ */ new Map();
535
+ const candidatesByColumn = /* @__PURE__ */ new Map();
536
+ for (const candidate of pending) {
537
+ if (candidate.requiresShapeCheck && !(shaped.get(candidate.extraName) ?? false)) continue;
538
+ const list = candidatesByColumn.get(candidate.declaredName) ?? [];
539
+ list.push(candidate.extraName);
540
+ candidatesByColumn.set(candidate.declaredName, list);
541
+ }
542
+ const findings = [];
543
+ for (const [declaredName, candidates] of candidatesByColumn) {
447
544
  if (candidates.length === 0) continue;
448
545
  candidates.sort();
449
- findings.push(buildRenameDataPendingFinding(table, column.name, candidates));
546
+ findings.push(buildRenameDataPendingFinding(table, declaredName, candidates));
450
547
  }
451
548
  return findings;
452
549
  }