From 608905ce602cef94f082e7eb4e35371057cfff85 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 00:10:00 +0000 Subject: [PATCH] fix(metadata-protocol): a seam that cannot answer is absent, not empty (#10789) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `backfillSeedTenancy` reported `no-split` over a driver it never queried. A no-op `execute` returns `null` — it neither throws nor is absent — so the `no-driver` guard's shape test passed, `normalizeRows(null)` flattened to `[]`, and the module's own `absent` branch was unreachable on a memory engine despite its comment naming exactly that case. The READ probes are now held to the standard that separates the two: a driver that answers returns a RESULT SET. Write statements stay on the bare seam (an UPDATE returns no result set on every dialect), a throwing seam keeps its existing `absent` route, and an empty result set in all three dialect spellings is still an ANSWER — so a healthy SQL install still reports `no-split`. Consumer-side only; no driver package was modified (#5499 freeze). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019yDEhPBC3tcGkW9bkce1HM --- .changeset/seed-tenancy-absent-seam.md | 45 +++ .../seed-tenancy-backfill.null-seam.test.ts | 353 ++++++++++++++++++ .../src/migrations/seed-tenancy-backfill.ts | 123 +++++- ...nancy-autonumber-split.integration.test.ts | 50 +++ 4 files changed, 562 insertions(+), 9 deletions(-) create mode 100644 .changeset/seed-tenancy-absent-seam.md create mode 100644 packages/metadata-protocol/src/migrations/seed-tenancy-backfill.null-seam.test.ts diff --git a/.changeset/seed-tenancy-absent-seam.md b/.changeset/seed-tenancy-absent-seam.md new file mode 100644 index 0000000000..4e727fcd40 --- /dev/null +++ b/.changeset/seed-tenancy-absent-seam.md @@ -0,0 +1,45 @@ +--- +"@objectstack/metadata-protocol": patch +--- + +`backfillSeedTenancy` no longer reports `no-split` over a driver it never queried +(#10789). The boot-time seed/API tenancy repair answered `status: 'no-split'` — +*"I looked, there is no split"* — on the memory driver, having looked at nothing, +and its own `absent` branch was unreachable there despite the branch's comment +saying *"Absent on a memory engine"*. + +`InMemoryDriver.execute()` logs `Raw execution not supported in InMemory driver` +and returns `null`. It neither throws nor is absent, so `resolveSeedTenancySeam`'s +shape test (`typeof d.execute === 'function'`) was satisfied and the `no-driver` +guard never fired; `normalizeRows(null)` is `[]`, which is also what a real driver +returns for a SELECT that matched nothing. Every branch of this migration reads +"no rows" as "healthy install, nothing to do", so the two collapsed into one +answer. + +The migration now separates the cases the guard used to conflate: **a seam that +cannot answer is absent, not empty.** Its READ probes are held to the standard +that actually distinguishes them — a driver that answers returns a RESULT SET — +so a probe that hands back no result set reports `absent` (with a `detail` naming +the reason) instead of being read as zero rows. Nothing names a driver: any host +with the same no-op shape is covered without an allowlist to maintain. This is the +consumer-side shape #10677 / PR #10788 landed for `os migrate duplicates`, applied +to this module's own probes. No driver package was modified. + +Three behaviours are deliberately unchanged: + +- **A real SQL install does not move.** An empty result set is an ANSWER in every + dialect spelling — a bare `[]`, `{ rows: [] }`, and the `[rows, fields]` tuple — + so a healthy install still reports `no-split`. The counter-table presence probe + is a `WHERE 1 = 0` SELECT that matches nothing by construction and runs on every + boot, which is exactly why "no rows" must stay distinct from "no answer". +- **Write statements are not held to "must answer".** An UPDATE or DELETE does not + return a result set on every dialect, so the repair's stamp and counter-merge + statements stay on the bare seam. +- **A seam that THROWS keeps its behaviour.** Throwing is a driver present and + refusing loudly, and step 1's `catch` already reported it as `absent`; only a + seam that RETURNS a non-answer was invisible. + +Boot-time behaviour is otherwise untouched: neither status logs anything, and +neither writes a ledger receipt, so a memory-driver boot logs exactly what it +logged before. What changes is the reported `status`, which is the value a caller +uses to tell "nothing to repair" from "could not look". diff --git a/packages/metadata-protocol/src/migrations/seed-tenancy-backfill.null-seam.test.ts b/packages/metadata-protocol/src/migrations/seed-tenancy-backfill.null-seam.test.ts new file mode 100644 index 0000000000..58566d9495 --- /dev/null +++ b/packages/metadata-protocol/src/migrations/seed-tenancy-backfill.null-seam.test.ts @@ -0,0 +1,353 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #10789 — `backfillSeedTenancy` reported `no-split` over a driver it never + * queried, and its own `absent` branch was unreachable on a no-op seam. + * + * ## The defect + * + * `InMemoryDriver.execute()` (`driver-memory/src/memory-driver.ts`) logs + * `Raw execution not supported in InMemory driver` and returns `null`. It + * neither throws nor is absent, so: + * + * 1. `resolveSeedTenancySeam`'s `canRun` — `typeof d.execute === 'function'`, + * a question about the driver's SHAPE — is satisfied, and + * `if (!seam?.exec) return { status: 'no-driver' }` never fires; + * 2. step 1's counter-table presence probe RETURNS instead of throwing, so its + * `catch { return { status: 'absent' } }` never runs — even though the + * branch's own comment says *"Absent on a memory engine"*, which makes this + * a provably broken intent rather than a design choice; + * 3. `normalizeRows(null)` is `[]`, so step 2 sees zero rows and the migration + * answers `no-split` — *"I looked, there is no split"* — having looked at + * nothing. + * + * ⭐ The distinction being lost, and the whole of what this file pins: **a seam + * that cannot ANSWER is absent, not empty.** `null` is a fourth thing beside the + * three dialect result-set shapes `normalizeRows` flattens — it means "I did not + * run your query", and it was mapped onto "your query returned no rows". + * + * Same class, same consumer-side shape, as #10677 / PR #10788 landed for + * `os migrate duplicates`: judge the seam by whether it returns a RESULT SET, + * not by whether `execute` exists. No driver is named by the implementation. + * + * ## Why the real driver is not booted here, and where it IS pinned + * + * `@objectstack/driver-memory` is deliberately NOT imported. Every module + * binding of that specifier is gated by `pnpm check:driver-memory-census` + * against `scripts/driver-memory-census.ledger.json`, whose own header rules + * that an unledgered arrival is "NOT a bookkeeping chore to silence" — it needs + * a disposition through #5704 Q2 / #6664 A-B-C first. The ledger is shrink-only. + * + * Nothing is lost by that. The `execute() -> null` shape is already pinned on a + * REAL booted memory driver by + * `packages/cli/src/commands/migrate/duplicates.null-seam.test.ts` (#10677), + * which asserts both halves on the live driver: the resolver still hands back a + * seam, and that seam resolves to `null`. This file pins what THAT one cannot — + * what `backfillSeedTenancy` does with such a seam — and the real-SQL-driver + * half (a seam that answers, over a real `_objectstack_sequences`) is pinned in + * `packages/runtime/src/seed-tenancy-autonumber-split.integration.test.ts`. + * + * ## The two-sided bar + * + * A change that answered `absent` whenever it was unsure would satisfy the first + * half of this file perfectly and destroy the status's value. So both readings + * are pinned here, and they falsify in opposite directions: + * + * - the `absent`-on-a-non-answering-seam cases are a DEFECT CONTROL — they are + * red on the pre-fix tree, where the migration answers `no-split`; + * - the `no-split`-on-a-real-seam cases are PRESERVED BEHAVIOUR — green before + * and after, and falsified by mutating the fix (make the non-answer + * detection over-trigger, e.g. treat an empty array as a non-answer, and + * they go red while the defect control stays green). + */ + +import { describe, it, expect, vi } from 'vitest'; +import { + backfillSeedTenancy, + isResultSet, + normalizeRows, + resolveSeedTenancySeam, + GLOBAL_TENANT, + ORGANIZATION_TABLE, +} from './seed-tenancy-backfill.js'; +import type { SeedTenancyExec } from './seed-tenancy-backfill.js'; + +function createLogger() { + return { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; +} + +/** + * The measured no-op seam: accepts every statement, answers none of them. + * + * Spelled as the driver spells it — `async () => null` — rather than as a + * rejection, because a seam that THROWS is a different case and is deliberately + * left alone below. + */ +const nonAnsweringSeam: SeedTenancyExec = async () => null; + +/** + * A seam that ANSWERS every probe, with an empty result set in one dialect's + * spelling. A real install with nothing to repair looks exactly like this: the + * counter table exists, and no object holds counters on both sides of a split. + */ +function answeringEmptySeam(spelling: 'sqlite' | 'pg' | 'mysql'): SeedTenancyExec { + const empty = { sqlite: [], pg: { rows: [], rowCount: 0 }, mysql: [[], []] }[spelling]; + return async () => empty; +} + +// ─────────────────────────────────────────────────────────────────────────── +// 1. The separation itself, on values +// ─────────────────────────────────────────────────────────────────────────── + +describe('#10789 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([{ object: 'crm_case' }])).toBe(true); + expect(isResultSet([])).toBe(true); + // pg: `{ rows, rowCount, … }`. + expect(isResultSet({ rows: [{ object: 'crm_case' }], rowCount: 1 })).toBe(true); + expect(isResultSet({ rows: [], rowCount: 0 })).toBe(true); + // mysql2: the `[rows, fields]` tuple. + expect(isResultSet([[{ object: 'crm_case' }], []])).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', () => { + // The safety argument for the whole change, asserted rather than claimed: + // every shape newly treated as "unreadable" is one that already produced + // zero rows, so no split this migration used to find can stop being found. + for (const shape of [null, undefined, 'SELECT 1', {}, { rows: 'not-an-array' }, 42]) { + expect(isResultSet(shape)).toBe(false); + expect(normalizeRows(shape)).toEqual([]); + } + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// 2. DEFECT CONTROL — `absent` on a seam that cannot answer +// (red pre-fix: the migration answers `no-split`) +// ─────────────────────────────────────────────────────────────────────────── + +describe('#10789 a seam that returns no result set is ABSENT, not empty', () => { + it('[defect control] a no-op seam reports absent — never no-split', async () => { + const log = createLogger(); + const result = await backfillSeedTenancy( + { exec: nonAnsweringSeam, client: 'better-sqlite3' }, + log as any, + ); + + // Pre-fix this is `no-split` — "I looked, there is no split" — from a probe + // that never ran. That is the observable defect, and this line is the pin. + expect(result.status).toBe('absent'); + expect(result.status).not.toBe('no-split'); + // `absent` already covers "could not read the counter table" (a THROWING + // seam has always landed here). `detail` is what separates the two reasons + // for an operator reading the result. + expect(result.detail).toMatch(/no result set/); + expect(result).toMatchObject({ splits: [], collisions: [], objectsStamped: 0 }); + }); + + it('[defect control] the resolver still says yes — which is why absent was unreachable', async () => { + // Unchanged on purpose. `canRun` asks whether the driver has the SHAPE of a + // seam, a no-op `execute` has that shape, and this is the half that made + // `no-driver` miss. The fix is downstream of here, not in the resolver: a + // resolver that rejected a callable would have to CALL it to know, which is + // a probe, not a shape test. + const seam = resolveSeedTenancySeam({ driver: { execute: async () => null } }); + expect(seam?.exec).toBeTypeOf('function'); + await expect(seam!.exec('SELECT 1')).resolves.toBeNull(); + }); + + it('[defect control] a seam answering the presence probe but not the split probe is absent too', async () => { + // The residual class: a seam that answers one statement and not the next. + // Pre-fix this is also `no-split`, for the same reason and one probe later. + const result = await backfillSeedTenancy( + { + exec: async (sql: string) => (sql.includes('WHERE 1 = 0') ? [] : null), + client: 'better-sqlite3', + }, + createLogger() as any, + ); + + expect(result.status).toBe('absent'); + expect(result.detail).toMatch(/no result set/); + }); + + it('[defect control] a healthy install and an unreadable one are no longer the same answer', async () => { + // The two runs differ ONLY in whether the seam answers. Before the fix both + // returned `no-split`, which is what made the status unusable for telling + // "nothing to repair" from "could not look". + const unreadable = await backfillSeedTenancy( + { exec: nonAnsweringSeam, client: 'better-sqlite3' }, + createLogger() as any, + ); + const healthy = await backfillSeedTenancy( + { exec: answeringEmptySeam('sqlite'), client: 'better-sqlite3' }, + createLogger() as any, + ); + + expect(unreadable.status).toBe('absent'); + expect(healthy.status).toBe('no-split'); + expect(unreadable.status).not.toBe(healthy.status); + }); + + it('[defect control] an unreadable seam stays SILENT — it is not a new boot-time warning', async () => { + // Blast radius: this migration runs at boot on every install. The status is + // the only thing that moves; a memory-driver boot logs exactly what it + // logged before, which is nothing from this module. + const log = createLogger(); + await backfillSeedTenancy({ exec: nonAnsweringSeam, client: 'better-sqlite3' }, log as any); + + expect(log.warn).not.toHaveBeenCalled(); + expect(log.error).not.toHaveBeenCalled(); + expect(log.info).not.toHaveBeenCalled(); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// 3. PRESERVED BEHAVIOUR — `no-split` on a real seam with no split rows +// (green pre-fix; falsified by mutating the fix to over-trigger) +// ─────────────────────────────────────────────────────────────────────────── + +describe('#10789 a seam that answers with no rows still reports no-split', () => { + it.each(['sqlite', 'pg', 'mysql'] as const)( + '[preserved] an empty result set in the %s spelling is an ANSWER, not a non-answer', + async (spelling) => { + // This is the half that stops the fix being vacuous. An empty result set + // is the overwhelmingly common case — every healthy install, every boot + // before a split exists — and all three dialects spell it differently. + // A non-answer check that rejected any of these would turn every healthy + // SQL install's boot status into `absent`. + const log = createLogger(); + const result = await backfillSeedTenancy( + { exec: answeringEmptySeam(spelling), client: 'better-sqlite3' }, + log as any, + ); + + expect(result.status).toBe('no-split'); + expect(result.detail).toBeUndefined(); + // Still silent, still writes no receipt — a healthy boot narrates nothing. + expect(log.warn).not.toHaveBeenCalled(); + expect(log.info).not.toHaveBeenCalled(); + }, + ); + + it('[preserved] the applied path still applies — write statements are NOT held to "must answer"', async () => { + // ⛔ The guard covers the READ probes only. An UPDATE/DELETE does not return + // a result set on every dialect (better-sqlite3 through knex reports a + // change count, mysql2 a `ResultSetHeader`), so holding the write + // statements to the same standard would break the repair on the very + // installs it exists for. This seam answers every SELECT and hands back a + // NON-result-set for every write — and the repair must still complete. + const writes: string[] = []; + const exec: SeedTenancyExec = async (sql: string) => { + if (sql.startsWith('UPDATE') || sql.startsWith('DELETE')) { + writes.push(sql.slice(0, 6)); + return { affectedRows: 3 }; // not a result set, by design + } + if (sql.includes('WHERE 1 = 0')) return []; + if (sql.includes('LEFT JOIN')) { + return [ + { object: 'crm_case', field: 'case_number', global_last_value: 38, organization_last_value: 1 }, + ]; + } + if (sql.includes(ORGANIZATION_TABLE)) return [{ id: 'org_a' }]; + if (sql.includes('rows_holding')) return []; + if (sql.includes('tenant_id')) return [{ tenant_id: 'org_a', last_value: 1 }]; + return []; + }; + + const result = await backfillSeedTenancy({ exec, client: 'better-sqlite3' }, createLogger() as any); + + expect(result.status).toBe('applied'); + expect(result.objectsStamped).toBe(1); + expect(result.organizationId).toBe('org_a'); + expect(writes).toContain('UPDATE'); + expect(writes).toContain('DELETE'); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// 4. NON-EFFECTS — the branches that must not move +// ─────────────────────────────────────────────────────────────────────────── + +describe('#10789 the branches this fix must leave alone', () => { + it('[non-effect] no-driver still fires where it fires today', async () => { + // A host with no raw-SQL-capable driver at all resolves to no seam, and that + // is still a different answer from a seam that cannot answer. + expect(resolveSeedTenancySeam({})).toBeUndefined(); + const result = await backfillSeedTenancy(resolveSeedTenancySeam({}), createLogger() as any); + expect(result.status).toBe('no-driver'); + }); + + it('[non-effect] a seam that THROWS is unchanged — that path was never the defect', async () => { + // Throwing is a driver present and refusing LOUDLY, and step 1's `catch` + // already reported it honestly as `absent`. Only a seam that RETURNS a + // non-answer was invisible, so only that one changed. + const result = await backfillSeedTenancy( + { + exec: async () => { + throw new Error('ECONNREFUSED'); + }, + client: 'better-sqlite3', + }, + createLogger() as any, + ); + + expect(result.status).toBe('absent'); + // No `detail` from the non-answer branch: this one did not take it. + expect(result.detail).toBeUndefined(); + }); + + it('[non-effect] the split probe throwing still reports absent with the driver message', async () => { + const result = await backfillSeedTenancy( + { + exec: async (sql: string) => { + if (sql.includes('WHERE 1 = 0')) return []; + throw new Error('no such table: _objectstack_sequences'); + }, + client: 'better-sqlite3', + }, + createLogger() as any, + ); + + expect(result.status).toBe('absent'); + expect(result.detail).toMatch(/no such table/); + }); + + it('[non-effect] a real split is still detected and still reaches the guards', async () => { + // The multi-tenant skip is reached through the same two probes the fix now + // guards, so a fix that rejected a legitimate answer would silently stop + // this branch from ever running. + const exec: SeedTenancyExec = async (sql: string) => { + if (sql.includes('WHERE 1 = 0')) return []; + if (sql.includes('LEFT JOIN')) { + return [ + { object: 'crm_case', field: 'case_number', global_last_value: 38, organization_last_value: 1 }, + ]; + } + if (sql.includes(ORGANIZATION_TABLE)) return [{ id: 'org_a' }, { id: 'org_b' }]; + return []; + }; + + const result = await backfillSeedTenancy({ exec, client: 'better-sqlite3' }, createLogger() as any); + + // Two organizations, so the owner is not derivable — the loud skip, not a + // silent no-op and not `absent`. + expect(result.status).toBe('skipped-ambiguous-organization'); + expect(result.splits).toEqual([ + { object: 'crm_case', field: 'case_number', globalLastValue: 38, organizationLastValue: 1 }, + ]); + expect(GLOBAL_TENANT).toBe('__global__'); + }); +}); diff --git a/packages/metadata-protocol/src/migrations/seed-tenancy-backfill.ts b/packages/metadata-protocol/src/migrations/seed-tenancy-backfill.ts index c5548e1e68..d7daae0241 100644 --- a/packages/metadata-protocol/src/migrations/seed-tenancy-backfill.ts +++ b/packages/metadata-protocol/src/migrations/seed-tenancy-backfill.ts @@ -144,9 +144,21 @@ export type SeedTenancyExec = (sql: string, params?: unknown[]) => Promise[] { return []; } +/** + * ── The seam that ACCEPTS a query but never ANSWERS one (#10789) ─────────── + * + * {@link normalizeRows} flattens the three shapes a raw SELECT comes back as. + * A seam can hand back a FOURTH thing, and it means something else entirely: + * `null` — "I did not run your query". `InMemoryDriver.execute()` is the + * measured case (it logs `Raw execution not supported in InMemory driver` and + * returns `null`); it neither throws nor is absent, so `resolveSeedTenancySeam`'s + * shape test is satisfied and `no-driver` never fires, while `normalizeRows(null)` + * is `[]` — which is also what a real driver returns for a SELECT that matched + * nothing. + * + * Every branch of this migration reads "no rows" as "healthy install, nothing to + * do", so the two collapsed into one answer and the module reported `no-split` + * — "I looked, there is no split" — over a driver it never queried. Step 1's + * `absent` branch, whose own comment says *"Absent on a memory engine"*, was + * unreachable there for exactly this reason. + * + * ⭐ **A seam that cannot ANSWER is absent, not empty.** The separation keys on + * the only thing that distinguishes them: a driver that answers returns a RESULT + * SET. Nothing here names a driver, so any host with the same no-op shape is + * covered without an allowlist to maintain — the consumer-side shape #10677 / + * PR #10788 landed for `os migrate duplicates`, applied to this module's own + * probes. + */ + +/** + * Is `result` one of the result-set shapes a raw SELECT can come back as? + * + * The same three {@link 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` — that is what keeps a healthy install's + * `no-split` intact, and it is the half of this change that stops it being a + * rename. + * + * This cannot lose a split that {@link normalizeRows} would have found: every + * shape it rejects is one already flattened to `[]`, so the only change is + * "reported as unreadable" replacing "reported as zero rows". + * + * ⛔ NOT exported from the package index. It has no consumer outside this + * module, and the CLI's `migrate/duplicates.ts` carries its own copy for its own + * probes (#10677) — unifying the two is a separate decision, exactly as + * `quoteIdent` records for the same pair. + */ +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; +} + +/** Why a probe was treated as unreadable — the `detail` an operator reads. */ +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"'; + +/** + * Run one READ probe and flatten it, failing when the seam answered nothing. + * + * The throw lands in the `catch` each probe below already has, so this adds no + * new branch and no new status — a probe that cannot answer takes the same route + * a probe that THREW has always taken. That equivalence is the point: both mean + * "this migration could not look", and only one of them used to say so. + * + * ⛔ READ probes only. An UPDATE or DELETE does not return a result set on every + * dialect (better-sqlite3 through knex reports a change count, mysql2 a + * `ResultSetHeader`), so the write statements in step 6 stay on the bare `exec`. + * Holding them to "must answer" would break the repair on exactly the installs + * it exists for. + */ +async function selectRows( + exec: SeedTenancyExec, + sql: string, + params?: unknown[], +): Promise[]> { + const result = await exec(sql, params); + if (!isResultSet(result)) throw new Error(SEAM_NO_ANSWER_DETAIL); + return normalizeRows(result); +} + /** * Reject anything that is not a plain SQL identifier. * @@ -848,8 +941,18 @@ export async function backfillSeedTenancy( // 1. Is there a counter table at all? Absent on a memory engine, and on any // install that has never allocated an autonumber. + // + // TWO ways this probe fails to find one, and the second is #10789. The + // table can be missing — the driver raises, and the `catch` reports it. Or + // the SEAM can be one that accepts the statement and never runs it: a no-op + // `execute` that returns `null` neither throws nor is absent, so this + // branch was unreachable on a memory engine despite the comment above + // saying it was the case it existed for. Both mean "no counter table was + // read", which is what `absent` says; `detail` separates the reasons. try { - await exec(buildSequencesPresenceSql(client)); + if (!isResultSet(await exec(buildSequencesPresenceSql(client)))) { + return { status: 'absent', ...empty, detail: SEAM_NO_ANSWER_DETAIL }; + } } catch { return { status: 'absent', ...empty }; } @@ -860,7 +963,7 @@ export async function backfillSeedTenancy( // loudly, because reaching it means a real defect is present. let splits: SeedTenancySplit[]; try { - const rows = normalizeRows(await exec(buildSplitProbeSql(client), [GLOBAL_TENANT, GLOBAL_TENANT])); + const rows = await selectRows(exec, buildSplitProbeSql(client), [GLOBAL_TENANT, GLOBAL_TENANT]); splits = rows .filter((r) => isSafeIdentifier(r.object) && isSafeIdentifier(r.field)) // Platform seeds stay global — the loader's own rule, see PLATFORM_NAMESPACE. @@ -904,7 +1007,7 @@ export async function backfillSeedTenancy( // 4. Exactly one organization, or there is nothing derivable to adopt. let organizationIds: string[] = []; try { - organizationIds = normalizeRows(await exec(buildOrganizationProbeSql(client))) + organizationIds = (await selectRows(exec, buildOrganizationProbeSql(client))) .map((r) => (r.id == null ? '' : String(r.id))) .filter((id) => id.length > 0); } catch { @@ -932,7 +1035,7 @@ export async function backfillSeedTenancy( const collisions: SeedTenancyCollision[] = []; for (const split of splits) { try { - const rows = normalizeRows(await exec(buildCollisionProbeSql(split.object, split.field, client))); + const rows = await selectRows(exec, buildCollisionProbeSql(split.object, split.field, client)); for (const r of rows) { if (r.value == null) continue; collisions.push({ @@ -985,9 +1088,11 @@ export async function backfillSeedTenancy( // same split and retries the whole repair. if (stampFailures.includes(split.object)) continue; try { - const orgRows = normalizeRows( - await exec(buildOrgCounterProbeSql(client), [split.object, split.field, GLOBAL_TENANT]), - ); + const orgRows = await selectRows(exec, buildOrgCounterProbeSql(client), [ + split.object, + split.field, + GLOBAL_TENANT, + ]); for (const row of orgRows) { const tenantId = row.tenant_id == null ? '' : String(row.tenant_id); if (!tenantId) continue; diff --git a/packages/runtime/src/seed-tenancy-autonumber-split.integration.test.ts b/packages/runtime/src/seed-tenancy-autonumber-split.integration.test.ts index 702edaeef5..f8f74cf77f 100644 --- a/packages/runtime/src/seed-tenancy-autonumber-split.integration.test.ts +++ b/packages/runtime/src/seed-tenancy-autonumber-split.integration.test.ts @@ -50,6 +50,7 @@ import { SqlDriver } from '@objectstack/driver-sql'; import { SeedLoaderService, backfillSeedTenancy, + buildSequencesPresenceSql, resolveSeedTenancySeam, GLOBAL_TENANT, } from '@objectstack/metadata-protocol'; @@ -385,4 +386,53 @@ describe('#8686 seed/API tenancy split — autonumber scope', () => { expect(untenanted).toBe(3); expect(await readSequences(driver)).toEqual([{ tenant: GLOBAL_TENANT, lastValue: 3 }]); }); + /** + * #10789 — PRESERVED-BEHAVIOUR control for the `absent`/`no-split` separation. + * + * `backfillSeedTenancy` used to answer `no-split` over a seam it never + * queried: a no-op `execute` returns `null`, `normalizeRows(null)` is `[]`, + * and zero rows is this module's every-branch no-op. The repair keys the + * distinction on whether a probe returns a RESULT SET — so this case exists + * to prove the OTHER side of that key, on the real driver, with real SQL: + * + * - an EMPTY result set is an ANSWER. `buildSequencesPresenceSql` is a + * `WHERE 1 = 0` SELECT that matches nothing by construction, and it is the + * first thing every boot runs. If "no rows" were read as "no answer", every + * healthy SQL install on earth would report `absent` at boot instead of + * `no-split` — a fix that satisfied the defect control perfectly and + * destroyed the status. + * - a real seam with no split rows still reports `no-split`. + * + * A hand-built double cannot stand in: the whole question is what + * better-sqlite3 through knex actually hands back for an empty SELECT, which + * is a fact about the driver, not about the fixture. + */ + it('[#10789 preserved] a real seam with no split rows still answers no-split, not absent', async () => { + const { driver, engine } = await bootInstall(); + + // An install that allocated numbers WITH an organization from the first + // write: `_objectstack_sequences` exists and holds exactly one row, so there + // is a counter table to read and nothing split across two partitions. + await createOrganization(engine); + await apiCreate(engine, 'api 1'); + await apiCreate(engine, 'api 2'); + expect(await readSequences(driver)).toEqual([{ tenant: ORG_ID, lastValue: 2 }]); + + const seam = resolveSeedTenancySeam(engine); + + // The measurement the separation rests on, taken from the driver itself + // rather than assumed: the presence probe matches no rows and still comes + // back as a result set (a bare array, on this dialect). + const presence = await seam!.exec(buildSequencesPresenceSql(seam!.client)); + expect(Array.isArray(presence)).toBe(true); + expect(presence).toEqual([]); + + const result = await backfillSeedTenancy(seam, createLogger() as any); + + expect(result.status).toBe('no-split'); + expect(result.detail).toBeUndefined(); + // Nothing moved: the SQL path is untouched by #10789. + expect(await readSequences(driver)).toEqual([{ tenant: ORG_ID, lastValue: 2 }]); + expect(await countUntenanted(driver)).toBe(0); + }); });