From 2a74cb35df9f089418c56312fad1db34dbba430a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 11:29:21 +0000 Subject: [PATCH] fix(cli): a raw-SQL seam that cannot answer is absent, not empty (#10677) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `os migrate duplicates --database-url memory://qa` exited 0 with `duplicates:[]`, `skipped:[]`, `counters.status:"read"` — the exact false all-clear the #8928 ruling's `no_sql_seam` refusal exists to prevent. That refusal was dead code for the memory driver. `InMemoryDriver.execute()` logs `Raw execution not supported in InMemory driver` and returns `null`: it neither throws nor is absent. The seam resolver asks whether the driver has the SHAPE of a seam (`typeof d.execute === 'function'`), which that satisfies, so the `if (!exec)` guard never fired — and `normalizeRows(null)` is `[]`, which is also what a real driver returns for a SELECT that matched nothing. The guard now keys on the distinction that actually separates the two: a seam that ANSWERS returns a result set, and one that cannot answer returns no result set at all. One trivial statement is put to the resolved seam before the scan starts, and every individual probe is held to the same standard, so a probe that returns no result set becomes a `skipped` entry with its reason instead of zero findings. No driver package is touched — the 2026-08-05 investment freeze covers the driver-memory/driver-mongodb family, so this is the consumer-side route triage directed. A seam that THROWS is deliberately left alone: that is a driver present and refusing loudly, already reported honestly by the per-probe `skipped` path, and claiming it here would invent a refusal #8928 never mandated. The clause-7 knownGap in the QA checklist said the memory driver exposes no raw-SQL seam. It exposes a no-op one; corrected, with the drifted duplicates.ts line citations re-derived. The mongodb branch is left explicitly unverified — that driver was not loaded for this fix. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019bmVFqoQPq63zhKrxdYG1r --- .../migrate-duplicates-null-seam-refusal.md | 46 +++ docs/qa/platform-checklist/areas/cli.json | 11 +- .../migrate/duplicates.null-seam.test.ts | 288 ++++++++++++++++++ .../cli/src/commands/migrate/duplicates.ts | 116 ++++++- 4 files changed, 450 insertions(+), 11 deletions(-) create mode 100644 .changeset/migrate-duplicates-null-seam-refusal.md create mode 100644 packages/cli/src/commands/migrate/duplicates.null-seam.test.ts diff --git a/.changeset/migrate-duplicates-null-seam-refusal.md b/.changeset/migrate-duplicates-null-seam-refusal.md new file mode 100644 index 0000000000..4837cf5a87 --- /dev/null +++ b/.changeset/migrate-duplicates-null-seam-refusal.md @@ -0,0 +1,46 @@ +--- +"@objectstack/cli": patch +--- + +`os migrate duplicates` no longer reports a clean bill of health over a driver it +could not query (#10677). The `no_sql_seam` refusal #8928 mandated was dead code +for the memory driver, so the exact outcome the ruling exists to forbid was +reachable: + +``` +os migrate duplicates --database-url memory://qa + -> exit 0 {"duplicates":[],"skipped":[],"counters":{"status":"read"}} +``` + +`InMemoryDriver.execute()` logs `Raw execution not supported in InMemory driver` +and returns `null` — it neither throws nor is absent. The seam resolver asks +whether the driver has the SHAPE of a seam (`typeof d.execute === 'function'`), +which that satisfies, so the `if (!exec)` guard never fired; and +`normalizeRows(null)` is `[]`, which is also what a real driver returns for a +SELECT that matched nothing. Three statements were swallowed and the report said +the install was clean. + +The command now separates the two cases the guard used to conflate: **a seam +that cannot answer is absent, not empty.** It asks the resolved seam one trivial +statement before the scan starts and refuses when the answer is not a result +set, and it holds every individual probe to the same standard, so a probe that +returns no result set becomes a `skipped` entry with its reason instead of zero +findings. + +``` +os migrate duplicates --database-url memory://qa + -> exit 1 {"error":"no_sql_seam","detail":"The active driver exposes no + usable raw SQL seam — it is either absent, or present but + returning no result set — …"} +``` + +Nothing here names a driver: a seam is judged by what it returns, so any host +with the same no-op shape is covered without an allowlist to maintain. No driver +package was modified. + +Two behaviours are deliberately unchanged. A seam that **throws** is a driver +present and refusing loudly, and the per-probe `skipped` path already reports +that honestly — claiming it here would swallow a transient connection error as +"no seam" and would invent a refusal #8928 never mandated. And a real SQL driver +is unaffected: every shape the new check rejects is one `normalizeRows` already +flattened to `[]`, so no row that used to be reported can be lost. diff --git a/docs/qa/platform-checklist/areas/cli.json b/docs/qa/platform-checklist/areas/cli.json index 4cb577bfe3..12243f39e9 100644 --- a/docs/qa/platform-checklist/areas/cli.json +++ b/docs/qa/platform-checklist/areas/cli.json @@ -1045,7 +1045,7 @@ "title": "os migrate duplicates: a read-only JSON inventory of identifiers minted across partitions — within-partition repeats excluded, nothing written, the live two-counter condition reported, and runnable BEFORE the #8686 repair destroys the evidence", "since": "v17", "status": "active", - "revision": 1, + "revision": 2, "priority": "P1", "surface": "cli", "personas": ["operator (local shell, pre-repair audit)"], @@ -1057,7 +1057,7 @@ ], "knownGaps": [ "ORDERING IS LOAD-BEARING: do NOT boot the dev server again between seeding and scanning — a #8686-repairing boot backfills organization_id = NULL, which is exactly the evidence this report inventories (the command's own header calls the evidence perishable). The duplicates command itself boots read-only (deferSchemaDdl + readOnlyProbe) and is safe to run any number of times", - "the memory and mongodb drivers expose no raw-SQL seam, so the negative no_sql_seam probe needs a second scratch config on the memory driver — stage it per run" + "the memory driver does NOT lack a raw-SQL seam — `InMemoryDriver.execute()` logs `Raw execution not supported in InMemory driver` and returns null, which is exactly the SHAPE the seam resolver tests for, so the no_sql_seam refusal was dead code for it (a clean `duplicates:[]` exit 0 instead, #10677). The refusal now keys on whether a seam returns a RESULT SET rather than on whether `execute` exists, so the memory driver does take the no_sql_seam branch. The negative probe still needs a second scratch config on the memory driver — stage it per run. The mongodb branch is NOT covered by that fix's evidence — the driver was not loaded — so treat it as unverified rather than assuming it behaves like memory" ] }, "steps": [ @@ -1110,7 +1110,7 @@ { "clause": "a driver with no raw-SQL seam refuses LOUDLY: {error: 'no_sql_seam', …} with exit 1 — an empty clean report from a driver the probe cannot run against would be indistinguishable from 'never looked'", "oracle": "log", - "verify": "the memory-driver run emits the no_sql_seam payload and echo $? is 1 (duplicates.ts:697-712); a boot failure likewise answers {error: 'boot_failed'} exit 1, never a zero-duplicate success", + "verify": "the memory-driver run emits the no_sql_seam payload and echo $? is 1 (duplicates.ts:787-812); a boot failure likewise answers {error: 'boot_failed'} exit 1, never a zero-duplicate success", "evidence": "the refusal payload + exit code" } ], @@ -1122,13 +1122,14 @@ ], "traps": ["stale-dist", "absence-inference"], "source": [ - "packages/cli/src/commands/migrate/duplicates.ts (the :17-80 contract header encoding the 2026-08-16 maintainer ruling's five points; report interfaces :82-165; the cross-partition HAVING at :263-266; flags :648-656; read-only boot :670-679; no_sql_seam refusal :697-712)", + "packages/cli/src/commands/migrate/duplicates.ts (the :17-80 contract header encoding the 2026-08-16 maintainer ruling's five points; report interfaces :82-165; the cross-partition HAVING at :263-266; the seam-answer guards at :434-523; flags :739-747; read-only boot :761-770; no_sql_seam refusal :787-812)", "packages/cli/src/commands/migrate/duplicates.contract.test.ts (the full JSON shape against a real sqlite), duplicates.pre-repair.test.ts (byte-identical DB + the #8686 repair measured destroying the evidence), duplicates.integration.test.ts, duplicates.probe-sql.test.ts — seam pins; none drives the oclif command end-to-end, hence no automated entry", "#8928 (the card and ruling), #8686 / #8844 (the closed producers whose damage this inventories)", "sibling item cli.migrate-plan-apply-json (lists duplicates as a variant; the scratch-DB boot recipe is shared)" ], "history": [ - { "revision": 1, "date": "2026-08-20", "change": "new — scoped scan-functionality sweep (扫描功能): `os migrate duplicates` landed 2026-08-16 (#8928) after the sibling migrate item's enumeration was authored, so the subcommand had no functional coverage. Authored from the :17-80 contract header's five ruling points, with the perishability ordering (seed → scan → only then any repair-bearing boot) carried as a load-bearing knownGap and the no-JSON-flag posture spelled out so the #4873 sweep does not misread an oclif 2", "ref": "claude/new-session-0pv25p" } + { "revision": 1, "date": "2026-08-20", "change": "new — scoped scan-functionality sweep (扫描功能): `os migrate duplicates` landed 2026-08-16 (#8928) after the sibling migrate item's enumeration was authored, so the subcommand had no functional coverage. Authored from the :17-80 contract header's five ruling points, with the perishability ordering (seed → scan → only then any repair-bearing boot) carried as a load-bearing knownGap and the no-JSON-flag posture spelled out so the #4873 sweep does not misread an oclif 2", "ref": "claude/new-session-0pv25p" }, + { "revision": 2, "date": "2026-08-21", "change": "clause-7 knownGap was factually wrong about the memory driver, and the run that trusted it produced a false all-clear: the driver exposes a NO-OP `execute` seam (warn + return null), not no seam, so the no_sql_seam refusal never fired and the scan answered exit 0 with `duplicates:[]` — indistinguishable from `never looked`. Corrected the knownGap, and re-derived the drifted duplicates.ts line citations against the fix that makes the refusal live (#10677). The mongodb half is left explicitly UNVERIFIED rather than restated: that driver was not loaded for the fix", "ref": "#10677" } ] }, { diff --git a/packages/cli/src/commands/migrate/duplicates.null-seam.test.ts b/packages/cli/src/commands/migrate/duplicates.null-seam.test.ts new file mode 100644 index 0000000000..40c98f33e1 --- /dev/null +++ b/packages/cli/src/commands/migrate/duplicates.null-seam.test.ts @@ -0,0 +1,288 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #10677 — the `no_sql_seam` refusal was dead code for the memory driver. + * + * #8928's ruling made "this driver cannot be probed" a LOUD failure precisely + * because the alternative is unfalsifiable: a scan that answers `duplicates:[]` + * over a driver it never queried is indistinguishable from a scan that queried + * and found nothing. The refusal existed, and for the memory driver it never + * ran — measured on framework `79ebb37` and reproduced at head: + * + * os migrate duplicates --database-url memory://qa + * -> exit 0, duplicates: [], skipped: [], counters.status: "read" + * -> stderr: 3x "Raw execution not supported in InMemory driver" + * + * `InMemoryDriver.execute()` logs that warning and returns `null`. It neither + * throws nor is absent, so `resolveSeedTenancyExec`'s `typeof d.execute === + * 'function'` test — a question about the driver's SHAPE — was satisfied, and + * `normalizeRows(null)` is `[]`, which is also what a real driver returns for a + * SELECT that matched nothing. + * + * ⭐ So what these tests pin is NOT "memory reports no_sql_seam". It is the + * distinction the guard now keys on: **a seam that cannot ANSWER is absent, not + * empty.** The first two cases below assert that separation on values alone, + * and the boot cases assert the real driver falls on the "cannot answer" side + * of it. No driver is named by the implementation — the seam is judged by what + * it returns. + * + * The mongodb branch is deliberately NOT asserted anywhere in this file: it was + * not exercised for this fix, and an assertion about a driver this suite never + * loads would be a false pin. + */ + +import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { resolveSeedTenancyExec, normalizeRows, GLOBAL_TENANT, ORGANIZATION_FIELD, SEQUENCES_TABLE } from '@objectstack/metadata-protocol'; +import { bootSchemaStack } from '../../utils/schema-migrate.js'; +import MigrateDuplicates, { + answeringSeam, + collectDuplicateIdentifierReport, + isResultSet, + seamAnswersNothing, +} from './duplicates.js'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const CLI_ROOT = resolve(HERE, '..', '..', '..'); + +const MEMORY_URL = 'memory://os-10677'; + +let dir: string; +const savedEnv: Record = {}; + +beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), 'os-10677-')); + mkdirSync(join(dir, 'dist'), { recursive: true }); + writeFileSync( + join(dir, 'dist', 'objectstack.json'), + JSON.stringify({ + manifest: { id: 'dup_null_seam', name: 'Null Seam', version: '0.0.0', type: 'app' }, + objects: [ + { + name: 'crm_case', + fields: { subject: { type: 'text' }, case_number: { type: 'autonumber' } }, + }, + ], + }), + ); + savedEnv.OS_ARTIFACT_PATH = process.env.OS_ARTIFACT_PATH; + savedEnv.NODE_ENV = process.env.NODE_ENV; + process.env.OS_ARTIFACT_PATH = join(dir, 'dist', 'objectstack.json'); + process.env.NODE_ENV = 'production'; // no dev-time auto-reconcile +}, 120_000); + +afterAll(() => { + process.env.OS_ARTIFACT_PATH = savedEnv.OS_ARTIFACT_PATH; + process.env.NODE_ENV = savedEnv.NODE_ENV; + try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// 1. The separation itself, on values +// ─────────────────────────────────────────────────────────────────────────── + +describe('#10677 isResultSet — "answered nothing" is not "answered no rows"', () => { + it('accepts every dialect result-set shape, INCLUDING the empty spellings', () => { + // better-sqlite3 through knex: a bare row array. + expect(isResultSet([{ dup_value: 'CASE-1' }])).toBe(true); + expect(isResultSet([])).toBe(true); + // pg: `{ rows, rowCount, … }`. + expect(isResultSet({ rows: [{ dup_value: 'CASE-1' }], rowCount: 1 })).toBe(true); + expect(isResultSet({ rows: [], rowCount: 0 })).toBe(true); + // mysql2: the `[rows, fields]` tuple. + expect(isResultSet([[{ dup_value: 'CASE-1' }], []])).toBe(true); + expect(isResultSet([[], []])).toBe(true); + }); + + it('rejects the fourth thing a seam can hand back — no result set at all', () => { + // The measured shape: `InMemoryDriver.execute()` returns exactly this. + expect(isResultSet(null)).toBe(false); + expect(isResultSet(undefined)).toBe(false); + // A host that echoes the statement back rather than running it. + expect(isResultSet('select 1')).toBe(false); + expect(isResultSet({})).toBe(false); + expect(isResultSet({ rows: 'not-an-array' })).toBe(false); + }); + + it('rejects only shapes normalizeRows already flattens to [] — no row can be lost', () => { + for (const shape of [null, undefined, 'select 1', {}, { rows: 'not-an-array' }, 42]) { + expect(isResultSet(shape)).toBe(false); + expect(normalizeRows(shape)).toEqual([]); + } + }); +}); + +describe('#10677 the two guards, on hand-built seams', () => { + it('seamAnswersNothing: true for a seam that returns, false for one that answers', async () => { + expect(await seamAnswersNothing(async () => null)).toBe(true); + expect(await seamAnswersNothing(async () => [])).toBe(false); + expect(await seamAnswersNothing(async () => [{ os_seam_probe: 1 }])).toBe(false); + }); + + it('seamAnswersNothing: a seam that THROWS is left alone — that path is unchanged', async () => { + // Throwing is a driver present and refusing LOUDLY. The per-probe `skipped` + // path already reports it honestly, so this guard must not claim it: doing + // so would turn an honest partial report into a refusal #8928 never + // mandated, and would swallow a transient connection error as "no seam". + expect( + await seamAnswersNothing(async () => { + throw new Error('ECONNREFUSED'); + }), + ).toBe(false); + }); + + it('answeringSeam: passes result sets through and fails a non-answer', async () => { + const rows = [{ dup_value: 'CASE-1' }]; + await expect(answeringSeam(async () => rows)('select 1')).resolves.toBe(rows); + await expect(answeringSeam(async () => [])('select 1')).resolves.toEqual([]); + await expect(answeringSeam(async () => null)('select 1')).rejects.toThrow(/no result set/); + }); + + it('answeringSeam: forwards sql and bound params untouched', async () => { + const seen: Array<[string, unknown[] | undefined]> = []; + const wrapped = answeringSeam(async (sql, params) => { + seen.push([sql, params]); + return []; + }); + await wrapped('select ?', ['x']); + expect(seen).toEqual([['select ?', ['x']]]); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// 2. The real memory driver, through a real boot +// ─────────────────────────────────────────────────────────────────────────── + +describe('#10677 the memory driver, booted for real', () => { + it('has the SHAPE of a seam and answers nothing — the two the guard had to separate', async () => { + const stack = await bootSchemaStack({ + jsonOutput: false, + databaseUrl: MEMORY_URL, + deferSchemaDdl: true, + readOnlyProbe: true, + projectRoot: dir, + }); + try { + const ql = (stack.kernel as { getService?: (n: string) => unknown }).getService?.('objectql'); + const exec = resolveSeedTenancyExec(ql); + + // The half that made the refusal dead code: the resolver still says yes. + // It asks whether `execute` is callable, and on this driver it is. + expect(exec, 'the shape test still passes — that is the defect, not a bug in the resolver').toBeTypeOf('function'); + + // The half the guard now asks about: it accepts the statement and hands + // back no result set. + await expect(exec!('select 1 as os_seam_probe', [])).resolves.toBeNull(); + expect(await seamAnswersNothing(exec!)).toBe(true); + } finally { + await stack.shutdown(); + } + }, 120_000); + + it('bare exec produces the false all-clear; the wrapped seam reports it as unreadable', async () => { + const stack = await bootSchemaStack({ + jsonOutput: false, + databaseUrl: MEMORY_URL, + deferSchemaDdl: true, + readOnlyProbe: true, + projectRoot: dir, + }); + try { + const ql = (stack.kernel as { getService?: (n: string) => unknown }).getService?.('objectql'); + const exec = resolveSeedTenancyExec(ql)!; + const opts = { + normalize: normalizeRows, + objects: stack.allObjects(), + database: stack.dbLabel, + globalTenant: GLOBAL_TENANT, + organizationField: ORGANIZATION_FIELD, + sequencesTable: SEQUENCES_TABLE, + }; + + // The population is real, so "nothing was scanned" cannot explain the + // empty result below. + const before = await collectDuplicateIdentifierReport({ ...opts, exec }); + expect(before.scanned.map((t) => `${t.object}.${t.field}`)).toContain('crm_case.case_number'); + + // ── The defect, reproduced in-test ────────────────────────────────── + // Every probe was swallowed, and the report says the install is clean. + expect(before.duplicates).toEqual([]); + expect(before.skipped).toEqual([]); + expect(before.counters.status).toBe('read'); + + // ── The same seam, held to "must answer" ──────────────────────────── + const after = await collectDuplicateIdentifierReport({ ...opts, exec: answeringSeam(exec) }); + expect(after.scanned).toEqual(before.scanned); + expect(after.skipped.map((s) => `${s.object}.${s.field ?? ''}`)).toContain('crm_case.case_number'); + for (const entry of after.skipped) expect(entry.reason).toMatch(/no result set/); + // The counter table is no longer claimed as read, either. + expect(after.counters.status).toBe('absent'); + } finally { + await stack.shutdown(); + } + }, 120_000); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// 3. The command itself — the symptom the QA run filed +// ─────────────────────────────────────────────────────────────────────────── + +describe('#10677 os migrate duplicates on a no-op seam', () => { + /** + * Run the real command, capturing the payload. + * + * `process.exitCode` is process-global and `emitJson` sets it, so it is saved + * and restored here — a test that left it at 1 would fail the whole vitest + * run from the outside, with nothing pointing back at this file. + * + * The stdout spy is installed BEFORE the command boots because the JSON boot + * reserves stdout and captures whatever `process.stdout.write` is at that + * moment (the mechanism `json-stdout.test.ts` documents). + */ + async function runCommand(args: string[]): Promise<{ out: string; exitCode: number | undefined }> { + const savedExit = process.exitCode; + const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: any, ...rest: any[]) => { + const cb = rest.find((a) => typeof a === 'function'); + if (cb) cb(); + return true; + }) as typeof process.stdout.write); + vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: any, ...rest: any[]) => { + const cb = rest.find((a) => typeof a === 'function'); + if (cb) cb(); + return true; + }) as typeof process.stderr.write); + try { + await MigrateDuplicates.run(args, { root: CLI_ROOT }); + return { + out: stdout.mock.calls.map((c) => String(c[0])).join(''), + exitCode: process.exitCode as number | undefined, + }; + } finally { + process.exitCode = savedExit; + vi.restoreAllMocks(); + } + } + + it('refuses loudly with no_sql_seam and exit 1 instead of a clean empty report', async () => { + const { out, exitCode } = await runCommand(['--database-url', MEMORY_URL]); + + // ONE document on stdout, and it is the refusal — not a report. + const payload = JSON.parse(out) as { error?: string; detail?: string; duplicates?: unknown }; + expect(payload.error).toBe('no_sql_seam'); + expect(exitCode).toBe(1); + + // The shape that must never come back: the clean bill of health. + expect(payload).not.toHaveProperty('duplicates'); + expect(payload).not.toHaveProperty('summary'); + // The detail names the case that was invisible before — a seam that is + // present and returns nothing, not merely one that is missing. + expect(payload.detail).toMatch(/returning no result set/); + }, 120_000); +}); diff --git a/packages/cli/src/commands/migrate/duplicates.ts b/packages/cli/src/commands/migrate/duplicates.ts index cbd2427bac..3f9fc12110 100644 --- a/packages/cli/src/commands/migrate/duplicates.ts +++ b/packages/cli/src/commands/migrate/duplicates.ts @@ -431,6 +431,97 @@ export function collectScanTargets( return { targets, skipped }; } +/** + * ── The seam that ACCEPTS a query but never ANSWERS one (#10677) ─────────── + * + * `resolveSeedTenancyExec` answers "is there a raw-SQL seam here?" by looking + * for a callable — `typeof d.execute === 'function'`. That is a question about + * the driver's SHAPE, and one driver in this repo satisfies the shape without + * satisfying the contract: `InMemoryDriver.execute()` logs + * `Raw execution not supported in InMemory driver` and returns `null`. It + * neither throws nor is absent. + * + * `normalizeRows(null)` is `[]`, and `[]` is also what a real driver returns + * for a SELECT that matched nothing. So the no-op seam produced the one + * outcome #8928's ruling exists to forbid: `duplicates: []`, `skipped: []`, + * `counters.status: "read"`, exit 0 — a clean bill of health from a probe that + * never ran. Measured on `os migrate duplicates --database-url memory://qa`: + * exit 0, an empty report, and three `Raw execution not supported` warnings on + * stderr for the three statements it silently swallowed. + * + * The fix keys on the only thing that actually separates the two cases: a + * driver that ANSWERS returns a RESULT SET, and a driver that cannot answer + * returns no result set at all. "Absent" and "empty" stop being the same + * observation. Nothing here names a driver — a seam is judged by what it + * returns, so a future host with the same no-op shape is covered without an + * allowlist to maintain. + */ + +/** + * Is `result` one of the result-set shapes a raw SELECT can come back as? + * + * The same three `normalizeRows` flattens, asked as a yes/no: a bare row array + * (better-sqlite3 through knex), `{ rows }` (pg), and the `[rows, fields]` + * tuple (mysql2) — an empty result set in any of those spellings is still a + * result set, and still `true`. Everything else is the fourth thing a seam can + * hand back, which is nothing: `null`, `undefined`, or a value the dialects do + * not produce. + * + * This cannot lose rows that `normalizeRows` would have found. Every shape it + * rejects is a shape `normalizeRows` already flattens to `[]`, so the change is + * only ever "reported as unreadable" replacing "reported as zero rows". + */ +export function isResultSet(result: unknown): boolean { + if (Array.isArray(result)) return true; + if (typeof result === 'object' && result !== null) { + return Array.isArray((result as { rows?: unknown }).rows); + } + return false; +} + +/** Legal in every dialect this report supports, reads nothing, writes nothing. */ +const SEAM_PREFLIGHT_SQL = 'select 1 as os_seam_probe'; + +const SEAM_NO_ANSWER_DETAIL = + 'the raw-SQL seam returned no result set — a seam that cannot answer is not a seam that answered "no rows"'; + +/** + * Does the resolved seam decline to answer at all? + * + * ⚠️ A seam that THROWS is deliberately NOT reported here. Throwing is a + * driver present and refusing loudly, and the per-probe `skipped` path already + * records that honestly (`duplicate probe failed: `), so an + * install whose seam errors keeps exactly the behaviour it has today. Only a + * seam that RETURNS a non-answer is the defect this guards, because only that + * one is invisible in the report. + */ +export async function seamAnswersNothing(exec: SeedTenancyExec): Promise { + try { + return !isResultSet(await exec(SEAM_PREFLIGHT_SQL, [])); + } catch { + return false; + } +} + +/** + * Wrap a seam so a probe that returns no result set FAILS instead of reading as + * zero rows. + * + * The preflight above rejects a wholly no-op seam before the scan starts; this + * covers the remainder of the class — a seam that answers one statement and not + * the next. It adds no new refusal and no new report key: the throw lands in + * the `catch` blocks the collector already has, so the outcome is a `skipped` + * entry naming the reason, which is what this command's contract has always + * promised for a target it could not read. + */ +export function answeringSeam(exec: SeedTenancyExec): SeedTenancyExec { + return async (sql: string, params?: unknown[]) => { + const result = await exec(sql, params); + if (!isResultSet(result)) throw new Error(SEAM_NO_ANSWER_DETAIL); + return result; + }; +} + /** How many values one holder query asks about — bound-parameter limits are per dialect. */ const HOLDER_BATCH = 200; @@ -694,15 +785,24 @@ export default class MigrateDuplicates extends Command { const getService = (stack.kernel as { getService?: (name: string) => unknown })?.getService; const ql = getService?.call(stack.kernel, 'objectql') as IObjectQLEngine | undefined; const exec = resolveSeedTenancyExec(ql); - if (!exec) { - // Loud absence, never a clean empty report: a driver with no raw-SQL - // seam (memory, mongodb) cannot be grouped by value from here, and - // "zero duplicates" would be indistinguishable from "never looked". + // Loud absence, never a clean empty report: a driver that cannot be + // grouped by value from here must say so, because "zero duplicates" + // would be indistinguishable from "never looked". + // + // TWO ways a seam fails to be one, and the second is the whole of #10677: + // it can be missing (`resolveSeedTenancyExec` returns undefined), or it + // can be present, accept every statement and answer none of them. The + // resolver only ever saw the first, because it judges the driver's SHAPE + // (`typeof d.execute === 'function'`) and a no-op `execute` has the right + // shape. So the seam is asked one trivial question before the scan + // starts, and a seam that returns no RESULT SET is absent, not empty. + if (!exec || (await seamAnswersNothing(exec))) { await emitJson( { error: 'no_sql_seam', detail: - 'The active driver exposes no raw SQL seam, so the data-side duplicate probe cannot run. ' + + 'The active driver exposes no usable raw SQL seam — it is either absent, or present but ' + + 'returning no result set — so the data-side duplicate probe cannot run. ' + 'This report supports the SQL drivers (sqlite / postgres / mysql / turso).', }, 1, @@ -712,7 +812,11 @@ export default class MigrateDuplicates extends Command { } const report = await collectDuplicateIdentifierReport({ - exec, + // Wrapped, not bare: the preflight cleared the seam as a whole, and + // this keeps every individual probe held to the same standard — one + // that returns no result set becomes a `skipped` entry rather than + // zero findings. + exec: answeringSeam(exec), normalize: normalizeRows, objects: stack.allObjects(), database: stack.dbLabel,