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,