Skip to content

Commit fc3a36a

Browse files
os-zhuangclaude
andauthored
fix(spec,objectql,sharing,storage): state per-row vs record dispatch on the hook contract (#6966) (#7101)
* fix(spec,objectql,sharing,storage): state per-row vs record dispatch on the hook contract (#6966) A predicate (`multi: true`) write dispatches its lifecycle hooks once per matched row — `after*` since #5038, `before*` since #5574 — on a context deliberately indistinguishable from a single-id write's. That is the feature, and it erased the only signal several handlers had: before #5574 a bulk `before*` fired once with `input.id` present-but-`undefined`, so "no id" meant "this call stands for N rows". Every guard written on it silently inverted rather than failing. Adds `HookContext.dispatch` — `{ mode: 'record' | 'per-row', index, scope }` — bound by the engine at every write dispatch site (insert, update, delete, both phases), at the point the dispatch ladder is decided. Optional, and an absent marker reads as "not per-row", so existing handlers keep their behaviour. `scope` is one object shared by every dispatch of one write across both phases: the seam handlers used to get by stashing on the context, which only ever worked because a single-id write reuses one context across its pair. Deliberately not the `isPredicateBulkWrite` discriminator #5574 retired under ADR-0049: that one inferred "bulk" at the consumer from `input.id` and `options.multi` and ended with no producer and no reachable consumer. This one is engine-produced and has readers. Behaviour fixed: - plugin-sharing — the `before*` stash of a write's affected row set was landing on a per-row context the `after*` phase never saw, so every bulk update or delete on a ruled object revoked all of that object's rule grants and queued a full asynchronous re-grant, once per matched row, with the repeats racing each other. Access was never widened; a bounded write now takes the bounded path again, the cap still applies to the union, and the `after*` work runs once per write instead of N times (the bounded branch was quadratic in batch size). - service-storage — the `beforeDelete` id pre-resolution was dead on every path and `afterDelete` was doing one `sys_file` lookup per row where the batch fits one `$in`. The pre-resolution query is gone entirely: the engine has already matched the rows. `beforeUpdate` copy-on-claim no longer runs per row against a batch-scoped payload, removing a row-conditioned rewrite of a shared SET clause (out of contract under ADR-0058 Addendum II D3). Stale comments in both packages asserting that predicate writes never populate `input.id`, and that the engine reuses one `HookContext` across a write's before/after pair, are corrected where they sit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EcLMsZRoR3daV3QzPXgwxK * docs(service-storage): name the follow-up issue at the afterUpdate ownership note (#6966) The comment said the bulk-update file-ownership hole was "filed separately"; it is #7102. A pointer a reader can follow beats a promise they cannot check. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EcLMsZRoR3daV3QzPXgwxK * test(objectql): pin the marker as an invariant, not just path by path (#6966) `HookContext.dispatch`'s JSDoc claims every dispatched context carries it — which is what makes `ctx.dispatch?.mode` safe to read with no "what if the engine did not bind it" branch. The per-path cases prove the four write paths; this proves the claim itself, and covers the one context a reader might worry about: update()/delete() keep a batch-scoped `hookContext` on the predicate path, and no handler ever sees it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EcLMsZRoR3daV3QzPXgwxK * docs(spec): regenerate the hook reference page for HookContext.dispatch (#6966) `content/docs/references/data/hook.mdx` is generated from the Zod schema, so the new key has to land there too — `check:docs` is what caught it. The row renders all three members inline (three keys, under the renderer's four-key limit), so it carries no `…` elision and none of the `any` trap the `roles` tombstone note warns about. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EcLMsZRoR3daV3QzPXgwxK * fix(plugin-sharing): an empty per-row union reads as unbounded, not as "no rows" (#6966) The accumulator only exists because a `before*` dispatch created it, so an empty one means every id it was handed was null — "we do not know", not "nothing changed". Reading it as an empty row set would silently skip the cleanup entirely, which is the direction #4757 was filed for and the rule this module states for its resolve path. Adds direct unit cover for the accumulator: the union across rows without re-querying the predicate, dedup across the two subscribers that both stash on every row, the empty-union verdict, and the cap applied to the union. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EcLMsZRoR3daV3QzPXgwxK * chore(spec): record HookDispatch in the api-surface snapshot (#6966) `HookDispatch` is a new public export, so the surface snapshot moves with it — 0 breaking, 1 added. Caught by CI's `check:api-surface`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EcLMsZRoR3daV3QzPXgwxK * chore(spec): regenerate api-surface and export-origins after merging main (#6966) Two generated artifacts move with the merge: - `export-origins/` is new on main (#4796) and had never seen `HookDispatch`. - `api-surface/` needed a real regeneration, not just my own entry. The merge of main into this branch produced a file byte-identical to THIS branch's side, silently dropping the five exports #7123 added on main (`SEARCH_VIRTUAL_TYPES`, `foldAsciiCase`, `asciiCaseInsensitiveContains`, `asciiCaseInsensitiveRegexSource`, `isVirtualSearchField`). These paths carry `merge=os-regen` in .gitattributes precisely so a merge regenerates rather than picks a side; it did not here, and `check:api-surface` is what caught it. Regenerating yields the union — both main's five and this branch's `HookDispatch`. Read the export-origins diff as its gate asks: one line, `HookDispatch` under `src/data/hook.zod.ts`, the same origin as `HookContext`. Not a re-home, not a new dual-source. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EcLMsZRoR3daV3QzPXgwxK --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0fd8556 commit fc3a36a

15 files changed

Lines changed: 873 additions & 70 deletions

File tree

.changeset/hook-dispatch-marker.md

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/objectql": minor
4+
"@objectstack/plugin-sharing": patch
5+
"@objectstack/service-storage": patch
6+
---
7+
8+
fix(spec,objectql,sharing,storage): a hook can tell a per-row bulk dispatch from a single-record write again (#6966)
9+
10+
A predicate (`multi: true`) write dispatches its lifecycle hooks **once per
11+
matched row**`after*` since #5038, `before*` since #5574 — on a context
12+
deliberately indistinguishable from a single-id write's, so a handler written
13+
for one record works unchanged on a batch. That indistinguishability is the
14+
feature, and it also erased the only signal several handlers had.
15+
16+
Before #5574 a bulk `before*` fired once with `input.id` present-but-`undefined`,
17+
so "`input.id` is empty" meant "this call stands for N rows". Guards across the
18+
platform were written on it. Every one of them **silently inverted** rather than
19+
failing: a per-row context has an id, so the guard now answers "single write" for
20+
every row of a batch. Two further assumptions broke with it — that the engine
21+
reuses one `HookContext` across a write's before/after pair, and that `after*`
22+
work keyed on the write's row set runs once.
23+
24+
### New: `HookContext.dispatch`
25+
26+
The engine now states the fact rather than leaving it to be inferred:
27+
28+
```ts
29+
ctx.dispatch // { mode: 'record' | 'per-row', index: number, scope: object } | undefined
30+
```
31+
32+
- `mode``'record'` when the call is the caller's whole write; `'per-row'`
33+
when it is one of N.
34+
- `index` — position in the fan-out. `index === 0` is how a handler does
35+
batch-scoped work once instead of N times.
36+
- `scope` — scratch shared by **every** dispatch of one write, both phases, same
37+
object identity. This is the seam handlers used to get by stashing on the
38+
context itself, which only ever worked because a single-id write reuses one
39+
context across its pair.
40+
41+
Bound at every write dispatch site — insert, update, delete, both phases.
42+
Optional, and an absent marker reads as "not a per-row dispatch", so a handler
43+
reads `ctx.dispatch?.mode === 'per-row'` and existing code keeps its behaviour.
44+
Reads carry no marker: a read has no fan-out.
45+
46+
It is deliberately **not** the `isPredicateBulkWrite` discriminator #5574
47+
retired. That one was removed under ADR-0049 for having neither a producer nor a
48+
reachable consumer — it inferred "bulk" from `input.id` and `options.multi` at
49+
the consumer, which is exactly what `asScalarId` stays unexported to prevent
50+
(#4434 / #4550). This one is produced by the engine at the point the dispatch
51+
ladder is decided, and the platform's own handlers read it.
52+
53+
### Behaviour fixed
54+
55+
**Sharing rules and the record-share cascade (`@objectstack/plugin-sharing`).**
56+
The `before*` hook stashes the write's affected row set for the `after*` hook to
57+
act on. On a predicate write that stash was landing on a per-row context the
58+
`after` phase never saw, so `readAffectedRows` answered `resolve-failed` and both
59+
subscribers took their safe branch: every bulk update or delete on a ruled object
60+
revoked **all** of that object's rule grants and queued a full asynchronous
61+
re-grant — once per matched row, with the repeats racing each other's re-grants.
62+
Access was never widened (the trade is the ruling's "over-granting is an
63+
incident, under-granting is a wobble" direction), but a bounded write now takes
64+
the bounded path again: the rows are unioned as the engine hands them over, the
65+
cap still applies to the union, and the `after*` work runs once per write.
66+
67+
**File-reference ownership (`@objectstack/service-storage`).** The `beforeDelete`
68+
hook that pre-resolved ids for a `where`-shaped delete was dead on every path,
69+
and `afterDelete` was falling back to one `sys_file` lookup **per row** where the
70+
batch fits one `$in`. Both are fixed by the marker, and the pre-resolution query
71+
is gone entirely — the engine has already matched the rows and hands them over.
72+
The `beforeUpdate` copy-on-claim pass no longer runs once per row against a
73+
batch-scoped payload, which also removes a row-conditioned rewrite of a shared
74+
`SET` clause (out of contract under ADR-0058 Addendum II D3).
75+
76+
No authored metadata changes, and no write's result, event or return contract
77+
changes.

content/docs/references/data/hook.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ const result = HookContextSchema.parse(data);
3636
| **input** | `Record<string, any>` || Mutable input parameters |
3737
| **result** | `any` | optional | Operation result (After hooks only) |
3838
| **previous** | `Record<string, any>` | optional | Record state before operation |
39+
| **dispatch** | `{ mode: Enum<'record' \| 'per-row'>; index: integer; scope: Record<string, any> }` | optional | How this hook call relates to the caller's write (engine-produced; #6966) |
3940
| **session** | `{ userId?: string; actor?: string; organizationId?: string; accessToken?: string; … }` | optional | Current session context |
4041
| **provenance** | `{ flowRunId?: string; attributedUserId?: string }` | optional | Server-stamped write provenance (never client-supplied, never an authorization input) |
4142
| **transaction** | `any` | optional | Database transaction handle |

docs/audits/2026-07-unknown-key-strictness-ledger.counts.md

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ regenerate.
2121
| Measure | Value |
2222
|---|---|
2323
| Triaged directories | 5 |
24-
| Object sites in them | 436 |
25-
| Still-open (strip) sites | 180 |
24+
| Object sites in them | 437 |
25+
| Still-open (strip) sites | 181 |
2626
| Files carrying at least one | 27 |
2727

2828
Remaining strip sites by class:
@@ -31,7 +31,7 @@ Remaining strip sites by class:
3131
|---|---|
3232
| authorable — the ruling's forced scope | 41 |
3333
| unresolved — needs a per-schema verdict | 33 |
34-
| wire / open — out of forced scope | 104 |
34+
| wire / open — out of forced scope | 105 |
3535
| no door — no carrier, ADR-0049 territory | 1 |
3636
| no gate — carrier live, no parse | 0 |
3737
| covered — no carrier, no parse, guarded at every consumer | 1 |
@@ -45,11 +45,11 @@ The `strict` column is the one the campaign schedules against; it counts both th
4545
| Dir | Sites | strict | passthrough | catchall | strip |
4646
|---|---|---|---|---|---|
4747
| `ui/` | 160 | 118 | 5 | 0 | 37 |
48-
| `data/` | 164 | 56 | 1 | 0 | 107 |
48+
| `data/` | 165 | 56 | 1 | 0 | 108 |
4949
| `automation/` | 65 | 42 | 0 | 0 | 23 |
5050
| `security/` | 20 | 7 | 0 | 0 | 13 |
5151
| `studio/` | 27 | 27 | 0 | 0 | 0 |
52-
| **total** | **436** | **250** | **6** | **0** | **180** |
52+
| **total** | **437** | **250** | **6** | **0** | **181** |
5353

5454
## File-level triage — site counts
5555

@@ -102,14 +102,14 @@ classify and is not listed (it becomes reportable the day it grows its first sit
102102
| `field.zod.ts` | 11 |
103103
| `filter.zod.ts` | 11 |
104104
| `hook-body.zod.ts` | 2 |
105-
| `hook.zod.ts` | 6 |
105+
| `hook.zod.ts` | 7 |
106106
| `mapping.zod.ts` | 3 |
107107
| `object.zod.ts` | 20 |
108108
| `query.zod.ts` | 5 |
109109
| `seed-loader.zod.ts` | 12 |
110110
| `seed.zod.ts` | 1 |
111111
| `validation.zod.ts` | 6 |
112-
| **total** | **164** |
112+
| **total** | **165** |
113113

114114
### `automation/` — sites
115115

@@ -179,7 +179,7 @@ over it is here.
179179

180180
### `data/` — open
181181

182-
**107 strip of 164**, in 16 file(s).
182+
**108 strip of 165**, in 16 file(s).
183183

184184
| File | Strip | Sites |
185185
|---|---|---|
@@ -195,17 +195,17 @@ over it is here.
195195
| `field-value.zod.ts` | 1 | 2 |
196196
| `field.zod.ts` | 3 | 11 |
197197
| `filter.zod.ts` | 11 | 11 |
198-
| `hook.zod.ts` | 4 | 6 |
198+
| `hook.zod.ts` | 5 | 7 |
199199
| `object.zod.ts` | 1 | 20 |
200200
| `query.zod.ts` | 4 | 5 |
201201
| `seed-loader.zod.ts` | 12 | 12 |
202-
| **total** | **107** | **164** |
202+
| **total** | **108** | **165** |
203203

204204
| Bucket | Sites |
205205
|---|---|
206206
| authorable — the ruling's forced scope | 9 |
207207
| unresolved — needs a per-schema verdict | 33 |
208-
| wire / open — out of forced scope | 65 |
208+
| wire / open — out of forced scope | 66 |
209209
| no door — no carrier, ADR-0049 territory | 0 |
210210
| no gate — carrier live, no parse | 0 |
211211
| covered — no carrier, no parse, guarded at every consumer | 0 |

packages/objectql/src/bulk-write-per-row-hooks.test.ts

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -921,6 +921,183 @@ describe('[#5574 / D7] the matched row set is read ONCE, and serves everything',
921921
});
922922
});
923923

924+
/* ────────────────────────────────────────────────────────────────────────────
925+
* 8. [#6966] The dispatch marker — the fact D1/D2's indistinguishability erased
926+
*
927+
* Sections 1–7 make a per-row context deliberately indistinguishable from a
928+
* single-id one, which is what lets a handler written for one record work
929+
* unchanged on a batch. The cost is that "am I one of N?" became unanswerable,
930+
* and the guards that had been answering it from `input.id`'s shape — "no id
931+
* means bulk" — silently inverted rather than failing. `HookContext.dispatch`
932+
* is that question restored as an engine-stated fact.
933+
*
934+
* Pinned here rather than in a file of its own for section 7's reason: the
935+
* marker exists to describe THIS fan-out, and a marker that drifts from the
936+
* dispatch it describes is worse than none.
937+
* ──────────────────────────────────────────────────────────────────────────── */
938+
939+
describe('[#6966] every dispatched context carries the marker, on all four write paths', () => {
940+
const seenOn = async (event: string, drive: (engine: ObjectQL, ids: string[]) => Promise<unknown>) => {
941+
const seen: Array<{ mode: unknown; index: unknown; id: unknown }> = [];
942+
const { engine } = await boot([hook('marker', event, (ctx) => {
943+
const d = (ctx as any).dispatch;
944+
seen.push({ mode: d?.mode, index: d?.index, id: (ctx.input as any)?.id });
945+
})]);
946+
const rows = await seedTasks(engine, [
947+
{ title: 'a', status: 'todo' },
948+
{ title: 'b', status: 'todo' },
949+
]);
950+
await drive(engine, rows.map((r) => String(r.id)));
951+
return seen;
952+
};
953+
954+
it('single-id UPDATE ⇒ one dispatch, mode "record", index 0', async () => {
955+
for (const event of ['beforeUpdate', 'afterUpdate']) {
956+
const seen = await seenOn(event, (engine, ids) =>
957+
engine.update('task', { status: 'done' }, { where: { id: ids[0] } }));
958+
expect(seen, event).toEqual([{ mode: 'record', index: 0, id: seen[0].id }]);
959+
}
960+
});
961+
962+
it('single-id DELETE ⇒ one dispatch, mode "record", index 0', async () => {
963+
for (const event of ['beforeDelete', 'afterDelete']) {
964+
const seen = await seenOn(event, (engine, ids) => engine.delete('task', { where: { id: ids[0] } } as any));
965+
expect(seen, event).toEqual([{ mode: 'record', index: 0, id: seen[0].id }]);
966+
}
967+
});
968+
969+
it('predicate UPDATE ⇒ N dispatches, mode "per-row", index counting 0..N-1', async () => {
970+
for (const event of ['beforeUpdate', 'afterUpdate']) {
971+
const seen = await seenOn(event, (engine) =>
972+
engine.update('task', { status: 'done' }, { multi: true, where: { status: 'todo' } }));
973+
expect(seen.map((s) => s.mode), event).toEqual(['per-row', 'per-row']);
974+
// The INDEX is the member a consumer keys "do this once per batch" on, so
975+
// it must count rather than repeat the batch context's 0.
976+
expect(seen.map((s) => s.index), event).toEqual([0, 1]);
977+
expect(new Set(seen.map((s) => s.id)).size, event).toBe(2);
978+
}
979+
});
980+
981+
it('predicate DELETE ⇒ N dispatches, mode "per-row", index counting 0..N-1', async () => {
982+
for (const event of ['beforeDelete', 'afterDelete']) {
983+
const seen = await seenOn(event, (engine) =>
984+
engine.delete('task', { multi: true, where: { status: 'todo' } } as any));
985+
expect(seen.map((s) => s.mode), event).toEqual(['per-row', 'per-row']);
986+
expect(seen.map((s) => s.index), event).toEqual([0, 1]);
987+
expect(new Set(seen.map((s) => s.id)).size, event).toBe(2);
988+
}
989+
});
990+
991+
it('a batch INSERT is per-row too, and a single insert is not', async () => {
992+
const batch: any[] = [];
993+
const { engine } = await boot([hook('marker', 'beforeInsert', (ctx) => {
994+
batch.push((ctx as any).dispatch);
995+
})]);
996+
await engine.insert('task', [{ title: 'a' }, { title: 'b' }, { title: 'c' }] as any);
997+
expect(batch.map((d) => [d.mode, d.index])).toEqual([['per-row', 0], ['per-row', 1], ['per-row', 2]]);
998+
999+
batch.length = 0;
1000+
await engine.insert('task', { title: 'solo' } as any);
1001+
expect(batch.map((d) => [d.mode, d.index])).toEqual([['record', 0]]);
1002+
});
1003+
1004+
it('NO handler is ever dispatched on a context without the marker', async () => {
1005+
// The contract the JSDoc states, pinned as an invariant rather than
1006+
// path-by-path. It is what makes `ctx.dispatch?.mode` safe to read without
1007+
// a "what if the engine did not bind it" branch — and it covers the one
1008+
// context a reader might worry about: `update()`/`delete()` keep a
1009+
// BATCH-scoped `hookContext` on the predicate path, and the claim is that
1010+
// no handler ever sees it (the per-row loop runs whenever `hasHooksFor` is
1011+
// true, and when it is false no handler runs at all).
1012+
const unmarked: string[] = [];
1013+
const events = [
1014+
'beforeInsert', 'afterInsert',
1015+
'beforeUpdate', 'afterUpdate',
1016+
'beforeDelete', 'afterDelete',
1017+
];
1018+
const { engine } = await boot(events.map((e) => hook(`probe_${e}`, e, (ctx) => {
1019+
const d = (ctx as any).dispatch;
1020+
if (!d || (d.mode !== 'record' && d.mode !== 'per-row') || typeof d.index !== 'number' || !d.scope) {
1021+
unmarked.push(`${e}:${JSON.stringify(d)}`);
1022+
}
1023+
})));
1024+
1025+
// Every write shape this engine can take, in one pass.
1026+
await engine.insert('task', { title: 'solo', status: 'todo' } as any);
1027+
const batch: any = await engine.insert('task', [
1028+
{ title: 'a', status: 'todo' },
1029+
{ title: 'b', status: 'todo' },
1030+
] as any);
1031+
await engine.update('task', { status: 'mid' }, { where: { id: batch[0].id } });
1032+
await engine.update('task', { status: 'done' }, { multi: true, where: { status: 'todo' } });
1033+
await engine.delete('task', { where: { id: batch[0].id } } as any);
1034+
await engine.delete('task', { multi: true, where: { status: 'done' } } as any);
1035+
1036+
expect(unmarked).toEqual([]);
1037+
});
1038+
});
1039+
1040+
describe('[#6966] `scope` is one object per WRITE, spanning both phases', () => {
1041+
it('carries a before-phase stash to the after phase of a predicate update', async () => {
1042+
// The regression this closes: a per-row `before*` context is freshly built,
1043+
// so a handler stashing on the CONTEXT (which is what every consumer did,
1044+
// because a single-id write reuses one context across its pair) wrote to an
1045+
// object the after phase never sees.
1046+
const arrived: unknown[] = [];
1047+
const { engine } = await boot([
1048+
hook('stash', 'beforeUpdate', (ctx) => {
1049+
const scope = (ctx as any).dispatch.scope as Record<string, unknown>;
1050+
((scope.ids ??= []) as unknown[]).push((ctx.input as any).id);
1051+
}),
1052+
hook('read', 'afterUpdate', (ctx) => {
1053+
arrived.push(JSON.stringify(((ctx as any).dispatch.scope as any).ids));
1054+
}),
1055+
]);
1056+
const rows = await seedTasks(engine, [
1057+
{ title: 'a', status: 'todo' },
1058+
{ title: 'b', status: 'todo' },
1059+
]);
1060+
const ids = rows.map((r) => String(r.id));
1061+
1062+
await engine.update('task', { status: 'done' }, { multi: true, where: { status: 'todo' } });
1063+
1064+
// Every after dispatch sees the WHOLE batch's collection — all before
1065+
// dispatches complete before the write, so the union is closed by then.
1066+
expect(arrived).toEqual([JSON.stringify(ids), JSON.stringify(ids)]);
1067+
});
1068+
1069+
it('is per WRITE — a second call gets a fresh scope, never the first call\'s', async () => {
1070+
const scopes: unknown[] = [];
1071+
const { engine } = await boot([hook('mark', 'beforeUpdate', (ctx) => {
1072+
const scope = (ctx as any).dispatch.scope as Record<string, unknown>;
1073+
scopes.push(scope);
1074+
scope.touched = true;
1075+
})]);
1076+
const rows = await seedTasks(engine, [{ title: 'a', status: 'todo' }]);
1077+
1078+
await engine.update('task', { status: 'mid' }, { where: { id: rows[0].id } });
1079+
await engine.update('task', { status: 'done' }, { where: { id: rows[0].id } });
1080+
1081+
expect(scopes).toHaveLength(2);
1082+
expect(scopes[0]).not.toBe(scopes[1]);
1083+
});
1084+
1085+
it('is the SAME object across a single-id write\'s before/after pair', async () => {
1086+
const scopes: unknown[] = [];
1087+
const grab = (ctx: HookContext) => { scopes.push((ctx as any).dispatch.scope); };
1088+
const { engine } = await boot([
1089+
hook('b', 'beforeUpdate', grab),
1090+
hook('a', 'afterUpdate', grab),
1091+
]);
1092+
const rows = await seedTasks(engine, [{ title: 'a', status: 'todo' }]);
1093+
1094+
await engine.update('task', { status: 'done' }, { where: { id: rows[0].id } });
1095+
1096+
expect(scopes).toHaveLength(2);
1097+
expect(scopes[0]).toBe(scopes[1]);
1098+
});
1099+
});
1100+
9241101
/* ────────────────────────────────────────────────────────────────────────────
9251102
* Harness
9261103
* ──────────────────────────────────────────────────────────────────────────── */

0 commit comments

Comments
 (0)