Skip to content

Commit e7fc6e7

Browse files
committed
refactor(metadata-core,objectql): sink both write-verb dispatch predicates into metadata-core (#5619)
Move `engine-delete-dispatch.ts` (#4550) and `engine-update-dispatch.ts` (#5480) from `packages/objectql/src/` to `packages/metadata-core/src/` unchanged, and keep the original objectql paths as re-export shims so no caller, no public export and no pinned call site moves. `@objectstack/objectql` depends on `@objectstack/metadata-protocol`, so that package's 13 fake engines could not import either predicate without closing a dependency cycle turbo 2.10.7 refuses outright. `@objectstack/metadata-core` is a package both sides already depend on and which depends on neither, so the sink is the one route that pins those doubles without inventing an edge. - 13 metadata-protocol test files: both fake write verbs now open with the producer's own predicate, imported from `@objectstack/metadata-core`. - 26 (file, verb) ledger entries deleted from `scripts/engine-double-contract.baseline.json`; the gate's counts move from 37 pinned / 165 ledger / 2 exempt to 63 pinned / 139 ledger / 2 exempt. - The gate's two slices now accept `@objectstack/metadata-core` alongside `@objectstack/objectql`, and its remediation message names the right one. - Six ledger entries whose `closes` prescribed exactly this sink ("tracked as #5619") are rewritten: the blocker is gone, what remains is the one-line pin, tracked as #5855. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We
1 parent d93080d commit e7fc6e7

21 files changed

Lines changed: 790 additions & 634 deletions
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
"@objectstack/metadata-core": patch
3+
"@objectstack/objectql": patch
4+
---
5+
6+
两个写动词的派发判定下沉到 `@objectstack/metadata-core` —— 公共 API 零变化,一次关闭 26 条 engine-double 基线条目
7+
8+
`ObjectQL.delete` / `ObjectQL.update` 的三分支派发判定(`engine-delete-dispatch.ts` #4550
9+
`engine-update-dispatch.ts` #5480)从 `packages/objectql/src/` **原样搬到**
10+
`packages/metadata-core/src/`。这是一次搬移,不是重构:两个模块本来就零 import、纯自包含,
11+
判定逻辑一个字未改。
12+
13+
**为什么搬。** `@objectstack/objectql``dependencies``@objectstack/metadata-protocol`,
14+
所以那个包里 13 个假引擎结构性地无法 import 这两个谓词 —— 反向 devDependency 即成环,
15+
turbo 2.10.7 直接拒绝任务图。判据来自门禁台账里
16+
`packages/spec/src/contracts/data-engine.test.ts` 那条 EXEMPT:反向 import 不可行时,唯一
17+
出路是下沉到**两边都已依赖**的包。`@objectstack/metadata-core` 正是这个包
18+
(`objectql -> metadata-core``metadata-protocol -> metadata-core` 都是既有边),而它自己
19+
`dependencies` 只有 `{ @objectstack/spec, zod }`,不含 objectql,故不引入新环。
20+
21+
**公共 API 与既有调用点零变化。** `packages/objectql/src/engine-delete-dispatch.ts` /
22+
`engine-update-dispatch.ts` 保留在原路径,改为 re-export shim,因此
23+
`@objectstack/objectql` 仍然导出
24+
`resolveEngineDeleteDispatch` / `assertEngineDeleteDispatch` / `scalarDeleteId` /
25+
`ENGINE_DELETE_REJECT_MESSAGE` / `ENGINE_DELETE_DISPATCH_CASES` 及 update 侧的五个同名对应物
26+
(与全部类型),`engine.ts` 与 37 个既有 pinned 调用点一行未动。同一批符号现在也从
27+
`@objectstack/metadata-core` 导出。
28+
29+
搭配的门禁改动:`scripts/check-engine-double-contract.mjs` 的两个 slice 现在同时接受
30+
`@objectstack/metadata-core``@objectstack/objectql` 两种拼写(它们指向同一个函数),
31+
失败提示也改为在「objectql 依赖该包」时优先建议 metadata-core。
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The **one** answer to "what does `ObjectQLEngine.delete` do with this call?"
5+
* — extracted so that the engine and every test double that stands in for it
6+
* read the same predicate rather than two hand-written approximations of it
7+
* (objectstack#4550, from objectstack#4434).
8+
*
9+
* ## Why this is a shared module and not four lines inside `engine.ts`
10+
*
11+
* `#4434` shipped green. `DELETE /api/v1/sharing/rules/:idOrName` answered 500
12+
* for **both** address forms the route advertises, for every rule, from the day
13+
* it was written — and `plugin-sharing`'s `deleteRule drops rule + all its
14+
* grants` test asserted success against it the whole time. The route was not
15+
* untested; it was tested against a **fake engine whose `delete` accepted a
16+
* call the real engine refuses**. A predicate-shaped purge of
17+
* `sys_record_share` (no scalar `where.id`, no `options.multi`) is precisely
18+
* the one shape `delete()` throws on, and the fake happily deleted by
19+
* predicate.
20+
*
21+
* The fix for #4434 mirrored the guard into that fake by hand. That closes one
22+
* fake and starts a second copy of the contract — the failure mode this module
23+
* exists to remove. A double that *imports the producer's own decision* cannot
24+
* be looser than the producer, ever, which is the property the gate wants and
25+
* the property a copy can only have until someone edits one side.
26+
*
27+
* Same reasoning as `packages/spec/src/data/*-conformance.ts` for drivers, and
28+
* the same shape as objectstack#4455: **the scan and the validator must answer
29+
* with one predicate.**
30+
*
31+
* ## Why this module lives in `@objectstack/metadata-core` and not in `objectql`
32+
*
33+
* It was written in `packages/objectql/src/` next to its only production caller
34+
* (objectstack#4550) and moved here unchanged by objectstack#5619 — a **move**,
35+
* not a rewrite: not one line of the predicate below differs from the version
36+
* `ObjectQL.delete` has been dispatching on since #4550.
37+
*
38+
* The move is what made a whole package's doubles pinnable. Thirteen fake
39+
* engines in `@objectstack/metadata-protocol` were structurally unable to reach
40+
* this predicate: `@objectstack/objectql` **depends on**
41+
* `@objectstack/metadata-protocol`, so the import a pin needs would have closed
42+
* a cycle — measured, not assumed, on turbo 2.10.7:
43+
*
44+
* ```
45+
* WARNING Circular package dependency detected: @objectstack/objectql, @objectstack/metadata-protocol
46+
* x Cyclic dependency detected:
47+
* | @objectstack/objectql#build, @objectstack/metadata-protocol#build
48+
* ```
49+
*
50+
* When a reverse import is impossible, the only honest way out is to sink the
51+
* predicate into a package **both sides already depend on** — the criterion
52+
* `packages/spec/src/contracts/data-engine.test.ts`'s EXEMPT entry in the gate's
53+
* ledger states. `@objectstack/metadata-core` is exactly that package:
54+
* `objectql -> metadata-core` and `metadata-protocol -> metadata-core` both
55+
* pre-date this change, and this package's own dependencies are
56+
* `{ @objectstack/spec, zod }` — no `objectql`, so no new edge and no new cycle.
57+
* This module importing nothing at all is what makes that free.
58+
*
59+
* `@objectstack/objectql` re-exports every symbol below from its original path,
60+
* so the 24+ call sites already pinned to it, and the public API, are unchanged.
61+
*
62+
* ## The contract, normatively
63+
*
64+
* `delete(object, options)` dispatches on exactly one question — *does this
65+
* call identify a single row by primary key?*
66+
*
67+
* - `options.where.id` is a **scalar** (`string` / `number` / `bigint`, not
68+
* `null`) → `by-id`: routes to `driver.delete`, runs cascade-delete and the
69+
* by-id RLS pre-image check.
70+
* - otherwise, `options.multi` is truthy → `multi`: routes to
71+
* `driver.deleteMany` with the middleware-composed AST.
72+
* - otherwise → **`reject`**. The call names neither one row nor a bulk
73+
* intent, and the engine throws rather than guessing.
74+
*
75+
* The scalar test is load-bearing and is the half a hand-written double most
76+
* often drops: `where: { id: { $in: [...] } }` is a *multi-row predicate*, not
77+
* an id. Treating it as an id would bind the operator object literally into
78+
* `driver.delete(object, {$in: […]})` **and** skip both the row-scoping AST
79+
* seeding (#2982) and the by-id pre-image check. So it is `reject` unless the
80+
* caller also said `multi`.
81+
*
82+
* @see ObjectQL.delete in `packages/objectql/src/engine.ts` — the only production caller.
83+
* @see packages/objectql/src/engine-delete-dispatch.ts — the re-export shim that keeps
84+
* objectql's original import path (and its public API) working.
85+
* @see packages/objectql/src/engine-delete-dispatch.test.ts — the test that drives the
86+
* REAL engine over `ENGINE_DELETE_DISPATCH_CASES`; it stays in objectql because it
87+
* needs `ObjectQL`, which this package must never depend on.
88+
* @see scripts/check-engine-double-contract.mjs — the gate that keeps doubles on it.
89+
*/
90+
91+
/** The message `delete()` throws when a call identifies neither one row nor a bulk intent. */
92+
export const ENGINE_DELETE_REJECT_MESSAGE = 'Delete requires an ID or options.multi=true';
93+
94+
/** What `ObjectQLEngine.delete` will do with a given options bag. */
95+
export type EngineDeleteDispatch =
96+
/** A scalar `where.id` — `driver.delete`, cascade + by-id RLS pre-image. */
97+
| { readonly kind: 'by-id'; readonly id: string | number | bigint }
98+
/** No single id but `options.multi` — `driver.deleteMany` with the composed AST. */
99+
| { readonly kind: 'multi' }
100+
/** Neither — the engine throws `ENGINE_DELETE_REJECT_MESSAGE`. */
101+
| { readonly kind: 'reject'; readonly message: string };
102+
103+
/** The subset of `EngineDeleteOptions` the dispatch decision actually reads. */
104+
export interface EngineDeleteDispatchInput {
105+
readonly where?: unknown;
106+
readonly multi?: unknown;
107+
readonly [k: string]: unknown;
108+
}
109+
110+
/**
111+
* Extract the SCALAR `where.id`, or `undefined` when the call does not name one
112+
* row by primary key.
113+
*
114+
* `null`, `undefined`, arrays, and operator objects (`{ $in: [...] }`,
115+
* `{ $ne: … }`) all yield `undefined` — they are predicates over many rows, not
116+
* a primary key.
117+
*/
118+
export function scalarDeleteId(
119+
options?: EngineDeleteDispatchInput | null,
120+
): string | number | bigint | undefined {
121+
const where = options?.where;
122+
if (!where || typeof where !== 'object') return undefined;
123+
if (!('id' in (where as Record<string, unknown>))) return undefined;
124+
const whereId = (where as Record<string, unknown>).id;
125+
const t = typeof whereId;
126+
if (whereId !== null && (t === 'string' || t === 'number' || t === 'bigint')) {
127+
return whereId as string | number | bigint;
128+
}
129+
return undefined;
130+
}
131+
132+
/**
133+
* Decide what `ObjectQLEngine.delete` does with `options`, without doing it.
134+
*
135+
* Pure and side-effect free, so a test double can call it to *classify* a call
136+
* and then implement `by-id` / `multi` however its fixture stores rows — while
137+
* being bound to the real engine's `reject` surface for free.
138+
*/
139+
export function resolveEngineDeleteDispatch(
140+
options?: EngineDeleteDispatchInput | null,
141+
): EngineDeleteDispatch {
142+
const id = scalarDeleteId(options);
143+
if (id !== undefined) return { kind: 'by-id', id };
144+
if (options?.multi) return { kind: 'multi' };
145+
return { kind: 'reject', message: ENGINE_DELETE_REJECT_MESSAGE };
146+
}
147+
148+
/**
149+
* Throw exactly what `ObjectQLEngine.delete` throws when a call is neither
150+
* `by-id` nor `multi`; return the resolved dispatch otherwise.
151+
*
152+
* This is the line a fake engine's `delete` opens with. One call pins the fake
153+
* to the producer's rejection surface, and — unlike a mirrored `if` — it cannot
154+
* drift when the producer's rule changes.
155+
*
156+
* ```ts
157+
* async delete(object: string, options?: any) {
158+
* assertEngineDeleteDispatch(options); // refuses what a real server refuses
159+
* …
160+
* }
161+
* ```
162+
*/
163+
export function assertEngineDeleteDispatch(
164+
options?: EngineDeleteDispatchInput | null,
165+
): Exclude<EngineDeleteDispatch, { kind: 'reject' }> {
166+
const dispatch = resolveEngineDeleteDispatch(options);
167+
if (dispatch.kind === 'reject') throw new Error(dispatch.message);
168+
return dispatch;
169+
}
170+
171+
/**
172+
* The shared conformance case-set for the delete dispatch — the same role
173+
* `packages/spec/src/data/*-conformance.ts` plays for drivers.
174+
*
175+
* Every case names a call shape and the verdict the **real engine** gives it.
176+
* A double proved against these is proved against the producer, including the
177+
* three shapes that look like an id and are not.
178+
*/
179+
export interface EngineDeleteDispatchCase {
180+
/** What the shape is, in the words a failure message should use. */
181+
readonly what: string;
182+
/** The options bag handed to `delete(object, options)`. */
183+
readonly options: EngineDeleteDispatchInput | undefined;
184+
/** The verdict the engine gives it. */
185+
readonly expect: EngineDeleteDispatch['kind'];
186+
}
187+
188+
export const ENGINE_DELETE_DISPATCH_CASES: readonly EngineDeleteDispatchCase[] = [
189+
{ what: 'scalar string id', options: { where: { id: 'rec_1' } }, expect: 'by-id' },
190+
{ what: 'scalar number id', options: { where: { id: 42 } }, expect: 'by-id' },
191+
{ what: 'scalar id alongside other predicates', options: { where: { id: 'rec_1', tenant: 't1' } }, expect: 'by-id' },
192+
{ what: 'multi with a predicate', options: { where: { rule_id: 'r1' }, multi: true }, expect: 'multi' },
193+
{ what: 'multi with no predicate at all', options: { multi: true }, expect: 'multi' },
194+
{ what: 'multi alongside an $in id set', options: { where: { id: { $in: ['a', 'b'] } }, multi: true }, expect: 'multi' },
195+
// ── The rejects. Everything below is what #4434 shipped against a fake that
196+
// accepted it, and what a running server answers 500 to.
197+
{ what: 'predicate on a non-id column, no multi', options: { where: { rule_id: 'r1' } }, expect: 'reject' },
198+
{ what: '$in over ids, no multi (an operator object is NOT an id)', options: { where: { id: { $in: ['a', 'b'] } } }, expect: 'reject' },
199+
{ what: 'array id, no multi', options: { where: { id: ['a', 'b'] } }, expect: 'reject' },
200+
{ what: 'null id, no multi', options: { where: { id: null } }, expect: 'reject' },
201+
{ what: 'empty where, no multi', options: { where: {} }, expect: 'reject' },
202+
{ what: 'no options at all', options: undefined, expect: 'reject' },
203+
{ what: 'multi explicitly false with a predicate', options: { where: { rule_id: 'r1' }, multi: false }, expect: 'reject' },
204+
];

0 commit comments

Comments
 (0)