From fd03ab28d91ead949dd709beeb128581d6edeb18 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 21:38:30 +0000 Subject: [PATCH 1/2] =?UTF-8?q?fix(objectql):=20sys=5Ffile=20hydrate=20?= =?UTF-8?q?=E8=AF=BB=E6=95=85=E9=9A=9C=E4=B8=8E=E3=80=8C=E6=97=A0=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E3=80=8D=E5=8F=AF=E5=88=86=E8=BE=A8=20(#6116)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `resolveFileReferences` 的 sys_file 批量查找此前坐在裸 `catch { return records }` 后面:连接中断、超时、权限拒绝、查询错误,与良性的「表还没建」 一样,都被同一次静默的裸 id 穿过所回答。消费方(UI / 导出)拿到裸 id 后按 「无附件」渲染,故障期间的表现与「记录本就没有文件」不可分辨 —— ADR-0110 D3 的族形,只是载体是功能面而非持久性面。 fail-open 行为本身不变(这是可诊断性修复,不是行为修复):文件元数据读失败 不该拖垮发起它的记录读,所以两个分支都仍然原样返回入参。变的是两种原因不再 共用同一份沉默。 catch 现在按错误类型分流,走仓内既有的 `isMissingTableError` 判别器 (`@objectstack/metadata/errors`),与本文件 `seedAutonumber` 同一调用,而不是 手抄一份 `code === '42P01'`: - 表未建:确实没有已提交的行,未 hydrate 的答案就是真相,保持静默穿过; - 其他读故障:一条 `warn`,带父对象、未 hydrate 的字段、未解析 id 数、driver 自己的报错、后果(这些 id 本次读会渲染成「无文件」)与修法。每次读说一次, 不是每条记录或每个 id 说一次。 按 AGENTS「Degradation log levels」判定为 warn 不升 error:此路径不声称任何 持久化,损失是功能性的、只影响本次响应,下一次成功读即修复。 一处对 issue 正文的实测更正:catch 位确为零输出,但整条路径并非字面无声 —— 上一帧的通用读处理器已在重抛前记 `Find operation failed`。该行未被触碰,也不 构成替代:它对良性与非良性故障逐字相同,且只描述 sys_file 子读,从不提及父 对象、被留作裸 id 的字段,或那个仍然返回给调用方的降级答案。测试中 `the pre-existing generic line cannot tell the two apart` 一节把这点钉住。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We --- .changeset/sys-file-hydrate-fault-warn.md | 48 ++ .../src/engine-file-hydrate-outage.test.ts | 419 ++++++++++++++++++ packages/objectql/src/engine.ts | 38 +- 3 files changed, 503 insertions(+), 2 deletions(-) create mode 100644 .changeset/sys-file-hydrate-fault-warn.md create mode 100644 packages/objectql/src/engine-file-hydrate-outage.test.ts diff --git a/.changeset/sys-file-hydrate-fault-warn.md b/.changeset/sys-file-hydrate-fault-warn.md new file mode 100644 index 0000000000..63dd2caca1 --- /dev/null +++ b/.changeset/sys-file-hydrate-fault-warn.md @@ -0,0 +1,48 @@ +--- +"@objectstack/objectql": patch +--- + +fix(objectql): a `sys_file` hydrate read fault is no longer indistinguishable from "this record has no file" (#6116) + +A file-field value stored as an opaque `sys_file` id is enriched on read into +`{ id, name, size, mimeType, url }`. That one batched lookup sat behind a bare +`catch { return records }`: **every** failure — connection drop, timeout, +permission denial, query error, and the benign "the table was never +provisioned" — was answered with the same silent pass-through of un-hydrated +ids. Consumers (UI, export) then receive a bare id where a file reference was +due and render it as *no attachment*, so a live outage looked exactly like a +record that genuinely holds no file. That is the ADR-0110 D3 shape — a fault +wearing the appearance of legitimate absent data — carried here on a functional +surface rather than a durability one. + +**Fail-open behaviour is unchanged, deliberately.** A file-metadata read that +fails must not take down the record read that asked for it, so the ids still +pass through un-hydrated and no read starts throwing. This is a +diagnosability fix: what changes is that the two reasons stop being the same +silence. + +The catch now discriminates by error **type**, through the shared +`isMissingTableError` predicate (`@objectstack/metadata/errors`) — the same +call the engine's autonumber seeding already makes, never a hand-rolled +`code === '42P01'` copy: + +- **table never provisioned** — the storage plugin is present but schema sync + has not run. There are genuinely no committed rows, so the un-hydrated + answer *is* the truth: passed through in silence, exactly as before, so an + app whose storage schema is not yet synced gains no per-read noise. +- **every other read failure** — the rows may well exist and simply were not + seen. One `warn` now names the parent object, the fields left un-hydrated, + how many ids went unresolved, the driver's own error, the consequence (those + ids will render as "no file" for this read) and the fix (check + storage/database availability, then re-read). Said once per read, not once + per record or per id. + +`warn` rather than `error` per the repo's degradation-log-level rule: nothing +on this path claims to have persisted anything, the answer is visibly smaller +for this response only, and the next successful read repairs it. + +Note for operators reading logs: the generic read handler one frame up already +logged `Find operation failed` for the failed sub-read. That line is unchanged +and is not a substitute — it is emitted identically for the benign and the +non-benign failure and describes the `sys_file` sub-read only, never the parent +object, the fields, or the degraded answer that was nevertheless returned. diff --git a/packages/objectql/src/engine-file-hydrate-outage.test.ts b/packages/objectql/src/engine-file-hydrate-outage.test.ts new file mode 100644 index 0000000000..bbc2d15518 --- /dev/null +++ b/packages/objectql/src/engine-file-hydrate-outage.test.ts @@ -0,0 +1,419 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #6116 — a `sys_file` hydrate READ FAULT must not be indistinguishable from + * "this record has no file". + * + * `resolveFileReferences` enriches a file-field value stored as an opaque + * `sys_file` id into `{ id, name, size, mimeType, url }` (ADR-0104 D3 wave 2). + * Its one batched lookup used to sit behind a bare `} catch { return records }`: + * EVERY failure — connection drop, timeout, permission denial, query error, and + * the benign "the table was never provisioned" — was answered with the same + * silent pass-through of un-hydrated ids. + * + * ONE CORRECTION to the issue body, measured rather than assumed: the catch is + * zero-output, but the PATH is not literally silent. The generic read handler + * one frame up already logs `error: 'Find operation failed' { object: + * 'sys_file' }` before rethrowing into this catch. That line is untouched here, + * and `the pre-existing generic line cannot tell the two apart` below pins why + * it does not satisfy the acceptance: it is byte-identical for the benign and + * the non-benign failure, and it describes the sub-read only — never the parent + * object, the fields left un-hydrated, or the consequence. + * + * The pass-through itself is correct and is NOT what this fixes. A file-metadata + * read that fails must not take down the record read that asked for it, so + * **fail-open is deliberate and unchanged** — the tests below pin it on both + * branches precisely so a future reader cannot mistake this for a behaviour + * change. What was wrong is that the degradation was INVISIBLE: consumers + * (UI, export) receive a bare id, render it as "no attachment", and the outage + * is indistinguishable from a record that genuinely holds no file. That is the + * ADR-0110 D3 family — a fault wearing the appearance of legitimate absent data + * — carried on a functional surface rather than a durability one. + * + * The fix discriminates by error TYPE through the shared `isMissingTableError` + * predicate (`@objectstack/metadata/errors`, #4825) — the same call + * `seedAutonumber` makes in this file (#5979) — never a hand-rolled + * `code === '42P01'` copy: + * + * - table never provisioned → pass ids through in silence (there are + * genuinely no committed rows, so the un-hydrated answer IS the truth); + * - every other read failure → ONE `warn` naming the object, the fields, the + * consequence and the fix, then pass the ids through anyway. + * + * `warn` and not `error`, per AGENTS "Degradation log levels": nothing on this + * path claims to have persisted anything, the answer is visibly smaller for + * this response only, and the next read repairs it — functional degradation. + * + * Why the gate never caught it (#5186's read-seam invention rule): that rule + * matches a catch that RETURNS AN EMPTY LITERAL (`[]`/`null`/`0`/…) for a read + * that failed. Returning the input argument unchanged is outside its + * vocabulary — see the PR for the stance on whether it should grow. + * + * These tests drive a fake DRIVER (not a fake engine), so no engine write-verb + * dispatch contract is involved. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { ObjectQL } from './engine'; + +/** The stored form: an opaque `sys_file` id token, per `isFileIdToken`. */ +const FILE_ID = 'f_7cQx2Lm90a'; + +/** + * A driver whose `sys_file` read behaves as `sysFileRead` says, while every + * other object reads normally out of an in-memory store. Splitting by object + * name is the whole point: the RECORD read must succeed so the test observes + * what the caller receives for a record that really does hold a file. + */ +function makeDriver(sysFileRead: () => Promise) { + const stores = new Map>(); + const storeFor = (object: string) => { + if (!stores.has(object)) stores.set(object, new Map()); + return stores.get(object)!; + }; + const driver: any = { + name: 'memory', + version: '0.0.0', + supports: {}, + async connect() {}, + async disconnect() {}, + async checkHealth() { + return true; + }, + async execute() { + return null; + }, + async find(object: string) { + if (object === 'sys_file') return sysFileRead(); + return [...storeFor(object).values()]; + }, + async findOne(object: string) { + if (object === 'sys_file') { + const rows = await sysFileRead(); + return rows[0] ?? null; + } + return [...storeFor(object).values()][0] ?? null; + }, + async create(object: string, data: Record) { + const id = (data.id as string) ?? `r_${storeFor(object).size + 1}`; + const row = { ...data, id }; + storeFor(object).set(id, row); + return row; + }, + async update(object: string, id: string, data: Record) { + const row = { ...storeFor(object).get(id), ...data, id }; + storeFor(object).set(id, row); + return row; + }, + async delete(object: string, id: string) { + return storeFor(object).delete(id); + }, + async count() { + return 0; + }, + async bulkCreate(object: string, rows: Record[]) { + return Promise.all(rows.map((r) => this.create(object, r))); + }, + async bulkUpdate() { + return []; + }, + async bulkDelete() {}, + async beginTransaction() { + return { __trx: true, commit: async () => {}, rollback: async () => {} }; + }, + async commit() {}, + async rollback() {}, + }; + return { driver, storeFor }; +} + +/** + * Records every line the engine writes, per level, for the log-contract pins. + * + * Captures the raw arg list because the two levels this file asserts on have + * DIFFERENT signatures in `Logger` (`warn(msg, meta)` vs + * `error(msg, error, meta)`); collapsing them to `(msg, meta)` reads the Error + * object as the metadata and quietly mis-asserts. + */ +function makeCapturingLogger() { + const lines: Record> = { + debug: [], info: [], warn: [], error: [], trace: [], fatal: [], + }; + const push = (level: string) => (...args: any[]) => { + lines[level].push({ msg: String(args[0]), args: args.slice(1) }); + }; + const logger: any = { + lines, + debug: push('debug'), + info: push('info'), + warn: push('warn'), + error: push('error'), + trace: push('trace'), + fatal: push('fatal'), + child() { + return logger; + }, + }; + return logger; +} + +/** The `warn` contract is `(message, meta)` — meta is the FIRST trailing arg. */ +const metaOfWarn = (line: { args: any[] }) => line.args[0]; + +/** Driver-shaped errors meaning "the table was never provisioned" (benign). */ +const MISSING_TABLE_ERRORS: Array<[string, () => unknown]> = [ + ['PostgreSQL 42P01 undefined_table', () => Object.assign(new Error('relation "sys_file" does not exist'), { code: '42P01' })], + ['MySQL ER_NO_SUCH_TABLE', () => Object.assign(new Error("Table 'app.sys_file' doesn't exist"), { code: 'ER_NO_SUCH_TABLE', errno: 1146 })], + ['SQLite message-only', () => new Error('no such table: sys_file')], +]; + +/** + * Driver-shaped errors meaning "the rows may well exist — I just could not see + * them". Each is a real outage class the old bare catch answered with silence. + */ +const OUTAGE_ERRORS: Array<[string, () => unknown]> = [ + ['connection refused', () => Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:5432'), { code: 'ECONNREFUSED' })], + ['statement timeout', () => Object.assign(new Error('canceling statement due to statement timeout'), { code: '57014' })], + ['permission denied', () => Object.assign(new Error('permission denied for table sys_file'), { code: '42501' })], + ['connection terminated mid-query', () => Object.assign(new Error('Connection terminated unexpectedly'), { code: '08006' })], +]; + +describe('sys_file hydrate read fault — distinguishable from "no file" (#6116)', () => { + let engine: ObjectQL; + let logger: ReturnType; + + /** + * Boots an engine holding ONE `doc` row whose `attachment` field carries a + * stored `sys_file` id, with the `sys_file` read wired to `sysFileRead`. + */ + async function boot(sysFileRead: () => Promise) { + logger = makeCapturingLogger(); + engine = new ObjectQL({ logger } as any); + const { driver, storeFor } = makeDriver(sysFileRead); + engine.registerDriver(driver, true); + await engine.init(); + engine.registry.registerObject({ + name: 'doc', + fields: { + title: { type: 'text' }, + attachment: { type: 'file' }, + }, + } as any); + // `sys_file` must be REGISTERED — the resolver skips without a failing + // query when the storage plugin is absent, which is a different branch. + engine.registry.registerObject({ + name: 'sys_file', + fields: { + name: { type: 'text' }, + size: { type: 'number' }, + mime_type: { type: 'text' }, + status: { type: 'text' }, + }, + } as any); + storeFor('doc').set('d1', { id: 'd1', title: 'Contract', attachment: FILE_ID }); + return { driver, storeFor }; + } + + beforeEach(() => { + logger = makeCapturingLogger(); + }); + + // -------------------------------------------------- fail-open, both sides -- + + describe('fail-open is UNCHANGED — ids pass through on every failure', () => { + for (const [label, make] of [...MISSING_TABLE_ERRORS, ...OUTAGE_ERRORS]) { + it(`returns the records with raw ids, never throws — ${label}`, async () => { + await boot(async () => { + throw make(); + }); + + const rows = await engine.find('doc', {} as any); + + // The read that ASKED for the file still answers. This is the pin that + // makes #6116 a diagnosability fix and not a behaviour change: if a + // later change turns this seam into a throw, this goes red. + expect(rows).toHaveLength(1); + expect(rows[0].title).toBe('Contract'); + expect(rows[0].attachment).toBe(FILE_ID); + }); + } + + it('findOne fails open on the same seam', async () => { + await boot(async () => { + throw Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:5432'), { code: 'ECONNREFUSED' }); + }); + + const row = await engine.findOne('doc', { where: { id: 'd1' } } as any); + + expect(row?.attachment).toBe(FILE_ID); + }); + }); + + // ---------------------------------------------------------------- benign -- + + describe('table not provisioned → silent pass-through (benign, unchanged)', () => { + for (const [label, make] of MISSING_TABLE_ERRORS) { + it(`adds no line of its own — ${label}`, async () => { + await boot(async () => { + throw make(); + }); + + await engine.find('doc', {} as any); + + // There are genuinely no committed rows, so the un-hydrated answer IS + // the truth and there is nothing to report. Reporting it anyway would + // fire on every read of an app whose storage schema is not synced — + // the noise that makes real lines unreadable. + expect(logger.lines.warn).toHaveLength(0); + expect(logger.lines.fatal).toHaveLength(0); + }); + } + }); + + // ------------------------------------------- why a new line was needed -- + + /** + * The pre-existing signal, measured — and why it does not satisfy the + * acceptance on its own. + * + * #6116's body says the seam logs nothing. Measured on `origin/main` that is + * true of the CATCH, but not of the whole path: the generic read handler one + * frame up (`engine.ts`, `'Find operation failed'`) already reports the + * failed `sys_file` sub-read at `error` before rethrowing into this catch. + * That line is real and this fix neither removes nor duplicates it. + * + * It cannot be the discriminator the acceptance asks for, for two reasons + * pinned below: it is emitted IDENTICALLY for the benign and the non-benign + * failure, and it describes the sub-read only — never the parent object, + * the fields left un-hydrated, or the consequence that those bare ids will + * read downstream as "this record has no file". + */ + describe('the pre-existing generic line cannot tell the two apart', () => { + async function errorCensus(make: () => unknown) { + await boot(async () => { + throw make(); + }); + await engine.find('doc', {} as any); + return logger.lines.error.map((l: any) => l.msg); + } + + it('reports the same `error` for a benign and a non-benign failure', async () => { + const benign = await errorCensus(() => new Error('no such table: sys_file')); + const outage = await errorCensus(() => + Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:5432'), { code: 'ECONNREFUSED' })); + + // Identical — so an operator reading only this line learns that a read + // failed, never whether the answer they received can be trusted. + expect(benign).toEqual(['Find operation failed']); + expect(outage).toEqual(['Find operation failed']); + }); + + it('names only the sub-read, not the degradation it caused', async () => { + await boot(async () => { + throw Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:5432'), { code: 'ECONNREFUSED' }); + }); + + await engine.find('doc', {} as any); + + // `error(message, error, meta)` — the meta says which object was read… + const [, meta] = logger.lines.error[0].args; + expect(meta).toMatchObject({ object: 'sys_file' }); + // …and nothing about `doc`, `attachment`, or the un-hydrated answer that + // was nevertheless returned to the caller. That gap is this issue. + expect(JSON.stringify(meta)).not.toMatch(/doc|attachment/); + }); + }); + + // ---------------------------------------------------------------- outage -- + + describe('read outage → exactly one `warn` that names the loss', () => { + for (const [label, make] of OUTAGE_ERRORS) { + it(`warns exactly once — ${label}`, async () => { + await boot(async () => { + throw make(); + }); + + await engine.find('doc', {} as any); + + expect(logger.lines.warn).toHaveLength(1); + // Functional degradation, not durability: nothing here claims to have + // persisted anything, so escalating to `error` would be the + // mirror-image failure AGENTS "Degradation log levels" warns about. + // The seam adds no loud line of its own — the single `error` present + // is the generic read handler's, pinned in its own block above. + expect(logger.lines.error).toHaveLength(1); + expect(logger.lines.error[0].msg).toBe('Find operation failed'); + expect(logger.lines.fatal).toHaveLength(0); + }); + } + + it('the line carries what a reader needs: object, fields, consequence, fix', async () => { + await boot(async () => { + throw Object.assign(new Error('canceling statement due to statement timeout'), { code: '57014' }); + }); + + await engine.find('doc', {} as any); + + const [line] = logger.lines.warn; + // The CONSEQUENCE, spelled out — this is the whole point of the issue: + // the reader must learn that bare ids will read as "no file". + expect(line.msg).toMatch(/sys_file/); + expect(line.msg).toMatch(/no file/i); + // …and the FIX, so the line is actionable rather than a bare complaint. + expect(line.msg).toMatch(/availability/i); + // The locus the generic line above cannot give: which PARENT object and + // which fields were left un-hydrated, and how many ids went unresolved. + expect(metaOfWarn(line)).toMatchObject({ object: 'doc', fields: ['attachment'], unresolvedIds: 1 }); + // The driver's own diagnosis, not a synthesized one. + expect(String(metaOfWarn(line).error)).toMatch(/statement timeout/); + }); + + it('says it ONCE per read, not once per record or per id', async () => { + const { storeFor } = await boot(async () => { + throw Object.assign(new Error('Connection terminated unexpectedly'), { code: '08006' }); + }); + storeFor('doc').set('d2', { id: 'd2', title: 'Invoice', attachment: 'f_zz11yy22xx' }); + storeFor('doc').set('d3', { id: 'd3', title: 'Receipt', attachment: 'f_aa33bb44cc' }); + + const rows = await engine.find('doc', {} as any); + + expect(rows).toHaveLength(3); + // One batched lookup fails once, so it is reported once — AGENTS: "Say it + // once, at the first degradation, not once per failed write." + expect(logger.lines.warn).toHaveLength(1); + expect(metaOfWarn(logger.lines.warn[0])).toMatchObject({ unresolvedIds: 3 }); + }); + }); + + // ----------------------------------------------------------- no false red -- + + describe('the healthy path stays silent', () => { + it('hydrates and logs nothing when the lookup succeeds', async () => { + await boot(async () => [ + { id: FILE_ID, name: 'contract.pdf', size: 1024, mime_type: 'application/pdf', status: 'committed' }, + ]); + + const rows = await engine.find('doc', {} as any); + + expect(rows[0].attachment).toMatchObject({ + id: FILE_ID, + name: 'contract.pdf', + size: 1024, + mimeType: 'application/pdf', + }); + expect(logger.lines.warn).toHaveLength(0); + }); + + it('an id with no committed row is a MISS, not a fault — still silent', async () => { + await boot(async () => []); + + const rows = await engine.find('doc', {} as any); + + // A clean miss (the read happened, nothing matched) is legitimate absent + // data — exactly the fact the outage branch must not be confused with. + // It keeps the raw id and stays quiet. + expect(rows[0].attachment).toBe(FILE_ID); + expect(logger.lines.warn).toHaveLength(0); + }); + }); +}); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 76f613e3f3..9458ffec55 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -4796,8 +4796,42 @@ export class ObjectQL implements IObjectQLEngine { 'sys_file', { where: { id: { $in: uniqueIds } }, context: { ...(execCtx ?? {}), __expandRead: true } as ExecutionContext }, )) ?? []; - } catch { - return records; // sys_file unregistered / unreadable — leave ids as-is + } catch (error) { + // [#6116] Fail-open is deliberate and UNCHANGED — a file-metadata read + // that fails must not take down the record read that asked for it, so the + // ids pass through un-hydrated on BOTH branches below. What changes is + // that the two reasons stop being the same silence. + // + // Benign: `sys_file` is registered but its TABLE was never provisioned + // (storage plugin present, schema sync not run yet). There are genuinely + // no committed rows, so "leave the ids as-is" IS the truth and there is + // nothing to report. Discriminated through the shared `isMissingTableError` + // predicate (`@objectstack/metadata/errors`, #4825) — never a hand-rolled + // `code === '42P01'` copy — the same call `seedAutonumber` makes above. + // + // Everything else (connection drop, timeout, permission denial, query + // error) means the rows may well exist and simply were not seen. The + // consumer then receives a bare id where `{ id, name, size, mimeType, + // url }` was due, and UI/export renders it as "this record has no + // attachment": a fault wearing the appearance of legitimate absent data, + // indistinguishable from a record that truly holds no file (ADR-0110 D3). + // One `warn`, not `error`, per AGENTS "Degradation log levels" — the loss + // is FUNCTIONAL and scoped to this response (the answer is visibly + // smaller, and the next read repairs it); nothing on this path claims to + // have persisted anything. + if (!isMissingTableError(error)) { + this.logger.warn( + 'sys_file lookup failed; file fields keep their raw ids and will render as "no file" for this read — ' + + 'check storage/database availability, then re-read to hydrate', + { + object: objectName, + fields: fileFields, + unresolvedIds: uniqueIds.length, + error: (error as Error)?.message, + }, + ); + } + return records; // fail-open: leave ids as-is } const fileMap = new Map(); From 4e99a8cda0d74fcfac1385f8b0745e32221a095a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 21:56:52 +0000 Subject: [PATCH 2/2] =?UTF-8?q?test(objectql):=20=E7=BB=99=20hydrate=20?= =?UTF-8?q?=E6=95=85=E9=9A=9C=E6=B5=8B=E8=AF=95=E7=9A=84=20query=20options?= =?UTF-8?q?=20=E4=B8=8A=E7=B1=BB=E5=9E=8B,=E4=B8=8D=E6=93=A6=E6=88=90=20an?= =?UTF-8?q?y=20(#6116)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI 的 `check:query-options-erasure` 棘轮判红:新测试把 test 面从 263 抬到 273 —— 10 处 `engine.find('doc', {} as any)` / `findOne(..., { … } as any)`。 这些入参本来就在契约内,不属于「故意越界的拒绝测试」,所以按棘轮给的第一条 remedy 处理:上类型,而不是 `as unknown as EngineQueryOptionsParsed`。 `find` / `findOne` 的 query 形参本身可选,`{}` 直接省略即可;findOne 的 `{ where: { id: 'd1' } }` 去掉断言后原样通过 `EngineQueryOptionsParsed`。 test 面回到上限 263,断言与用例数不变(21 例仍全绿)。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We --- .../src/engine-file-hydrate-outage.test.ts | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/objectql/src/engine-file-hydrate-outage.test.ts b/packages/objectql/src/engine-file-hydrate-outage.test.ts index bbc2d15518..c996b8dc1b 100644 --- a/packages/objectql/src/engine-file-hydrate-outage.test.ts +++ b/packages/objectql/src/engine-file-hydrate-outage.test.ts @@ -227,7 +227,7 @@ describe('sys_file hydrate read fault — distinguishable from "no file" (#6116) throw make(); }); - const rows = await engine.find('doc', {} as any); + const rows = await engine.find('doc'); // The read that ASKED for the file still answers. This is the pin that // makes #6116 a diagnosability fix and not a behaviour change: if a @@ -243,7 +243,7 @@ describe('sys_file hydrate read fault — distinguishable from "no file" (#6116) throw Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:5432'), { code: 'ECONNREFUSED' }); }); - const row = await engine.findOne('doc', { where: { id: 'd1' } } as any); + const row = await engine.findOne('doc', { where: { id: 'd1' } }); expect(row?.attachment).toBe(FILE_ID); }); @@ -258,7 +258,7 @@ describe('sys_file hydrate read fault — distinguishable from "no file" (#6116) throw make(); }); - await engine.find('doc', {} as any); + await engine.find('doc'); // There are genuinely no committed rows, so the un-hydrated answer IS // the truth and there is nothing to report. Reporting it anyway would @@ -293,7 +293,7 @@ describe('sys_file hydrate read fault — distinguishable from "no file" (#6116) await boot(async () => { throw make(); }); - await engine.find('doc', {} as any); + await engine.find('doc'); return logger.lines.error.map((l: any) => l.msg); } @@ -313,7 +313,7 @@ describe('sys_file hydrate read fault — distinguishable from "no file" (#6116) throw Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:5432'), { code: 'ECONNREFUSED' }); }); - await engine.find('doc', {} as any); + await engine.find('doc'); // `error(message, error, meta)` — the meta says which object was read… const [, meta] = logger.lines.error[0].args; @@ -333,7 +333,7 @@ describe('sys_file hydrate read fault — distinguishable from "no file" (#6116) throw make(); }); - await engine.find('doc', {} as any); + await engine.find('doc'); expect(logger.lines.warn).toHaveLength(1); // Functional degradation, not durability: nothing here claims to have @@ -352,7 +352,7 @@ describe('sys_file hydrate read fault — distinguishable from "no file" (#6116) throw Object.assign(new Error('canceling statement due to statement timeout'), { code: '57014' }); }); - await engine.find('doc', {} as any); + await engine.find('doc'); const [line] = logger.lines.warn; // The CONSEQUENCE, spelled out — this is the whole point of the issue: @@ -375,7 +375,7 @@ describe('sys_file hydrate read fault — distinguishable from "no file" (#6116) storeFor('doc').set('d2', { id: 'd2', title: 'Invoice', attachment: 'f_zz11yy22xx' }); storeFor('doc').set('d3', { id: 'd3', title: 'Receipt', attachment: 'f_aa33bb44cc' }); - const rows = await engine.find('doc', {} as any); + const rows = await engine.find('doc'); expect(rows).toHaveLength(3); // One batched lookup fails once, so it is reported once — AGENTS: "Say it @@ -393,7 +393,7 @@ describe('sys_file hydrate read fault — distinguishable from "no file" (#6116) { id: FILE_ID, name: 'contract.pdf', size: 1024, mime_type: 'application/pdf', status: 'committed' }, ]); - const rows = await engine.find('doc', {} as any); + const rows = await engine.find('doc'); expect(rows[0].attachment).toMatchObject({ id: FILE_ID, @@ -407,7 +407,7 @@ describe('sys_file hydrate read fault — distinguishable from "no file" (#6116) it('an id with no committed row is a MISS, not a fault — still silent', async () => { await boot(async () => []); - const rows = await engine.find('doc', {} as any); + const rows = await engine.find('doc'); // A clean miss (the read happened, nothing matched) is legitimate absent // data — exactly the fact the outage branch must not be confused with.