Skip to content

Commit 7193160

Browse files
fix(objectql): a data driver receives the declared driver options and nothing else (#30151)
Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么:** 引擎交给数据驱动的选项,现在只含 `DriverOptionsSchema` 声明的键(从 schema 读出,去掉退役墓碑 `timeout`)。`buildDriverOptions` 返回的就是这组键;find、findOne、count、aggregate、insert、update、delete(按 id 与多行)以及原始 `execute` 都把它交给驱动。hook 读到的 `input.options` 不变,仍是调用方的选项袋加上驱动选项(加法视图)。 - **为什么改:** 维护者裁决(选项 A):引擎交给驱动的只有声明的驱动选项,别无其他。沿用 #4400 把引擎选项袋做成封闭契约的做法,往下推一跳到驱动。 - **风险与回滚:** 声明键(`transaction`、`tenantId`、`tenantIds`、`timezone`、`preserveAudit`、`bypassTenantAudit`)原样到达,有对照测试;仓内驱动都只读声明键。若有第三方驱动读未声明的键,它将读不到(仓内没有)。`@objectstack/objectql` patch,spec 只改两处注释。回滚 = revert 本 PR。 - **先看:** ① `packages/objectql/src/engine-driver-options.ts` 的 `pickDeclaredDriverOptions`、`mergeDriverOptionsIntoHookView` 和 `buildDriverOptions` 早返回那几行;② `engine.ts` 里 find / findOne / insert / update / delete 把 `driverOptions` 交给驱动、把合并视图留给 hook 的位置。 - **两分钟验证:** `pnpm --filter @objectstack/objectql exec vitest run --project local src/engine-driver-options.test.ts`,预期 `8 passed`;把 `buildDriverOptions` 里的 `pickDeclaredDriverOptions(base)` 换回 `{ ...base }` 再跑,第二个用例转红。 - **需要你做的:** 无,席位落地。 ## What changes The engine → driver hop is now a closed contract, one hop below the engine option bags #4400 closed: a data driver receives the keys `DriverOptionsSchema` declares and nothing else. - `packages/objectql/src/engine-driver-options.ts` - `declaredDriverOptionKeys()` reads the key set off `DriverOptionsSchema.shape` on first use, minus any member that admits only absence (a `retiredKey()` tombstone, today `timeout`). It is never restated, so a key the spec declares reaches drivers with no edit here. - `pickDeclaredDriverOptions(bag)` cuts a bag to that set (own enumerable keys, values untouched). - `buildDriverOptions` starts from the declared keys of `base` instead of a copy of the whole bag, so every bag it returns is declared-only. A caller's explicit `tenantId` / `transaction` / `bypassTenantAudit` still wins, exactly as before. - `mergeDriverOptionsIntoHookView(callerBag, driverOptions)` builds the `after*` view of `input.options`: the caller's bag with the driver options merged on, additive as the hook PHASE contract states; the caller's own object when nothing is added; and a copy of the driver options when there is no caller bag, so the hooks' view and the driver's bag are never one object. - `packages/objectql/src/engine.ts`: `find`, `findOne`, `insert`, `update` and `delete` keep the built options in a local `driverOptions`, hand THAT to every driver call (including the reads and writes the verb makes along the way), and assign the merged view to `input.options` for hooks. - `packages/objectql/src/engine-execute-transactions.ts`: the raw `execute` door hands its driver the declared keys of its bag. - `packages/objectql/src/engine-top-level.ts`: comment-only. The pass-through keys docblock now says the bag's declared keys are the base of the driver options. - `packages/spec/src/data/hook.zod.ts`, `packages/spec/src/data/driver.zod.ts`: comment-only. The hook PHASE paragraph said the `after*` handler and the driver read one view; it now says the merged view is the hooks' and a driver is handed the declared keys, and it names both `buildDriverOptions` (which produces the driver's set) and `mergeDriverOptionsIntoHookView` (which merges that set into the hook view). `DriverOptionsSchema`'s doc states the hop rule. - `.changeset/driver-options-declared-keys-only.md`: `@objectstack/objectql` patch. ## Tests `packages/objectql/src/engine-driver-options.test.ts` (new, 8 cases), on a recording driver double that captures the options argument as the driver receives it: - `a data driver receives the declared driver options and nothing else` - the hop key set is `DriverOptionsSchema`'s declared keys, and every shape key it leaves out refuses every probe value (a tombstone); - `find`, `findOne`, `insert`, `update` and `delete` (by id and multi-row; `count`, `updateMany`, `deleteMany` asserted reached too) hand the driver only declared option keys; - the raw `execute` door hands the driver only declared option keys. - `control: the declared keys the engine threads still reach the driver`: `transaction`, `tenantId` and `timezone` arrive on every verb; `preserveAudit` and a caller's explicit `bypassTenantAudit` arrive unchanged. - `the after* hook view of input.options stays additive`: an `afterFind` hook still reads the caller's own keys beside the driver options; an `afterUpdate` hook still reads a key the caller passed; the merge helper answers the caller's own bag when it adds nothing, and a copy, never the driver's bag itself, when there is no caller bag. `packages/objectql/src/engine.test.ts`: four expansion sub-read cases hold the driver's received option keys to a subset of `declaredDriverOptionKeys()`; the file-reference case reads the expansion marker at the middleware seam. ## Acceptance notes - **Why `Clause-②: no`, no arm, `Metadata-protocol: none`, `patch`.** No accept set moves: every engine door accepts the option bags it accepted, and `DriverOptionsSchema` is unchanged apart from its doc comment. The keys that stop reaching a driver were never declared on the driver contract (the `DriverOptions` type carries none of them, so a typed driver could not read one without a cast), and no driver in this repository reads one. What a metadata author can write does not change. - **Why the cut is not inside the hook view.** `buildDriverOptions`'s result used to be both the driver's options and the `after*` hook view of `input.options`. Hooks in this repository read caller keys from that view after the merge (an `afterInsert` / `afterUpdate` handler reads one there), and the hook PHASE contract states the merge is additive. So the declared set is what the driver is handed, and hooks keep the additive view; the third test block pins that. - **Undeclared keys are dropped without a sound**, as ruled: the caller's bag was already judged at the engine's own door (#4400), so nothing dropped at the hop is a caller's mistake. A dev-mode warning was considered and not added; the contract review decided against one. - **Bulk `after*` per-row dispatches** read the same merged view as before. - **Which doors admit the schema's other declared keys.** `insert` and the raw `execute` door hand them on when a caller sets them; `find`, `findOne`, `update` and `delete` admit only the six threaded keys at their own door (`ENGINE_DRIVER_PASSTHROUGH_KEYS`), unchanged by this PR. - **Contract review.** PASS with no must-fix; the should-fix items are done at 6aaa035 (see Verification). ## Verification Head: 6aaa035 (the contract-review should-fix round). Every reading below is against that tree. <details><summary>Tests, typecheck, full suite, and the one-shot ablation of the no-bag copy</summary> ``` head 6aaa035, one verify-lock hold (VERDICT command-exit 0) $ pnpm exec turbo run build --filter='@objectstack/objectql...' --concurrency=2 BUILD_EXIT=0 $ pnpm --filter @objectstack/objectql exec vitest run --project local --maxWorkers=2 \ src/engine-driver-options.test.ts src/engine.test.ts src/hook-input-shape-contract.test.ts Test Files 3 passed (3) Tests 160 passed (160) $ pnpm --filter @objectstack/objectql typecheck TYPECHECK_EXIT=0 check:test-typecheck: OK — 39 file(s) / 233 error(s) / 64 pinned signature(s) held $ pnpm --filter @objectstack/objectql test TEST_EXIT=0 Test Files 548 passed (548) Tests 10844 passed (10844) One-shot ablation of the new behaviour (scripts/ablation-replace.mjs, wrap mode): anchor "if (callerBag === null || typeof callerBag !== 'object') return { ...driverOptions };" x1 -> x0 replace "if (callerBag === null || typeof callerBag !== 'object') return driverOptions;" x0 -> x1 blob 08ffacb -> 6a9047dba9d2 (packages/objectql/src/engine-driver-options.ts) × merging adds the driver options, answers the caller's own bag when nothing is added, and never answers the driver's bag itself Test Files 1 failed (1) Tests 1 failed | 7 passed (8) restored: blob == HEAD (08ffacb) and `git diff HEAD` is empty Direction: red, as predicted, on the merge case only. The declared-keys rule itself was ablated once at 7319d7c (replacing the pick with a spread of the whole bag reddens the rule case only, 1 failed | 7 passed), and that line is unchanged since. ``` </details> <details><summary>Derived gate families</summary> ``` head 6aaa035 $ node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack 84 command(s) derived (9 paths vs merge base 1f13eb3; check:stack-collection-maps is new, for engine-top-level.ts) every one run, exit code captured before any pipe: 82 x exit 0 2 x exit 3 (PREREQUISITE NOT MET => NOT MEASURED locally; CI runs both) - pnpm check:dual-build-cjs-loads (needs every workspace package's dist/) - pnpm check:type-check-debt (its own closure build never got the verify lock) $ node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran RAN_FILE Run reconciliation — 84 derived, 82 run, 2 NOT-MEASURED, 0 UNRUN. Repo-wide scans (pnpm lint and the full farm) are CI's. ``` </details> <!-- signed: security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent eca7cd2 commit 7193160

9 files changed

Lines changed: 471 additions & 40 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
fix(objectql): a data driver receives the declared driver options and nothing else
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The engine → driver hop is a closed contract, as the engine's own option bags are: every driver call the engine makes is handed the keys `DriverOptionsSchema` declares and no other key.
11+
12+
- **Which calls.** `find`, `findOne`, `count`, `aggregate`, `insert`, `update` and `delete` (by id and multi-row), the reads and writes those verbs make along the way, and the raw `execute` door.
13+
- **What arrives, unchanged.** `transaction`, `tenantId`, `tenantIds`, `timezone`, `preserveAudit` and `bypassTenantAudit` reach the driver exactly as before, and a caller's explicit value still wins. The schema's other declared keys pass when a caller sets them at a door that admits them, which is `insert` and the raw `execute` door; `find`, `findOne`, `update` and `delete` admit only the six keys above at their own door. The retired `timeout` tombstone is not handed on.
14+
- **What stays with the engine.** Undeclared keys in a caller's engine option bag stay with the engine and are no longer copied onto a driver's options. A driver reads the query from its `query` argument, as the driver contract declares.
15+
- **Hooks are unchanged.** An `after*` hook still reads `input.options` as the caller's bag with the driver options merged on.
16+
- **Who moves.** A driver that read a key `DriverOptionsSchema` does not declare off its options argument: read the query from the `query` argument, or have the key declared on `DriverOptionsSchema` first. No driver in this repository reads one.
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The engine → driver hop is a closed contract: a data driver is handed the
5+
* keys `DriverOptionsSchema` declares and nothing else (the precedent is the
6+
* closed engine option bags of #4400, one hop further down).
7+
*
8+
* The seam under test is the options argument as a driver RECEIVES it, read
9+
* off a recording driver double — the shape `engine-driver-read-scope.test.ts`
10+
* uses, since `@objectstack/objectql` cannot import a driver. Each verb is
11+
* driven with a caller bag that carries its own engine-level keys (`where`,
12+
* `fields`, `limit`, `multi`, a write-observability callback), because a
13+
* caller's bag is what the engine builds a driver's options from.
14+
*
15+
* Both directions are pinned: nothing undeclared arrives (the rule), and every
16+
* declared key the engine threads still arrives with its value (the control),
17+
* so the rule cannot be met by handing the driver nothing. A third block pins
18+
* that the `after*` hook view of `input.options` stays ADDITIVE (HookContext
19+
* PHASE contract) — the cut is the driver's, never the hooks'.
20+
*/
21+
22+
import { describe, it, expect } from 'vitest';
23+
import { DriverOptionsSchema } from '@objectstack/spec/data';
24+
import type { ExecutionContext } from '@objectstack/spec/kernel';
25+
import { ObjectQL } from './engine.js';
26+
import {
27+
declaredDriverOptionKeys,
28+
mergeDriverOptionsIntoHookView,
29+
pickDeclaredDriverOptions,
30+
} from './engine-driver-options.js';
31+
32+
const ORG = 'org_a';
33+
const TZ = 'Asia/Shanghai';
34+
const TRX = { handle: 'trx_1' };
35+
36+
/** The caller every verb runs as: an organization, a timezone and an open unit. */
37+
const CALLER: ExecutionContext = {
38+
userId: 'usr_1',
39+
tenantId: ORG,
40+
timezone: TZ,
41+
transaction: TRX,
42+
} as ExecutionContext;
43+
44+
interface ReceivedCall {
45+
method: string;
46+
/** Every own key of the options object exactly as the driver received it. */
47+
keys: Array<string | symbol>;
48+
options: Record<string, unknown> | undefined;
49+
}
50+
51+
/**
52+
* A driver double that records the options argument of every data method it
53+
* is handed, and otherwise serves one in-memory table.
54+
*/
55+
function makeRecordingDriver(received: ReceivedCall[]) {
56+
const rows = new Map<string, Record<string, unknown>>();
57+
let nextId = 0;
58+
const record = (method: string, options: unknown) => {
59+
const opts = options && typeof options === 'object' ? (options as Record<string, unknown>) : undefined;
60+
received.push({ method, keys: opts ? Reflect.ownKeys(opts) : [], options: opts ? { ...opts } : undefined });
61+
};
62+
const matching = (where: any) =>
63+
[...rows.values()].filter((r) => !where || Object.entries(where).every(([k, v]) => k.startsWith('$') || r[k] === v));
64+
const driver: any = {
65+
name: 'memory',
66+
version: '0.0.0',
67+
supports: {},
68+
async connect() {}, async disconnect() {}, async checkHealth() { return true; },
69+
async syncSchema() {},
70+
async execute(_command: unknown, _params: unknown, options: unknown) { record('execute', options); return []; },
71+
async find(_o: string, ast: any, options: unknown) {
72+
record('find', options);
73+
const hit = matching(ast?.where);
74+
return typeof ast?.limit === 'number' ? hit.slice(0, ast.limit) : hit;
75+
},
76+
async findOne(_o: string, ast: any, options: unknown) { record('findOne', options); return matching(ast?.where)[0] ?? null; },
77+
async count(_o: string, ast: any, options: unknown) { record('count', options); return matching(ast?.where).length; },
78+
async create(_o: string, data: any, options: unknown) {
79+
record('create', options);
80+
nextId += 1;
81+
const row = { ...data, id: data.id ?? `r_${nextId}` };
82+
rows.set(row.id, row);
83+
return row;
84+
},
85+
async bulkCreate(_o: string, data: any[], options: unknown) {
86+
record('bulkCreate', options);
87+
return data.map((d) => { nextId += 1; const row = { ...d, id: d.id ?? `r_${nextId}` }; rows.set(row.id, row); return row; });
88+
},
89+
async update(_o: string, id: string, data: any, options: unknown) {
90+
record('update', options);
91+
const cur = rows.get(id);
92+
if (!cur) return null;
93+
const next = { ...cur, ...data, id };
94+
rows.set(id, next);
95+
return next;
96+
},
97+
async updateMany(_o: string, ast: any, data: any, options: unknown) {
98+
record('updateMany', options);
99+
const hit = matching(ast?.where);
100+
for (const r of hit) rows.set(r.id as string, { ...r, ...data, id: r.id });
101+
return hit.length;
102+
},
103+
async delete(_o: string, id: string, options: unknown) { record('delete', options); return rows.delete(id); },
104+
async deleteMany(_o: string, ast: any, options: unknown) {
105+
record('deleteMany', options);
106+
const hit = matching(ast?.where);
107+
for (const r of hit) rows.delete(r.id as string);
108+
return hit.length;
109+
},
110+
async bulkUpdate() { return []; }, async bulkDelete() {},
111+
};
112+
return driver;
113+
}
114+
115+
/** Tenant-scoped by default: the registry injects `organization_id`. */
116+
const TASK = { name: 'dopt_task', fields: { title: { type: 'text' }, status: { type: 'text' } } } as any;
117+
118+
async function makeEngine() {
119+
const received: ReceivedCall[] = [];
120+
const engine = new ObjectQL();
121+
engine.registerDriver(makeRecordingDriver(received), true);
122+
await engine.init();
123+
engine.registry.registerObject(TASK, 'com.example.driver-options');
124+
engine.setTenancyPostureProvider(() => 'isolated');
125+
return { engine, received };
126+
}
127+
128+
/** Drive every verb the rule names, each with its caller bag's own keys. */
129+
async function driveEveryVerb(engine: ObjectQL): Promise<void> {
130+
const [a] = (await engine.insert('dopt_task', [{ title: 'a', status: 'todo' }, { title: 'b', status: 'todo' }], {
131+
context: CALLER,
132+
onFieldsDropped: () => {},
133+
} as any)) as Array<Record<string, unknown>>;
134+
await engine.find('dopt_task', { where: { status: 'todo' }, fields: ['title'], limit: 5, offset: 0, context: CALLER });
135+
await engine.findOne('dopt_task', { where: { id: a!.id }, context: CALLER });
136+
await engine.count('dopt_task', { where: { status: 'todo' } }, { context: CALLER });
137+
await engine.update('dopt_task', { id: a!.id, title: 'a2' }, { context: CALLER, onFieldsDropped: () => {} } as any);
138+
await engine.update('dopt_task', { status: 'done' }, { where: { status: 'todo' }, multi: true, context: CALLER } as any);
139+
await engine.delete('dopt_task', { where: { id: a!.id }, context: CALLER } as any);
140+
await engine.delete('dopt_task', { where: { status: 'done' }, multi: true, context: CALLER } as any);
141+
}
142+
143+
describe('a data driver receives the declared driver options and nothing else', () => {
144+
it('the hop key set is DriverOptionsSchema\'s declared keys; the only shape keys it leaves out admit no value', () => {
145+
const shape = DriverOptionsSchema.shape as Record<string, { safeParse(v: unknown): { success: boolean } }>;
146+
const declared = declaredDriverOptionKeys();
147+
for (const key of declared) expect(Object.keys(shape)).toContain(key);
148+
for (const key of ['transaction', 'tenantId', 'tenantIds', 'timezone', 'preserveAudit', 'bypassTenantAudit']) {
149+
expect(declared.has(key), key).toBe(true);
150+
}
151+
// What is left out is a retired tombstone: it refuses every value.
152+
const left = Object.keys(shape).filter((k) => !declared.has(k));
153+
expect(left.length).toBeGreaterThan(0);
154+
for (const key of left) {
155+
for (const probe of [1, 'x', true, {}, [], null]) {
156+
expect(shape[key]!.safeParse(probe).success, `${key} accepted ${JSON.stringify(probe)}`).toBe(false);
157+
}
158+
}
159+
});
160+
161+
it('find, findOne, insert, update and delete hand the driver only declared option keys', async () => {
162+
const { engine, received } = await makeEngine();
163+
await driveEveryVerb(engine);
164+
165+
const methods = new Set(received.map((c) => c.method));
166+
// Not vacuous: every verb the rule names really reached the driver.
167+
for (const m of ['find', 'findOne', 'create', 'update', 'delete', 'updateMany', 'deleteMany', 'count']) {
168+
expect(methods.has(m) || (m === 'create' && methods.has('bulkCreate')), `driver.${m} was called`).toBe(true);
169+
}
170+
const declared = declaredDriverOptionKeys();
171+
for (const call of received) {
172+
for (const key of call.keys) {
173+
expect(typeof key === 'string' && declared.has(key), `driver.${call.method} received undeclared key ${String(key)}`).toBe(true);
174+
}
175+
}
176+
});
177+
178+
it('the raw execute door hands the driver only declared option keys', async () => {
179+
const { engine, received } = await makeEngine();
180+
await engine.execute('SELECT 1', { object: 'dopt_task', args: [], transaction: TRX });
181+
const call = received.find((c) => c.method === 'execute');
182+
expect(call).toBeDefined();
183+
expect(call!.keys).toEqual(['transaction']);
184+
expect(call!.options!.transaction).toBe(TRX);
185+
});
186+
});
187+
188+
describe('control: the declared keys the engine threads still reach the driver', () => {
189+
it('transaction, tenantId and timezone arrive on find, findOne, insert, update and delete', async () => {
190+
const { engine, received } = await makeEngine();
191+
await driveEveryVerb(engine);
192+
193+
for (const method of ['find', 'findOne', 'update', 'delete']) {
194+
const call = received.find((c) => c.method === method);
195+
expect(call, `driver.${method} was called`).toBeDefined();
196+
expect(call!.options!.transaction, method).toBe(TRX);
197+
expect(call!.options!.tenantId, method).toBe(ORG);
198+
expect(call!.options!.timezone, method).toBe(TZ);
199+
}
200+
const insert = received.find((c) => c.method === 'bulkCreate' || c.method === 'create');
201+
expect(insert!.options!.transaction).toBe(TRX);
202+
expect(insert!.options!.tenantId).toBe(ORG);
203+
expect(insert!.options!.timezone).toBe(TZ);
204+
});
205+
206+
it('preserveAudit and a caller\'s explicit bypassTenantAudit arrive unchanged', async () => {
207+
const { engine, received } = await makeEngine();
208+
const [row] = (await engine.insert('dopt_task', [{ title: 'a' }], { context: CALLER } as any)) as Array<Record<string, unknown>>;
209+
await engine.update(
210+
'dopt_task',
211+
{ id: row!.id, title: 'b' },
212+
{ context: { ...CALLER, preserveAudit: true }, bypassTenantAudit: true } as any,
213+
);
214+
const update = received.filter((c) => c.method === 'update').at(-1)!;
215+
expect(update.options!.preserveAudit).toBe(true);
216+
expect(update.options!.bypassTenantAudit).toBe(true);
217+
});
218+
});
219+
220+
describe('the after* hook view of input.options stays additive', () => {
221+
it('an afterFind hook still reads the caller\'s own keys beside the driver options', async () => {
222+
const { engine } = await makeEngine();
223+
const seen: Array<Record<string, unknown>> = [];
224+
engine.registerHook('afterFind', async (ctx: any) => { seen.push(ctx.input.options); }, { object: 'dopt_task' });
225+
await engine.find('dopt_task', { where: { status: 'todo' }, limit: 3, context: CALLER });
226+
expect(seen).toHaveLength(1);
227+
expect(seen[0]!.limit).toBe(3);
228+
expect(seen[0]!.where).toEqual({ status: 'todo' });
229+
expect(seen[0]!.tenantId).toBe(ORG);
230+
});
231+
232+
it('an afterUpdate hook still reads a write-observability callback the caller passed', async () => {
233+
const { engine } = await makeEngine();
234+
const [row] = (await engine.insert('dopt_task', [{ title: 'a' }], { context: CALLER } as any)) as Array<Record<string, unknown>>;
235+
const callback = () => {};
236+
let seenCallback: unknown;
237+
engine.registerHook('afterUpdate', async (ctx: any) => { seenCallback = ctx.input.options?.onMentionsWithheld; }, { object: 'dopt_task' });
238+
await engine.update('dopt_task', { id: row!.id, title: 'b' }, { context: CALLER, onMentionsWithheld: callback } as any);
239+
expect(seenCallback).toBe(callback);
240+
});
241+
242+
it('merging adds the driver options, answers the caller\'s own bag when nothing is added, and never answers the driver\'s bag itself', () => {
243+
const bag = { where: { a: 1 }, tenantId: ORG };
244+
expect(mergeDriverOptionsIntoHookView(bag, pickDeclaredDriverOptions(bag))).toBe(bag);
245+
expect(mergeDriverOptionsIntoHookView(bag, { tenantId: ORG, timezone: TZ })).toEqual({ where: { a: 1 }, tenantId: ORG, timezone: TZ });
246+
const driverBag = { tenantId: ORG };
247+
const view = mergeDriverOptionsIntoHookView(undefined, driverBag);
248+
expect(view).toEqual(driverBag);
249+
expect(view).not.toBe(driverBag);
250+
expect(mergeDriverOptionsIntoHookView(bag, undefined)).toBe(bag);
251+
});
252+
});

0 commit comments

Comments
 (0)