Skip to content

Commit afb83d3

Browse files
os-zhuangclaude
andauthored
refactor(objectql): update 的三分支派发抽成生产者侧唯一判定 + 门禁 update 切片 (#5480) (#5754)
* refactor(objectql): extract ObjectQL.update's dispatch into a shared producer-side predicate (#5480) `delete` has had one since #4550 (`resolveEngineDeleteDispatch`), so any test double standing in for the engine can import the producer's own decision and cannot be looser than it. `update`'s identical three-way dispatch was an inline literal throw in `engine.ts` with no exported constant and no reusable function — so #5393 could pin its fake's `delete` to the producer and structurally could not do the same for `update`, on a verb whose predicate branch rewrites every matching row's fields. - new `packages/objectql/src/engine-update-dispatch.ts`: `resolveEngineUpdateDispatch` / `assertEngineUpdateDispatch` / `scalarUpdateId` / `ENGINE_UPDATE_REJECT_MESSAGE` / `ENGINE_UPDATE_DISPATCH_CASES`, all exported from `@objectstack/objectql`. - `ObjectQL.update` now uses it — producer and predicate are one copy. Behaviour-preserving: the three branches, the `$in` predicate test and the reject message text are unchanged. Two places where update is not delete are copied verbatim rather than "improved", and documented as such: `data.id` is taken without a scalar test and outranks both `where` and `multi` (objectstack#5748), and the branch reads truthiness, so `where: { id: 0 }` is not an identifying call. `engine-update-dispatch.test.ts` drives the REAL engine with a recording driver over the case set and asserts observed behaviour equals the predicate's verdict case by case — verified non-vacuous by perturbing the engine's own branch, which turns the `real ObjectQL.update` half red while the predicate half stays green. check-engine-double-contract grows an `update` slice on the same criterion: ledger entries are now keyed on (file, verb) with a required `verb` and a new DECLARED invariant, the driver-only-member veto applies at every arity (a fake driver spelling its key `_i` had 19 of its doubles read as engine doubles), and the self-test covers the update slice on both sides of every decision including cross-slice credit. Measured: delete unchanged at 108 doubles / 91 files / 27 pinned; update 126 doubles / 110 files, 10 pinned (objectql's own fakes), 116 in the shrink-only ledger. Fixes #5480 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V7WetGmnfoXNn8cLieKKmx * test(scripts): ledger the update double #5738 landed after this slice was measured (#5480) Base crossing, not a regression on either side. `packages/services/service-automation/src/plugin-startup-log-cause.test.ts` arrived on main in #5738 (from #5661) after this branch measured the update slice and while main still had no update slice at all — so its own CI could not have flagged its fake engine's `update`, and this ledger could not have recorded it. It becomes a violation only on the merge ref, where the new slice and the new file meet for the first time. Re-measured on the merge with origin/main rather than transcribed: 1 unguarded engine double at line 170. The package already carries @objectstack/objectql in devDependencies, so the pin is one line, but the file is under `packages/services/**` — outside this PR's scope — so it goes to the services lane's batch alongside the `crud-bulk-intent.test.ts` upgrade step 3 leaves it. Like every other update entry, it claims no dormancy probe. Verified on the merged tree: engine-double-contract OK (self-test + run), delete unchanged at 108 doubles / 91 files / 27 pinned with zero RECONCILED, update 127 / 111 with 10 pinned and 117 ledgered; query-options-erasure ratchet holds; check-nul-bytes clean; @objectstack/objectql typecheck clean and 124 files / 2044 tests green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V7WetGmnfoXNn8cLieKKmx --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent dde9202 commit afb83d3

17 files changed

Lines changed: 1822 additions & 150 deletions
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
`ObjectQL.update` 的三分支派发抽成生产者侧唯一判定 `engine-update-dispatch.ts`
6+
7+
`delete` 的派发决策自 #4550 起就是一份共享判定(`resolveEngineDeleteDispatch`),任何顶替引擎的测试替身都能 import 它,因此结构上不可能比引擎更宽松。`update` 的同款三分支——标量 `where.id` → 按 id;`options.multi` → `driver.updateMany`;否则抛错——此前只是 `engine.ts` 里的一个内联字面量 throw,既没有导出的常量也没有可复用的函数。后果不是理论上的:#5393 给 flow 的 `update_record` / `delete_record` 补真实契约测试时,delete 侧能把假引擎钉死在生产者契约上,update 侧只能退而断言执行器交出的 options 包,并在文件头写明「不对引擎会不会接受它发表第二份意见」——因为唯一的替代做法是在 fake 里手抄一遍判定,而手抄必然漏掉 `where: { id: { $in: [...] } }` 看着像 id 实为谓词这一半(#4434 正是这样带着全绿的测试发布了一条对每个调用者都回 500 的路由)。同一个执行器的两个写入动词,一个能被绑定到生产者契约、另一个结构上不能,而谓词 update 的破坏性并不低——它覆盖每一行匹配记录的字段。
8+
9+
本次新增:
10+
11+
- `packages/objectql/src/engine-update-dispatch.ts`,导出 `resolveEngineUpdateDispatch` / `assertEngineUpdateDispatch` / `scalarUpdateId` / `ENGINE_UPDATE_REJECT_MESSAGE` / `ENGINE_UPDATE_DISPATCH_CASES`,均从 `@objectstack/objectql` 公开导出;
12+
- `ObjectQL.update` **自身改用它**——生产者与判定必须是同一份,否则只是第二份副本。
13+
14+
这是**行为保持的重构**:三分支语义、`$in` 谓词判定、拒绝消息文本一字未改(`Update requires an ID or options.multi=true`,现在是导出常量 `ENGINE_UPDATE_REJECT_MESSAGE`)。判定里有两处刻意照抄而非「改良」了生产者的现状,并在模块头与测试中写明:
15+
16+
1. `data.id` **不做标量测试**,只要为真就直接作为 id,且优先于 `where``multi`;
17+
2. 分支按**真值**而非 `!== undefined`,所以 `where: { id: 0 }` 不走按 id 路径。
18+
19+
比生产者更「聪明」的判定就是第二份意见,正是 #4550 消除的东西;这两点该改的时候会在两个文件里一起改,现在那是一次编辑而不是两次。
20+
21+
新测试 `engine-update-dispatch.test.ts` 不去对照写在旁边的期望表,而是用记录型 driver 驱动**真实引擎**跑完 `ENGINE_UPDATE_DISPATCH_CASES`,逐例断言引擎的实际行为等于判定的裁决——两半唯一同处一室的地方。
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// objectstack#5480 — the shared update-dispatch predicate must be the REAL
4+
// engine's answer, not a second opinion that happens to agree today.
5+
//
6+
// Exactly the argument `engine-delete-dispatch.test.ts` makes for `delete`,
7+
// and it matters more here rather than less: a shared predicate that drifted
8+
// from `ObjectQL.update` would make every fake engine pinned to it confidently,
9+
// uniformly wrong, while the gate over them reported success. So this file does
10+
// not test the predicate against a table of expectations written next to it. It
11+
// drives the **real engine** with a recording driver over
12+
// `ENGINE_UPDATE_DISPATCH_CASES` and asserts the engine's observed behaviour
13+
// equals the predicate's verdict, case by case.
14+
//
15+
// If someone changes the dispatch rule in `engine.ts` without changing
16+
// `engine-update-dispatch.ts`, this goes red here — the one place where both
17+
// halves are in the room together.
18+
19+
import { describe, it, expect } from 'vitest';
20+
import { ObjectQL } from './engine.js';
21+
import {
22+
ENGINE_UPDATE_DISPATCH_CASES,
23+
ENGINE_UPDATE_REJECT_MESSAGE,
24+
resolveEngineUpdateDispatch,
25+
assertEngineUpdateDispatch,
26+
scalarUpdateId,
27+
} from './engine-update-dispatch.js';
28+
29+
/** Records which driver entry point the engine chose, if any. */
30+
function makeRecordingDriver() {
31+
const calls: Array<{ fn: 'update' | 'updateMany'; arg: unknown }> = [];
32+
const driver: any = {
33+
name: 'recording',
34+
version: '0.0.0',
35+
supports: {},
36+
async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; },
37+
async find() { return []; },
38+
async findOne() { return null; },
39+
async create(_o: string, data: Record<string, unknown>) { return { id: 'r1', ...data }; },
40+
async update(_o: string, id: string, data: Record<string, unknown>) { calls.push({ fn: 'update', arg: id }); return { id, ...data }; },
41+
async updateMany(_o: string, ast: unknown) { calls.push({ fn: 'updateMany', arg: ast }); return 0; },
42+
async delete() { return true; },
43+
async deleteMany() { return 0; },
44+
async count() { return 0; },
45+
async bulkCreate() { return []; }, async bulkUpdate() { return []; }, async bulkDelete() {},
46+
async beginTransaction() { return { __trx: true, commit: async () => {}, rollback: async () => {} }; },
47+
async commit() {}, async rollback() {},
48+
};
49+
return { driver, calls };
50+
}
51+
52+
async function makeEngine() {
53+
const engine = new ObjectQL();
54+
const { driver, calls } = makeRecordingDriver();
55+
engine.registerDriver(driver, true);
56+
await engine.init();
57+
engine.registry.registerObject({
58+
name: 'task',
59+
fields: { title: { type: 'text' }, tenant: { type: 'text' } },
60+
} as any);
61+
return { engine, calls };
62+
}
63+
64+
/** What the real engine actually did with this `(data, options)` pair. */
65+
async function observeEngine(data: unknown, options: unknown): Promise<'by-id' | 'multi' | 'reject'> {
66+
const { engine, calls } = await makeEngine();
67+
try {
68+
await engine.update('task', data as any, options as any);
69+
} catch (e) {
70+
if ((e as Error).message === ENGINE_UPDATE_REJECT_MESSAGE) return 'reject';
71+
throw e;
72+
}
73+
if (calls.length !== 1) {
74+
throw new Error(`expected exactly one driver call, saw ${JSON.stringify(calls)}`);
75+
}
76+
return calls[0].fn === 'update' ? 'by-id' : 'multi';
77+
}
78+
79+
describe('engine update dispatch — the shared predicate IS the engine (#5480)', () => {
80+
it('has cases on both sides of the guard (an empty or one-sided set proves nothing)', () => {
81+
const kinds = new Set(ENGINE_UPDATE_DISPATCH_CASES.map((c) => c.expect));
82+
expect(kinds).toEqual(new Set(['by-id', 'multi', 'reject']));
83+
expect(ENGINE_UPDATE_DISPATCH_CASES.filter((c) => c.expect === 'reject').length).toBeGreaterThan(3);
84+
});
85+
86+
for (const c of ENGINE_UPDATE_DISPATCH_CASES) {
87+
it(`real engine agrees with the predicate: ${c.what}${c.expect}`, async () => {
88+
expect(resolveEngineUpdateDispatch(c.data, c.options).kind, 'predicate').toBe(c.expect);
89+
expect(await observeEngine(c.data, c.options), 'real ObjectQL.update').toBe(c.expect);
90+
});
91+
}
92+
93+
it('rejects with the exact message a fake must reproduce', () => {
94+
expect(() => assertEngineUpdateDispatch({ title: 'x' }, { where: { tenant: 't1' } }))
95+
.toThrow(ENGINE_UPDATE_REJECT_MESSAGE);
96+
// …and returns the dispatch (never `reject`) when the call is legal.
97+
expect(assertEngineUpdateDispatch({ title: 'x' }, { where: { id: 'a' } })).toEqual({ kind: 'by-id', id: 'a' });
98+
expect(assertEngineUpdateDispatch({ id: 'a' }, undefined)).toEqual({ kind: 'by-id', id: 'a' });
99+
expect(assertEngineUpdateDispatch({ title: 'x' }, { multi: true })).toEqual({ kind: 'multi' });
100+
});
101+
102+
it('scalarUpdateId treats operator objects and arrays as predicates, not ids', () => {
103+
expect(scalarUpdateId({ where: { id: 'a' } })).toBe('a');
104+
expect(scalarUpdateId({ where: { id: 7 } })).toBe(7);
105+
expect(scalarUpdateId({ where: { id: { $in: ['a'] } } })).toBeUndefined();
106+
expect(scalarUpdateId({ where: { id: ['a'] } })).toBeUndefined();
107+
expect(scalarUpdateId({ where: { id: null } })).toBeUndefined();
108+
expect(scalarUpdateId({ where: {} })).toBeUndefined();
109+
expect(scalarUpdateId(undefined)).toBeUndefined();
110+
});
111+
112+
// ── The two places `update` is NOT `delete`. Both are pinned here rather
113+
// than left to the reader, because they are exactly what a hand-copied
114+
// guard gets wrong in the OTHER direction: too strict, and the double
115+
// then refuses a call the producer accepts.
116+
it('data.id outranks where and multi, and is NOT scalar-tested (the producer\'s rule, verbatim)', () => {
117+
expect(resolveEngineUpdateDispatch({ id: 'rec_1' }, { where: { id: { $in: ['a'] } }, multi: true }))
118+
.toEqual({ kind: 'by-id', id: 'rec_1' });
119+
// An operator object parked in the PAYLOAD is taken as an id — the engine
120+
// does exactly this today, so the predicate must say so too. Improving on
121+
// the producer here would make this module a second opinion, which is the
122+
// thing #4550 removed. Tracked as #5748; when it is fixed it is fixed in
123+
// both files at once, which is now one edit instead of two, and this
124+
// assertion is what tells the next author to turn BOTH halves over.
125+
const operatorInPayload = resolveEngineUpdateDispatch({ id: { $in: ['a', 'b'] } }, { multi: true });
126+
expect(operatorInPayload.kind).toBe('by-id');
127+
});
128+
129+
it('branches on TRUTHINESS, so a falsy scalar id does not identify a row', () => {
130+
expect(resolveEngineUpdateDispatch({ title: 'x' }, { where: { id: 0 } }).kind).toBe('reject');
131+
expect(resolveEngineUpdateDispatch({ title: 'x' }, { where: { id: '' } }).kind).toBe('reject');
132+
expect(resolveEngineUpdateDispatch({ id: 0, title: 'x' }, { multi: true }).kind).toBe('multi');
133+
// …while `scalarUpdateId` still reports the raw scalar it found. The two
134+
// answer different questions and only `resolveEngineUpdateDispatch`
135+
// answers the engine's.
136+
expect(scalarUpdateId({ where: { id: 0 } })).toBe(0);
137+
});
138+
139+
it('reads data UNGUARDED, exactly like the producer', () => {
140+
// `ObjectQL.update` opens with `data.id`, so a missing payload is a
141+
// TypeError there. A double kinder than the producer about it would hide
142+
// the producer's behaviour.
143+
expect(() => resolveEngineUpdateDispatch(undefined as any, { multi: true })).toThrow(TypeError);
144+
});
145+
});

0 commit comments

Comments
 (0)