@affiant/core 0.1.0-alpha.0 → 0.1.0-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1699 @@
1
+ /**
2
+ * The store contract — every assertion a Docket store has to pass, written once and
3
+ * parametrised over the store under test.
4
+ *
5
+ * `@affiant/core` ships an in-memory reference store, and a host running on a
6
+ * database ships its own. The two are only interchangeable if they are measured by
7
+ * the same assertions, so the assertions live here rather than in the reference
8
+ * store's own suite: {@link runDocketStoreContract} and
9
+ * {@link runSessionStoreContract} register them against whatever factory they are
10
+ * handed, and the reference store is simply the first caller.
11
+ *
12
+ * ```ts
13
+ * import { describe, it, expect, beforeAll, afterAll } from "vitest";
14
+ * import { InMemoryDocketStore } from "@affiant/core/store-memory";
15
+ * import { runDocketStoreContract } from "@affiant/core/testing";
16
+ *
17
+ * runDocketStoreContract((clock) => new InMemoryDocketStore({ clock }), {
18
+ * api: { describe, it, expect, beforeAll, afterAll },
19
+ * });
20
+ * ```
21
+ *
22
+ * **The test runner comes in as a parameter.** Nothing here imports `vitest`, and
23
+ * `@affiant/core` gains no dependency — runtime, peer or optional — from carrying
24
+ * the contract. The caller passes the four functions it already has in scope, which
25
+ * is also what lets a store built on another runner run the same assertions.
26
+ *
27
+ * **A store is built once per block, and every case owns a tenant.** The factory is
28
+ * called from the block's `beforeAll`, because a store backed by a database is
29
+ * expensive to build and a fresh instance would not give a fresh database anyway.
30
+ * Isolation comes from tenancy instead: each case files under a tenant id of its
31
+ * own, which is a property the contract requires regardless (AZ-2). A case that
32
+ * needs a second tenant is handed one.
33
+ *
34
+ * **The clock is the case's.** A store is built with a {@link Clock} rather than
35
+ * handed an instant per call, so the harness owns a settable one, hands it to the
36
+ * factory, and resets it before every case.
37
+ *
38
+ * Rules the cases serve: DK-1 (idempotent filing, the guarded compare-and-set,
39
+ * expiry as queryable state, preserved amendments, execution recorded once,
40
+ * lineage), DK-2 (an amendment's `null` clears a field and an absent key leaves it
41
+ * alone), DK-3 (a bounded, paged, host-scheduled sweep and an opaque cursor on
42
+ * every list), DK-4 (retention, purge and export as hooks, and a row that reads
43
+ * forward), DK-5 (rehydration order), AZ-2 (a wrong-tenant lookup is a miss),
44
+ * AZ-5 (an approved write nobody has reported on is never aged out), GT-4 (a
45
+ * re-file never refreshes the deadline).
46
+ *
47
+ * @packageDocumentation
48
+ */
49
+ import { PROTOCOL_VERSION } from "@affiant/contract";
50
+ import { newEntry } from "./docket/entry.js";
51
+ import { withConfidence } from "./model/affidavit.js";
52
+ import { chainOf, mintConversation } from "./model/provenance.js";
53
+ /** A {@link Clock} that reads `start` until a test moves it. */
54
+ export function stubClock(start) {
55
+ let current = start;
56
+ return {
57
+ now: () => current,
58
+ set: (instant) => {
59
+ current = instant;
60
+ },
61
+ };
62
+ }
63
+ /** One sworn field, filled in enough to be a real Affidavit field. */
64
+ export function sampleField(name, value) {
65
+ return {
66
+ name,
67
+ value,
68
+ previousValue: null,
69
+ provenance: chainOf(mintConversation({
70
+ confidence: 0.9,
71
+ at: "2026-09-04T09:00:00.000Z",
72
+ note: `User stated: ${name}`,
73
+ conversationTurn: 1,
74
+ })),
75
+ isMandatory: false,
76
+ kind: "text",
77
+ };
78
+ }
79
+ /**
80
+ * An Affidavit over `names`, shaped like something a pipeline would actually file.
81
+ *
82
+ * Built through `withConfidence` rather than as an object literal so the three
83
+ * numbers on it are the ones AF-2 computes, never numbers a test author typed.
84
+ */
85
+ export function sampleAffidavit(names = ["status"]) {
86
+ return withConfidence({
87
+ protocolVersion: PROTOCOL_VERSION,
88
+ operationType: "update",
89
+ entityType: "Invoice",
90
+ entityId: "invoice-1",
91
+ conversationTurn: 1,
92
+ createdAt: "2026-09-04T09:00:00.000Z",
93
+ }, names.map((name) => sampleField(name, `${name}-value`)));
94
+ }
95
+ /** The defaults every Docket fixture starts from. */
96
+ const BASE = {
97
+ tenantId: "tenant-a",
98
+ conversationId: "conv-1",
99
+ channel: "chat",
100
+ requirement: "ReviewerConfirmation",
101
+ toolName: "update_invoice",
102
+ filedAt: "2026-09-04T09:00:00.000Z",
103
+ expiresAt: "2026-09-04T09:30:00.000Z",
104
+ };
105
+ /** A filed entry with `entryId`, overridable field by field. */
106
+ export function sampleEntry(entryId, overrides = {}) {
107
+ return newEntry({ ...BASE, affidavit: sampleAffidavit(), entryId, ...overrides });
108
+ }
109
+ /** The ids of `entries`, in the order they were returned. */
110
+ export function entryIds(entries) {
111
+ return entries.map((entry) => entry.entryId);
112
+ }
113
+ /**
114
+ * One object that is both a Docket and the rehydration surface over it.
115
+ *
116
+ * A store built on a database implements both interfaces itself; the reference
117
+ * pair is two objects, and this is what hands them to
118
+ * {@link runSessionStoreContract} as one.
119
+ */
120
+ export function withSessionStore(docket, sessions) {
121
+ return {
122
+ file: (entry) => docket.file(entry),
123
+ get: (entryId, scope) => docket.get(entryId, scope),
124
+ transition: (entryId, scope, expected, patch) => docket.transition(entryId, scope, expected, patch),
125
+ preserveAmendments: (entryId, scope, amendments, act) => docket.preserveAmendments(entryId, scope, amendments, act),
126
+ recordExecution: (entryId, scope, outcome, detail, expected) => docket.recordExecution(entryId, scope, outcome, detail, expected),
127
+ recordSupersession: (entryId, scope, supersededBy) => docket.recordSupersession(entryId, scope, supersededBy),
128
+ listPending: (scope, page) => docket.listPending(scope, page),
129
+ listApprovedUnexecuted: (scope, page) => docket.listApprovedUnexecuted(scope, page),
130
+ expireDue: (now, scope, limit) => docket.expireDue(now, scope, limit),
131
+ retention: (policy, scope, limit) => docket.retention(policy, scope, limit),
132
+ purge: (tenantId) => docket.purge(tenantId),
133
+ export: (scope) => docket.export(scope),
134
+ rehydrate: (scope, page) => sessions.rehydrate(scope, page),
135
+ };
136
+ }
137
+ // ---------------------------------------------------------------------------
138
+ // The instants every case shares
139
+ // ---------------------------------------------------------------------------
140
+ /** The instant a case starts at, and the instant every fixture is filed at. */
141
+ const NOON = "2026-09-04T09:00:00.000Z";
142
+ /** The deadline `sampleEntry` gives an entry filed at {@link NOON}. */
143
+ const DEADLINE = "2026-09-04T09:30:00.000Z";
144
+ /** One millisecond past {@link DEADLINE}: the boundary is inclusive (DK-1). */
145
+ const AFTER_DEADLINE = "2026-09-04T09:30:00.001Z";
146
+ /** A day later, for a retention cut. */
147
+ const LATE = "2026-09-05T09:00:00.000Z";
148
+ /** An approval by a named person (AZ-1). */
149
+ function attestedBy(id, entryId, at = NOON) {
150
+ return { by: { kind: "member", id }, at, entryId };
151
+ }
152
+ /** The patch an approval writes. */
153
+ function approval(entryId, patch = {}) {
154
+ return {
155
+ status: "approved",
156
+ decision: { kind: "approve", reason: null, at: NOON },
157
+ attestation: attestedBy("person-7", entryId),
158
+ ...patch,
159
+ };
160
+ }
161
+ /** Narrows a transition result to the entry it produced, failing the case if it refused. */
162
+ function applied(expect, result) {
163
+ expect(typeof result).not.toBe("string");
164
+ return result;
165
+ }
166
+ /**
167
+ * `cursor` with its first character changed.
168
+ *
169
+ * The first character is the one that must matter: whatever a store encodes, the
170
+ * front of the string is where it says which list the position belongs to, so a
171
+ * cursor altered there names a position the store did not mint. A store that read it
172
+ * anyway would page a caller to a row by arithmetic on a string.
173
+ */
174
+ function tampered(cursor) {
175
+ if (cursor === null)
176
+ throw new Error("the list handed back no cursor to tamper with");
177
+ const first = cursor.slice(0, 1);
178
+ return (first === "A" ? "B" : "A") + cursor.slice(1);
179
+ }
180
+ /** Everything `export` yields for `scope`, collected. */
181
+ async function exported(store, scope) {
182
+ const out = [];
183
+ for await (const entry of store.export(scope))
184
+ out.push(entry);
185
+ return out;
186
+ }
187
+ const DOCKET_SECTIONS = [
188
+ {
189
+ id: "filing",
190
+ title: "filing is idempotent by entry id (DK-1)",
191
+ cases: [
192
+ {
193
+ id: "filing/refile-keeps-the-existing-deadline",
194
+ title: "returns the existing entry on a re-file, with its existing deadline (GT-4)",
195
+ async run({ store, expect, entry }) {
196
+ const first = await store.file(entry("entry-1"));
197
+ const refiled = await store.file(entry("entry-1", {
198
+ expiresAt: "2026-09-04T23:59:00.000Z",
199
+ filedAt: "2026-09-04T09:10:00.000Z",
200
+ }));
201
+ expect(first.created).toBe(true);
202
+ expect(refiled.created).toBe(false);
203
+ expect(refiled.entry.expiresAt).toBe(first.entry.expiresAt);
204
+ expect(refiled.entry.filedAt).toBe(first.entry.filedAt);
205
+ },
206
+ },
207
+ {
208
+ id: "filing/keeps-one-entry-not-two",
209
+ title: "keeps one entry, not two",
210
+ async run({ store, expect, scope, entry }) {
211
+ await store.file(entry("entry-1"));
212
+ await store.file(entry("entry-1"));
213
+ const page = await store.listPending(scope, { limit: 10 });
214
+ expect(page.items).toHaveLength(1);
215
+ },
216
+ },
217
+ {
218
+ id: "filing/never-overwrites-the-affidavit",
219
+ title: "never overwrites the filed Affidavit",
220
+ async run({ store, expect, entry }) {
221
+ await store.file(entry("entry-1", { affidavit: sampleAffidavit(["amount"]) }));
222
+ const refiled = await store.file(entry("entry-1", { affidavit: sampleAffidavit(["recipient"]) }));
223
+ expect(refiled.entry.affidavit.fields.map((field) => field.name)).toEqual(["amount"]);
224
+ },
225
+ },
226
+ ],
227
+ },
228
+ {
229
+ id: "transition",
230
+ title: "the guarded compare-and-set (DK-1)",
231
+ cases: [
232
+ {
233
+ id: "transition/first-applies-second-is-already-decided",
234
+ title: "applies the first transition and refuses the second as a lost race",
235
+ async run({ store, expect, scope, entry }) {
236
+ await store.file(entry("entry-1"));
237
+ const first = await store.transition("entry-1", scope, "pending", approval("entry-1"));
238
+ const second = await store.transition("entry-1", scope, "pending", {
239
+ status: "rejected",
240
+ decision: { kind: "reject", reason: "too late", at: NOON },
241
+ });
242
+ expect(applied(expect, first).status).toBe("approved");
243
+ expect(applied(expect, first).execution).toBe("unexecuted");
244
+ expect(second).toBe("already-decided");
245
+ const stored = await store.get("entry-1", scope);
246
+ expect(stored?.status).toBe("approved");
247
+ expect(stored?.decision).toEqual({ kind: "approve", reason: null, at: NOON });
248
+ },
249
+ },
250
+ {
251
+ id: "transition/one-of-two-interleaved-wins",
252
+ title: "lets exactly one of two interleaved transitions win",
253
+ async run({ store, expect, scope, entry }) {
254
+ await store.file(entry("entry-1"));
255
+ const results = await Promise.all([
256
+ store.transition("entry-1", scope, "pending", approval("entry-1")),
257
+ store.transition("entry-1", scope, "pending", {
258
+ status: "rejected",
259
+ decision: { kind: "reject", reason: "no", at: NOON },
260
+ }),
261
+ ]);
262
+ const refusals = results.filter((result) => typeof result === "string");
263
+ expect(refusals).toEqual(["already-decided"]);
264
+ expect(results.filter((result) => typeof result !== "string")).toHaveLength(1);
265
+ },
266
+ },
267
+ {
268
+ id: "transition/a-burst-has-a-single-winner",
269
+ title: "survives a burst of interleaved decisions with a single winner",
270
+ async run({ store, expect, scope, entry }) {
271
+ await store.file(entry("entry-1"));
272
+ const results = await Promise.all(Array.from({ length: 25 }, (_unused, index) => store.transition("entry-1", scope, "pending", {
273
+ status: "approved",
274
+ attestation: attestedBy(`person-${index}`, "entry-1"),
275
+ })));
276
+ expect(results.filter((result) => typeof result !== "string")).toHaveLength(1);
277
+ expect(results.filter((result) => result === "already-decided")).toHaveLength(24);
278
+ },
279
+ },
280
+ {
281
+ id: "transition/records-the-approver",
282
+ title: "records the approver on the row (AZ-1)",
283
+ async run({ store, expect, scope, entry }) {
284
+ await store.file(entry("entry-1"));
285
+ await store.transition("entry-1", scope, "pending", approval("entry-1"));
286
+ const stored = await store.get("entry-1", scope);
287
+ expect(stored?.attestation).toEqual({
288
+ by: { kind: "member", id: "person-7" },
289
+ at: NOON,
290
+ entryId: "entry-1",
291
+ });
292
+ expect(stored?.decidedAt).toBe(NOON);
293
+ },
294
+ },
295
+ {
296
+ id: "transition/records-a-relay-as-member-via-relay",
297
+ title: "records a relayed decision as member-via-relay, never as member (AZ-3)",
298
+ async run({ store, expect, scope, entry }) {
299
+ await store.file(entry("entry-1", { channel: "mcp" }));
300
+ const decided = applied(expect, await store.transition("entry-1", scope, "pending", {
301
+ status: "approved",
302
+ attestation: {
303
+ by: {
304
+ kind: "member-via-relay",
305
+ memberId: "person-7",
306
+ relay: {
307
+ principal: "relay-desk",
308
+ channelIdentity: "slack:U024BE7LH",
309
+ messageId: "relay-msg-9",
310
+ },
311
+ },
312
+ at: NOON,
313
+ entryId: "entry-1",
314
+ },
315
+ }));
316
+ expect(decided.attestation?.by.kind).toBe("member-via-relay");
317
+ },
318
+ },
319
+ {
320
+ id: "transition/refuses-a-self-contradicting-patch",
321
+ title: "refuses a patch that contradicts its own status",
322
+ async run({ store, expect, scope, entry }) {
323
+ await store.file(entry("entry-1"));
324
+ await expect(store.transition("entry-1", scope, "pending", { status: "approved", execution: null })).rejects.toThrow(RangeError);
325
+ await expect(store.transition("entry-1", scope, "pending", {
326
+ status: "rejected",
327
+ execution: "executed",
328
+ })).rejects.toThrow(RangeError);
329
+ await expect(store.transition("entry-1", scope, "pending", { status: "pending" })).rejects.toThrow(RangeError);
330
+ },
331
+ },
332
+ {
333
+ id: "transition/not-found-for-an-id-outside-the-scope",
334
+ title: "says not-found for an id nothing in scope carries",
335
+ async run({ store, expect, scope }) {
336
+ expect(await store.transition("nope", scope, "pending", approval("nope"))).toBe("not-found");
337
+ },
338
+ },
339
+ {
340
+ id: "transition/amendment-null-is-cleared-absent-is-untouched",
341
+ title: "carries an amendment map whose null is a cleared field and whose absent key is not (DK-2)",
342
+ async run({ store, expect, scope, entry }) {
343
+ // DK-2 lives inside the map, so it has to survive whatever a store
344
+ // serialises that map as: a `null` value is the fact that the reviewer
345
+ // cleared the field, and a key that is not there says nothing about the
346
+ // field at all. A store that dropped null-valued keys on the way to the
347
+ // database would turn "clear it" into "leave it alone".
348
+ await store.file(entry("amended"));
349
+ await store.file(entry("untouched"));
350
+ await store.transition("amended", scope, "pending", approval("amended", { amendments: { status: "paid", note: null } }));
351
+ await store.transition("untouched", scope, "pending", approval("untouched"));
352
+ const amended = await store.get("amended", scope);
353
+ expect(amended?.amendments).toEqual({ status: "paid", note: null });
354
+ expect(Object.keys(amended?.amendments ?? {}).sort()).toEqual(["note", "status"]);
355
+ expect(amended?.amendments?.["note"]).toBeNull();
356
+ expect(Object.keys(amended?.amendments ?? {})).not.toContain("reference");
357
+ // A patch that names no amendments leaves the row's map as it stands.
358
+ expect((await store.get("untouched", scope))?.amendments).toBeNull();
359
+ },
360
+ },
361
+ ],
362
+ },
363
+ {
364
+ id: "deadline",
365
+ title: "a decision that arrives after the deadline (DK-1)",
366
+ cases: [
367
+ {
368
+ id: "deadline/reads-expired-without-a-sweep",
369
+ title: "reads expired without any sweep, and refuses the transition as expired",
370
+ async run({ store, clock, expect, scope, entry }) {
371
+ await store.file(entry("entry-1"));
372
+ expect((await store.get("entry-1", scope))?.status).toBe("pending");
373
+ clock.set(AFTER_DEADLINE);
374
+ const read = await store.get("entry-1", scope);
375
+ expect(read?.status).toBe("expired");
376
+ // The row left pending at its own deadline, not at the instant somebody looked.
377
+ expect(read?.decidedAt).toBe(DEADLINE);
378
+ expect(await store.transition("entry-1", scope, "pending", approval("entry-1"))).toBe("expired");
379
+ },
380
+ },
381
+ {
382
+ id: "deadline/preserves-a-late-decisions-amendments",
383
+ title: "preserves the amendments the late decision carried, for a resubmission",
384
+ async run({ store, clock, expect, scope, entry }) {
385
+ await store.file(entry("entry-1"));
386
+ clock.set(AFTER_DEADLINE);
387
+ const refused = await store.transition("entry-1", scope, "pending", approval("entry-1"));
388
+ expect(refused).toBe("expired");
389
+ const preserved = await store.preserveAmendments("entry-1", scope, { status: "paid", note: null }, { at: AFTER_DEADLINE, by: "person-7" });
390
+ expect(typeof preserved).not.toBe("string");
391
+ const row = preserved;
392
+ // The refused decision's own act, not the store's clock reading: a
393
+ // resubmission binds the prefilled values to the moment the person typed
394
+ // them (DK-1, PV-2).
395
+ expect(row.preservedAmendments).toEqual({
396
+ amendments: { status: "paid", note: null },
397
+ at: AFTER_DEADLINE,
398
+ by: "person-7",
399
+ });
400
+ // DK-2: a null value is a cleared field, and the key is present to say so.
401
+ expect(Object.keys(row.preservedAmendments?.amendments ?? {})).toContain("note");
402
+ // Nobody accepted anything, so the accepted-amendment map stays empty.
403
+ expect(row.amendments).toBeNull();
404
+ // The refusal stands: nothing about the decision was recorded.
405
+ expect(row.status).toBe("expired");
406
+ expect(row.decision).toBeNull();
407
+ expect(row.attestation).toBeNull();
408
+ },
409
+ },
410
+ {
411
+ id: "deadline/preserves-the-first-record-not-the-second",
412
+ title: "keeps the first preserved amendments when a second late decision arrives (DK-1, DK-4)",
413
+ async run({ store, clock, expect, scope, entry }) {
414
+ // A recorded fact is appended, never edited. Two reviewers deciding a row
415
+ // that has already expired both have their decision refused; the first one
416
+ // whose amendments were preserved is the one a resubmission prefills from,
417
+ // and the second cannot overwrite it.
418
+ await store.file(entry("entry-1"));
419
+ clock.set(AFTER_DEADLINE);
420
+ const act = { at: AFTER_DEADLINE, by: "person-7" };
421
+ await store.preserveAmendments("entry-1", scope, { status: "paid" }, act);
422
+ const second = await store.preserveAmendments("entry-1", scope, { status: "void" }, {
423
+ at: "2026-09-04T10:00:00.000Z",
424
+ by: "person-9",
425
+ });
426
+ expect(typeof second).not.toBe("string");
427
+ expect(second.preservedAmendments).toEqual({
428
+ amendments: { status: "paid" },
429
+ at: AFTER_DEADLINE,
430
+ by: "person-7",
431
+ });
432
+ expect((await store.get("entry-1", scope))?.preservedAmendments).toEqual({
433
+ amendments: { status: "paid" },
434
+ at: AFTER_DEADLINE,
435
+ by: "person-7",
436
+ });
437
+ },
438
+ },
439
+ {
440
+ id: "deadline/refuses-to-preserve-on-a-live-row",
441
+ title: "refuses to preserve amendments on a row that has not expired",
442
+ async run({ store, expect, scope, entry }) {
443
+ await store.file(entry("entry-1"));
444
+ const act = { at: NOON, by: "person-7" };
445
+ expect(await store.preserveAmendments("entry-1", scope, { status: "paid" }, act)).toBe("not-expired");
446
+ expect(await store.preserveAmendments("missing", scope, {}, act)).toBe("not-found");
447
+ },
448
+ },
449
+ {
450
+ id: "deadline/reads-the-same-swept-or-not",
451
+ title: "reads the same whether or not the sweep has caught up",
452
+ async run({ store, clock, expect, scope, entry }) {
453
+ await store.file(entry("entry-1"));
454
+ await store.file(entry("entry-2"));
455
+ clock.set(AFTER_DEADLINE);
456
+ const unswept = await store.get("entry-1", scope);
457
+ await store.expireDue(AFTER_DEADLINE, scope, 10);
458
+ const swept = await store.get("entry-1", scope);
459
+ expect(swept).toEqual(unswept);
460
+ },
461
+ },
462
+ {
463
+ id: "deadline/the-boundary-instant-reads-expired",
464
+ title: "reads a row at exactly its deadline as expired, on every surface (DK-1)",
465
+ async run({ store, clock, expect, scope, entry }) {
466
+ // The deadline is inclusive of the instant itself. Half-open the other way
467
+ // would leave a one-millisecond window in which a row is still decidable at
468
+ // its own deadline, and every surface that reports a status would disagree
469
+ // with the transition guard for exactly that long. The clock sits on the
470
+ // deadline for the whole case, so a store that compared strictly would read
471
+ // every one of these as `pending`. `listApprovedUnexecuted` has no boundary
472
+ // case anywhere in this contract, and cannot: expiry applies only to a row
473
+ // that still reads `pending`, so an approved row keeps its status past its
474
+ // deadline and its list never sees the boundary at all.
475
+ await store.file(entry("at-the-deadline"));
476
+ clock.set(DEADLINE);
477
+ const read = await store.get("at-the-deadline", scope);
478
+ expect(read?.status).toBe("expired");
479
+ expect(read?.decidedAt).toBe(DEADLINE);
480
+ expect((await store.listPending(scope, { limit: 10 })).items).toHaveLength(0);
481
+ expect(await store.transition("at-the-deadline", scope, "pending", approval("at-the-deadline"))).toBe("expired");
482
+ },
483
+ },
484
+ {
485
+ id: "deadline/preserves-amendments-at-the-boundary-instant",
486
+ title: "preserves a late decision's amendments on a row at exactly its deadline (DK-1)",
487
+ async run({ store, clock, expect, scope, entry }) {
488
+ // `preserveAmendments` answers `not-expired` for a row that does not read
489
+ // `expired`, so it is a surface the deadline comparison decides. A store
490
+ // that compared strictly would refuse the one decision this method exists
491
+ // to catch: the one that arrived on the deadline.
492
+ await store.file(entry("at-the-deadline"));
493
+ clock.set(DEADLINE);
494
+ const preserved = await store.preserveAmendments("at-the-deadline", scope, { status: "paid" }, { at: DEADLINE, by: "person-7" });
495
+ expect(typeof preserved).not.toBe("string");
496
+ expect(preserved.preservedAmendments).toEqual({
497
+ amendments: { status: "paid" },
498
+ at: DEADLINE,
499
+ by: "person-7",
500
+ });
501
+ },
502
+ },
503
+ {
504
+ id: "deadline/sweep-dates-the-row-to-its-own-deadline",
505
+ title: "records a swept row as having left pending at its deadline, not at the sweep",
506
+ async run({ store, clock, expect, scope, entry }) {
507
+ // A swept row and an unswept one past the same deadline have to be the
508
+ // same value, or a host learns to tell whether the sweep has caught up and
509
+ // comes to depend on it. The sweep instant is deliberately later than the
510
+ // deadline here, so a store that stamped `now` would be caught.
511
+ const sweptAt = "2026-09-04T11:00:00.000Z";
512
+ await store.file(entry("entry-1"));
513
+ clock.set(sweptAt);
514
+ const swept = await store.expireDue(sweptAt, scope, 10);
515
+ expect(swept.expired).toEqual(["entry-1"]);
516
+ const row = await store.get("entry-1", scope);
517
+ expect(row?.status).toBe("expired");
518
+ expect(row?.decidedAt).toBe(DEADLINE);
519
+ expect(row?.decidedAt).not.toBe(sweptAt);
520
+ },
521
+ },
522
+ ],
523
+ },
524
+ {
525
+ id: "execution",
526
+ title: "execution outcome on an approved row (DK-1)",
527
+ cases: [
528
+ {
529
+ id: "execution/moves-execution-without-touching-the-approval",
530
+ title: "moves execution without touching the approval",
531
+ async run({ store, expect, scope, entry }) {
532
+ await store.file(entry("entry-1"));
533
+ await store.transition("entry-1", scope, "pending", approval("entry-1"));
534
+ const executed = (await store.recordExecution("entry-1", scope, "executed", "wrote 1 row", "unexecuted"));
535
+ expect(executed.status).toBe("approved");
536
+ expect(executed.execution).toBe("executed");
537
+ expect(executed.executionDetail).toBe("wrote 1 row");
538
+ expect(executed.attestation).not.toBeNull();
539
+ },
540
+ },
541
+ {
542
+ id: "execution/committed-is-distinguishable-from-failed",
543
+ title: "distinguishes approved-and-committed from approved-but-failed",
544
+ async run({ store, expect, scope, entry }) {
545
+ await store.file(entry("entry-1"));
546
+ await store.file(entry("entry-2"));
547
+ await store.transition("entry-1", scope, "pending", approval("entry-1"));
548
+ await store.transition("entry-2", scope, "pending", approval("entry-2"));
549
+ await store.recordExecution("entry-1", scope, "executed", null, "unexecuted");
550
+ await store.recordExecution("entry-2", scope, "failed", "unique constraint", "unexecuted");
551
+ expect((await store.get("entry-1", scope))?.execution).toBe("executed");
552
+ const failed = await store.get("entry-2", scope);
553
+ expect(failed?.status).toBe("approved");
554
+ expect(failed?.execution).toBe("failed");
555
+ expect(failed?.executionDetail).toBe("unique constraint");
556
+ },
557
+ },
558
+ {
559
+ id: "execution/refuses-an-outcome-on-an-unapproved-row",
560
+ title: "refuses an execution outcome on a row nobody approved",
561
+ async run({ store, expect, scope, entry }) {
562
+ await store.file(entry("entry-1"));
563
+ expect(await store.recordExecution("entry-1", scope, "executed", null, "unexecuted")).toBe("not-approved");
564
+ expect(await store.recordExecution("missing", scope, "executed", null, "unexecuted")).toBe("not-found");
565
+ },
566
+ },
567
+ {
568
+ id: "execution/refuses-an-outcome-on-a-rejected-row",
569
+ title: "refuses an execution outcome on a row that was rejected",
570
+ async run({ store, expect, scope, entry }) {
571
+ // `not-approved` and `execution-already-recorded` are different answers, and
572
+ // this is the first of them: nobody authorised this write, so there is
573
+ // nothing for an executor to have done and no outcome to record (AZ-5). A
574
+ // guard written as "is it still pending?" rather than "is it approved?"
575
+ // accepts every non-pending row here and answers with the other refusal.
576
+ await store.file(entry("entry-1"));
577
+ await store.transition("entry-1", scope, "pending", {
578
+ status: "rejected",
579
+ decision: { kind: "reject", reason: "wrong amount", at: NOON },
580
+ });
581
+ expect(await store.recordExecution("entry-1", scope, "executed", null, "unexecuted")).toBe("not-approved");
582
+ expect((await store.get("entry-1", scope))?.execution).toBeNull();
583
+ },
584
+ },
585
+ {
586
+ id: "execution/refuses-an-outcome-on-an-expired-row",
587
+ title: "refuses an execution outcome on an expired row, swept or not",
588
+ async run({ store, clock, expect, scope, entry }) {
589
+ // The same distinction for the row nobody decided at all. It reads `expired`
590
+ // whether or not a sweep has run (DK-1), so the answer is `not-approved`
591
+ // here too — and both halves are asserted, because a store whose guard asks
592
+ // "has this row a recorded outcome?" rather than "is this row approved?"
593
+ // answers correctly for the unswept row by accident and wrongly for the
594
+ // swept one.
595
+ await store.file(entry("unswept"));
596
+ await store.file(entry("swept"));
597
+ clock.set(AFTER_DEADLINE);
598
+ expect(await store.expireDue(AFTER_DEADLINE, scope, 10)).toEqual({
599
+ expired: ["unswept", "swept"],
600
+ more: false,
601
+ });
602
+ for (const entryId of ["unswept", "swept"]) {
603
+ expect(await store.recordExecution(entryId, scope, "executed", null, "unexecuted")).toBe("not-approved");
604
+ expect((await store.get(entryId, scope))?.execution).toBeNull();
605
+ }
606
+ },
607
+ },
608
+ {
609
+ id: "execution/records-once-refusing-a-flip",
610
+ title: "records an outcome once, refusing a report that would flip a committed row",
611
+ async run({ store, expect, scope, entry }) {
612
+ // DK-4: a recorded fact is appended to, never edited in place. DK-1: an
613
+ // approved-and-committed write has to stay distinguishable from an
614
+ // approved-but-failed one - which it does not, if the last caller wins.
615
+ await store.file(entry("entry-1"));
616
+ await store.transition("entry-1", scope, "pending", approval("entry-1"));
617
+ await store.recordExecution("entry-1", scope, "executed", "invoice row 41", "unexecuted");
618
+ const second = await store.recordExecution("entry-1", scope, "failed", "actually it blew up", "unexecuted");
619
+ expect(second).toBe("execution-already-recorded");
620
+ const row = await store.get("entry-1", scope);
621
+ expect(row?.execution).toBe("executed");
622
+ expect(row?.executionDetail).toBe("invoice row 41");
623
+ expect(row?.status).toBe("approved");
624
+ },
625
+ },
626
+ {
627
+ id: "execution/refuses-the-flip-in-the-other-direction",
628
+ title: "refuses the flip in the other direction too: failed does not become executed",
629
+ async run({ store, expect, scope, entry }) {
630
+ await store.file(entry("entry-1"));
631
+ await store.transition("entry-1", scope, "pending", approval("entry-1"));
632
+ await store.recordExecution("entry-1", scope, "failed", "unique constraint", "unexecuted");
633
+ const second = await store.recordExecution("entry-1", scope, "executed", "retried and it worked", "unexecuted");
634
+ // A host that retries a write reports once, when it knows the outcome. The
635
+ // retries are the host's business; the Docket carries the one fact.
636
+ expect(second).toBe("execution-already-recorded");
637
+ const row = await store.get("entry-1", scope);
638
+ expect(row?.execution).toBe("failed");
639
+ expect(row?.executionDetail).toBe("unique constraint");
640
+ },
641
+ },
642
+ {
643
+ id: "execution/one-of-two-interleaved-reports-wins",
644
+ title: "lets exactly one of two interleaved execution reports win",
645
+ async run({ store, expect, scope, entry }) {
646
+ await store.file(entry("entry-1"));
647
+ await store.transition("entry-1", scope, "pending", approval("entry-1"));
648
+ // Two executors reporting at once - an outbox and a retry, say. The guard
649
+ // is a compare-and-set, so one applies and the other is refused; neither is
650
+ // queued and neither is written on top of the other.
651
+ const results = await Promise.all([
652
+ store.recordExecution("entry-1", scope, "executed", "first", "unexecuted"),
653
+ store.recordExecution("entry-1", scope, "failed", "second", "unexecuted"),
654
+ ]);
655
+ const refused = results.filter((result) => result === "execution-already-recorded");
656
+ expect(refused).toHaveLength(1);
657
+ const row = await store.get("entry-1", scope);
658
+ expect(["executed", "failed"]).toContain(row?.execution);
659
+ expect(row?.executionDetail).toBe(row?.execution === "executed" ? "first" : "second");
660
+ },
661
+ },
662
+ {
663
+ id: "execution/already-recorded-is-not-not-found",
664
+ title: "refuses a second report on a row whose outcome was recorded, not a missing one",
665
+ async run({ store, expect, scope, entry }) {
666
+ // The three refusals are distinct answers a caller acts on differently: no
667
+ // such row, a row nobody approved, and a row that already said what happened.
668
+ await store.file(entry("entry-1"));
669
+ await store.transition("entry-1", scope, "pending", approval("entry-1"));
670
+ await store.recordExecution("entry-1", scope, "executed", null, "unexecuted");
671
+ expect(await store.recordExecution("missing", scope, "failed", null, "unexecuted")).toBe("not-found");
672
+ expect(await store.recordExecution("entry-1", scope, "failed", null, "unexecuted")).toBe("execution-already-recorded");
673
+ },
674
+ },
675
+ {
676
+ id: "execution/approved-leaves-pending-and-joins-the-executors-list",
677
+ title: "leaves an approved row out of the pending list and in the executor's list",
678
+ async run({ store, expect, scope, entry }) {
679
+ await store.file(entry("entry-1"));
680
+ await store.transition("entry-1", scope, "pending", approval("entry-1"));
681
+ expect((await store.listPending(scope, { limit: 10 })).items).toHaveLength(0);
682
+ expect((await store.listApprovedUnexecuted(scope, { limit: 10 })).items).toHaveLength(1);
683
+ await store.recordExecution("entry-1", scope, "executed", null, "unexecuted");
684
+ expect((await store.listApprovedUnexecuted(scope, { limit: 10 })).items).toHaveLength(0);
685
+ },
686
+ },
687
+ ],
688
+ },
689
+ {
690
+ id: "lineage",
691
+ title: "resubmission lineage (DK-1)",
692
+ cases: [
693
+ {
694
+ id: "lineage/names-successor-and-predecessor",
695
+ title: "names the successor on the superseded row and the predecessor on the new one",
696
+ async run({ store, expect, scope, entry }) {
697
+ await store.file(entry("entry-1"));
698
+ await store.transition("entry-1", scope, "pending", {
699
+ status: "rejected",
700
+ decision: { kind: "reject", reason: "wrong amount", at: NOON },
701
+ });
702
+ await store.file(entry("entry-2", { supersedes: "entry-1" }));
703
+ const superseded = (await store.recordSupersession("entry-1", scope, "entry-2"));
704
+ expect(superseded.status).toBe("rejected");
705
+ expect(superseded.lineage).toEqual({ supersedes: null, supersededBy: "entry-2" });
706
+ expect((await store.get("entry-2", scope))?.lineage).toEqual({
707
+ supersedes: "entry-1",
708
+ supersededBy: null,
709
+ });
710
+ },
711
+ },
712
+ {
713
+ id: "lineage/supersedes-at-the-boundary-instant",
714
+ title: "supersedes a row at exactly its deadline, which is no longer open (DK-1)",
715
+ async run({ store, clock, expect, scope, entry }) {
716
+ // `recordSupersession` answers `not-terminal` for a row that still reads
717
+ // `pending`, so it too is decided by the deadline comparison: on the
718
+ // deadline the row is terminal and a resubmission may name itself its
719
+ // successor.
720
+ await store.file(entry("at-the-deadline"));
721
+ clock.set(DEADLINE);
722
+ const superseded = await store.recordSupersession("at-the-deadline", scope, "entry-2");
723
+ expect(typeof superseded).not.toBe("string");
724
+ expect(superseded.status).toBe("expired");
725
+ expect(superseded.lineage.supersededBy).toBe("entry-2");
726
+ },
727
+ },
728
+ {
729
+ id: "lineage/refuses-to-supersede-an-open-row",
730
+ title: "refuses to supersede a row that is still open for a decision",
731
+ async run({ store, expect, scope, entry }) {
732
+ await store.file(entry("entry-1"));
733
+ expect(await store.recordSupersession("entry-1", scope, "entry-2")).toBe("not-terminal");
734
+ expect(await store.recordSupersession("missing", scope, "entry-2")).toBe("not-found");
735
+ },
736
+ },
737
+ {
738
+ id: "lineage/keeps-the-first-successor-not-the-second",
739
+ title: "keeps the first successor when a second supersession arrives (DK-1, DK-4)",
740
+ async run({ store, clock, expect, scope, entry }) {
741
+ // The successor link is a later fact like any other: a row reads forward, so
742
+ // the second report is not written over the first (DK-1). The gate cannot
743
+ // produce this — a resubmission derives its id from the superseded one, so a
744
+ // repeated resubmit replays to the same successor — which is exactly why the
745
+ // store owes the guarantee: two direct records of a successor on an expired
746
+ // row, which only a store call can make, must leave the first one standing.
747
+ await store.file(entry("entry-1"));
748
+ clock.set(AFTER_DEADLINE);
749
+ await store.recordSupersession("entry-1", scope, "entry-2");
750
+ const second = await store.recordSupersession("entry-1", scope, "entry-3");
751
+ expect(typeof second).not.toBe("string");
752
+ expect(second.lineage.supersededBy).toBe("entry-2");
753
+ expect((await store.get("entry-1", scope))?.lineage.supersededBy).toBe("entry-2");
754
+ },
755
+ },
756
+ {
757
+ id: "lineage/supersedes-a-row-that-expired-unswept",
758
+ title: "supersedes an entry that expired without ever being swept",
759
+ async run({ store, clock, expect, scope, entry }) {
760
+ await store.file(entry("entry-1"));
761
+ clock.set(AFTER_DEADLINE);
762
+ const superseded = (await store.recordSupersession("entry-1", scope, "entry-2"));
763
+ expect(superseded.status).toBe("expired");
764
+ expect(superseded.lineage.supersededBy).toBe("entry-2");
765
+ },
766
+ },
767
+ ],
768
+ },
769
+ {
770
+ id: "sweep",
771
+ title: "expireDue is bounded and reports what is left (DK-3)",
772
+ cases: [
773
+ {
774
+ id: "sweep/expires-at-most-the-limit",
775
+ title: "expires at most the limit and says more remain",
776
+ async run({ store, clock, expect, scope, entry }) {
777
+ clock.set(AFTER_DEADLINE);
778
+ await fileDueEntries(store, entry, 5);
779
+ const first = await store.expireDue(AFTER_DEADLINE, scope, 2);
780
+ expect(first.expired).toEqual(["entry-1", "entry-2"]);
781
+ expect(first.more).toBe(true);
782
+ },
783
+ },
784
+ {
785
+ id: "sweep/drains-in-bounded-calls",
786
+ title: "drains five due entries in three bounded calls",
787
+ async run({ store, clock, expect, scope, entry }) {
788
+ clock.set(AFTER_DEADLINE);
789
+ await fileDueEntries(store, entry, 5);
790
+ const first = await store.expireDue(AFTER_DEADLINE, scope, 2);
791
+ const second = await store.expireDue(AFTER_DEADLINE, scope, 2);
792
+ const third = await store.expireDue(AFTER_DEADLINE, scope, 2);
793
+ const fourth = await store.expireDue(AFTER_DEADLINE, scope, 2);
794
+ expect(first.expired).toEqual(["entry-1", "entry-2"]);
795
+ expect(second.expired).toEqual(["entry-3", "entry-4"]);
796
+ expect(third.expired).toEqual(["entry-5"]);
797
+ expect(third.more).toBe(false);
798
+ // Nothing is expired twice: a swept row is no longer due.
799
+ expect(fourth).toEqual({ expired: [], more: false });
800
+ },
801
+ },
802
+ {
803
+ id: "sweep/expires-in-filing-order",
804
+ title: "expires in filing order",
805
+ async run({ store, clock, expect, scope, entry }) {
806
+ clock.set(AFTER_DEADLINE);
807
+ await fileDueEntries(store, entry, 4);
808
+ const swept = await store.expireDue(AFTER_DEADLINE, scope, 10);
809
+ expect(swept.expired).toEqual(["entry-1", "entry-2", "entry-3", "entry-4"]);
810
+ },
811
+ },
812
+ {
813
+ id: "sweep/leaves-entries-that-are-not-due",
814
+ title: "leaves entries that are not due alone",
815
+ async run({ store, expect, scope, entry }) {
816
+ await store.file(entry("due", { expiresAt: "2026-09-04T09:10:00.000Z" }));
817
+ await store.file(entry("later", { expiresAt: "2026-09-04T23:00:00.000Z" }));
818
+ const swept = await store.expireDue("2026-09-04T09:15:00.000Z", scope, 10);
819
+ expect(swept.expired).toEqual(["due"]);
820
+ expect((await store.get("later", scope))?.status).toBe("pending");
821
+ },
822
+ },
823
+ {
824
+ id: "sweep/never-sweeps-a-row-that-left-pending",
825
+ title: "never sweeps an entry that already left pending",
826
+ async run({ store, clock, expect, scope, entry }) {
827
+ await store.file(entry("approved-1", { status: "approved" }));
828
+ await store.file(entry("rejected-1", { status: "rejected" }));
829
+ clock.set(AFTER_DEADLINE);
830
+ expect(await store.expireDue(AFTER_DEADLINE, scope, 10)).toEqual({
831
+ expired: [],
832
+ more: false,
833
+ });
834
+ },
835
+ },
836
+ {
837
+ id: "sweep/sweeps-only-the-scope-asked-for",
838
+ title: "sweeps only the scope it was asked for",
839
+ async run({ store, clock, expect, scope, entry, conversation }) {
840
+ clock.set(AFTER_DEADLINE);
841
+ await fileDueEntries(store, entry, 2);
842
+ await store.file(entry("other-conv", { conversationId: "conv-2" }));
843
+ const swept = await store.expireDue(AFTER_DEADLINE, conversation("conv-2"), 10);
844
+ expect(swept.expired).toEqual(["other-conv"]);
845
+ expect((await store.get("entry-1", scope))?.status).toBe("expired");
846
+ },
847
+ },
848
+ {
849
+ id: "sweep/the-boundary-instant-is-due",
850
+ title: "sweeps a row whose deadline is exactly the instant it is given (DK-1, DK-3)",
851
+ async run({ store, clock, expect, scope, entry }) {
852
+ await store.file(entry("at-the-deadline"));
853
+ clock.set(DEADLINE);
854
+ const swept = await store.expireDue(DEADLINE, scope, 10);
855
+ expect(swept.expired).toEqual(["at-the-deadline"]);
856
+ expect((await store.get("at-the-deadline", scope))?.status).toBe("expired");
857
+ },
858
+ },
859
+ {
860
+ id: "sweep/a-full-sweep-with-nothing-left-says-so",
861
+ title: "says nothing remains when the last call expired exactly its limit (DK-3)",
862
+ async run({ store, clock, expect, scope, entry }) {
863
+ // A host drains the sweep by calling until `more` is false, so a `more`
864
+ // that reported "a full page, therefore probably more" would never settle:
865
+ // the host would keep calling and the queue would never read as drained.
866
+ clock.set(AFTER_DEADLINE);
867
+ await fileDueEntries(store, entry, 2);
868
+ const swept = await store.expireDue(AFTER_DEADLINE, scope, 2);
869
+ expect(swept.expired).toEqual(["entry-1", "entry-2"]);
870
+ expect(swept.more).toBe(false);
871
+ },
872
+ },
873
+ {
874
+ id: "sweep/refuses-an-unbounded-sweep",
875
+ title: "refuses an unbounded sweep",
876
+ async run({ store, clock, expect, scope, entry }) {
877
+ clock.set(AFTER_DEADLINE);
878
+ await fileDueEntries(store, entry, 1);
879
+ await expect(store.expireDue(AFTER_DEADLINE, scope, 0)).rejects.toThrow(RangeError);
880
+ await expect(store.expireDue(AFTER_DEADLINE, scope, -1)).rejects.toThrow(RangeError);
881
+ await expect(store.expireDue(AFTER_DEADLINE, scope, 1.5)).rejects.toThrow(RangeError);
882
+ },
883
+ },
884
+ ],
885
+ },
886
+ {
887
+ id: "paging",
888
+ title: "every list is paged with an opaque cursor (DK-3, RT-2)",
889
+ cases: [
890
+ {
891
+ id: "paging/walks-the-pending-list-a-page-at-a-time",
892
+ title: "walks the pending list a page at a time",
893
+ async run({ store, expect, scope, entry }) {
894
+ for (let index = 1; index <= 5; index += 1)
895
+ await store.file(entry(`entry-${index}`));
896
+ const first = await store.listPending(scope, { limit: 2 });
897
+ const second = await store.listPending(scope, { cursor: first.cursor, limit: 2 });
898
+ const third = await store.listPending(scope, { cursor: second.cursor, limit: 2 });
899
+ expect(entryIds(first.items)).toEqual(["entry-1", "entry-2"]);
900
+ expect(first.more).toBe(true);
901
+ expect(entryIds(second.items)).toEqual(["entry-3", "entry-4"]);
902
+ expect(entryIds(third.items)).toEqual(["entry-5"]);
903
+ expect(third.more).toBe(false);
904
+ expect(third.cursor).toBeNull();
905
+ },
906
+ },
907
+ {
908
+ id: "paging/hands-back-a-cursor-when-more-remain",
909
+ title: "hands back a cursor when a page does not drain the list",
910
+ async run({ store, expect, scope, entry }) {
911
+ await store.file(entry("entry-1"));
912
+ await store.file(entry("entry-2"));
913
+ const page = await store.listPending(scope, { limit: 1 });
914
+ expect(page.cursor).not.toBeNull();
915
+ expect(page.more).toBe(true);
916
+ },
917
+ },
918
+ {
919
+ id: "paging/refuses-a-tampered-cursor",
920
+ title: "refuses a cursor a caller altered by one character (DK-3)",
921
+ async run({ store, expect, scope, entry }) {
922
+ // This is the half of opacity a suite can measure. That a cursor is
923
+ // unreadable is not: every encoding a store could choose is decodable by
924
+ // somebody who knows it, and an assertion that the string does not contain
925
+ // a particular entry id is passed by a plaintext cursor that contains
926
+ // everything else. What can be measured is that the store refuses a
927
+ // position it did not mint, so a caller cannot page to a row by editing
928
+ // one - which is the property the rule is protecting.
929
+ await store.file(entry("entry-1"));
930
+ await store.file(entry("entry-2"));
931
+ await store.file(entry("approved-1", { status: "approved" }));
932
+ await store.file(entry("approved-2", { status: "approved" }));
933
+ const pending = await store.listPending(scope, { limit: 1 });
934
+ const approved = await store.listApprovedUnexecuted(scope, { limit: 1 });
935
+ await expect(store.listPending(scope, { cursor: tampered(pending.cursor), limit: 1 })).rejects.toThrow(RangeError);
936
+ await expect(store.listApprovedUnexecuted(scope, { cursor: tampered(approved.cursor), limit: 1 })).rejects.toThrow(RangeError);
937
+ },
938
+ },
939
+ {
940
+ id: "paging/refuses-a-cursor-from-another-list",
941
+ title: "refuses a cursor minted for a different list",
942
+ async run({ store, expect, scope, entry }) {
943
+ // A cursor is bound to the list that produced it, so a pending cursor
944
+ // handed to the executor's list is a caller error and not a silently
945
+ // different page (DK-3).
946
+ await store.file(entry("entry-1"));
947
+ await store.file(entry("entry-2"));
948
+ const pending = await store.listPending(scope, { limit: 1 });
949
+ await expect(store.listApprovedUnexecuted(scope, { cursor: pending.cursor, limit: 1 })).rejects.toThrow(RangeError);
950
+ },
951
+ },
952
+ {
953
+ id: "paging/refuses-a-cursor-nobody-minted",
954
+ title: "refuses a cursor nobody minted",
955
+ async run({ store, expect, scope }) {
956
+ await expect(store.listPending(scope, { cursor: "not-a-cursor", limit: 1 })).rejects.toThrow(RangeError);
957
+ },
958
+ },
959
+ {
960
+ id: "paging/refuses-an-unbounded-page",
961
+ title: "refuses an unbounded page",
962
+ async run({ store, expect, scope }) {
963
+ await expect(store.listPending(scope, { limit: 0 })).rejects.toThrow(RangeError);
964
+ },
965
+ },
966
+ {
967
+ id: "paging/drops-a-row-the-moment-it-reads-expired",
968
+ title: "drops an entry out of the pending list the moment it reads expired",
969
+ async run({ store, clock, expect, scope, entry }) {
970
+ await store.file(entry("entry-1"));
971
+ expect((await store.listPending(scope, { limit: 10 })).items).toHaveLength(1);
972
+ clock.set(AFTER_DEADLINE);
973
+ expect((await store.listPending(scope, { limit: 10 })).items).toHaveLength(0);
974
+ },
975
+ },
976
+ {
977
+ id: "paging/a-full-page-with-nothing-left-says-so",
978
+ title: "says nothing remains when a page holds exactly the limit and drains the list (DK-3)",
979
+ async run({ store, expect, scope, entry }) {
980
+ // A client pages until `more` is false. A list that answered "a full page,
981
+ // therefore probably more" would hand back a cursor to an empty page every
982
+ // time the row count divided evenly by the page size.
983
+ await store.file(entry("pending-1"));
984
+ await store.file(entry("pending-2"));
985
+ await store.file(entry("approved-1", { status: "approved" }));
986
+ await store.file(entry("approved-2", { status: "approved" }));
987
+ const pending = await store.listPending(scope, { limit: 2 });
988
+ const approved = await store.listApprovedUnexecuted(scope, { limit: 2 });
989
+ expect(entryIds(pending.items)).toEqual(["pending-1", "pending-2"]);
990
+ expect(pending.more).toBe(false);
991
+ expect(pending.cursor).toBeNull();
992
+ expect(entryIds(approved.items)).toEqual(["approved-1", "approved-2"]);
993
+ expect(approved.more).toBe(false);
994
+ expect(approved.cursor).toBeNull();
995
+ },
996
+ },
997
+ {
998
+ id: "paging/a-conversation-narrows-every-list",
999
+ title: "narrows both lists to one conversation when the scope names one",
1000
+ async run({ store, expect, scope, entry, conversation }) {
1001
+ await store.file(entry("here-pending", { conversationId: "conv-2" }));
1002
+ await store.file(entry("elsewhere-pending", { conversationId: "conv-3" }));
1003
+ await store.file(entry("here-approved", { conversationId: "conv-2" }));
1004
+ await store.file(entry("elsewhere-approved", { conversationId: "conv-3" }));
1005
+ await store.transition("here-approved", scope, "pending", approval("here-approved"));
1006
+ await store.transition("elsewhere-approved", scope, "pending", approval("elsewhere-approved"));
1007
+ const narrowed = conversation("conv-2");
1008
+ expect(entryIds((await store.listPending(narrowed, { limit: 10 })).items)).toEqual([
1009
+ "here-pending",
1010
+ ]);
1011
+ expect(entryIds((await store.listApprovedUnexecuted(narrowed, { limit: 10 })).items)).toEqual(["here-approved"]);
1012
+ // The rows outside the conversation are still the tenant's.
1013
+ expect((await store.listPending(scope, { limit: 10 })).items).toHaveLength(2);
1014
+ expect((await store.listApprovedUnexecuted(scope, { limit: 10 })).items).toHaveLength(2);
1015
+ },
1016
+ },
1017
+ ],
1018
+ },
1019
+ {
1020
+ id: "retention",
1021
+ title: "retention ages out terminal entries in bounded pages (DK-4)",
1022
+ cases: [
1023
+ {
1024
+ id: "retention/removes-at-most-the-limit",
1025
+ title: "removes at most the limit and says whether more remain",
1026
+ async run({ store, clock, expect, scope, entry }) {
1027
+ for (const entryId of ["old-1", "old-2", "old-3"]) {
1028
+ await decide(store, clock, scope, entry, entryId, "reject", NOON);
1029
+ }
1030
+ clock.set(LATE);
1031
+ const first = await store.retention({ olderThan: LATE }, scope, 2);
1032
+ const second = await store.retention({ olderThan: LATE }, scope, 2);
1033
+ const third = await store.retention({ olderThan: LATE }, scope, 2);
1034
+ expect(first).toEqual({ removed: 2, more: true });
1035
+ expect(second).toEqual({ removed: 1, more: false });
1036
+ expect(third).toEqual({ removed: 0, more: false });
1037
+ expect(await exported(store, scope)).toHaveLength(0);
1038
+ },
1039
+ },
1040
+ {
1041
+ id: "retention/leaves-pending-and-newer-terminal-rows",
1042
+ title: "leaves a pending entry and a newer terminal one alone",
1043
+ async run({ store, clock, expect, scope, entry }) {
1044
+ await store.file(entry("still-open", { expiresAt: "2026-09-06T09:00:00.000Z" }));
1045
+ await decide(store, clock, scope, entry, "old", "reject", NOON);
1046
+ await decide(store, clock, scope, entry, "recent", "reject", "2026-09-05T08:59:59.000Z");
1047
+ clock.set(LATE);
1048
+ const result = await store.retention({ olderThan: "2026-09-04T12:00:00.000Z" }, scope, 10);
1049
+ expect(result).toEqual({ removed: 1, more: false });
1050
+ expect(entryIds(await exported(store, scope))).toEqual(["still-open", "recent"]);
1051
+ },
1052
+ },
1053
+ {
1054
+ id: "retention/never-ages-out-an-approved-unexecuted-row",
1055
+ title: "never ages out an approved write the executor has not reported on (AZ-5)",
1056
+ async run({ store, clock, expect, scope, entry }) {
1057
+ // It is the only record that a write was authorised and has not yet
1058
+ // happened, and the Docket is the sole record of approval authority.
1059
+ await store.file(entry("awaiting-executor", { expiresAt: "2026-09-06T23:59:00.000Z" }));
1060
+ await store.transition("awaiting-executor", scope, "pending", {
1061
+ status: "approved",
1062
+ decidedAt: NOON,
1063
+ });
1064
+ clock.set(LATE);
1065
+ const result = await store.retention({ olderThan: LATE }, scope, 10);
1066
+ expect(result).toEqual({ removed: 0, more: false });
1067
+ const row = await store.get("awaiting-executor", scope);
1068
+ expect(row).not.toBeNull();
1069
+ expect(row?.execution).toBe("unexecuted");
1070
+ },
1071
+ },
1072
+ {
1073
+ id: "retention/ages-out-a-row-that-expired-unswept",
1074
+ title: "ages out an entry that expired without ever being swept",
1075
+ async run({ store, clock, expect, scope, entry }) {
1076
+ clock.set(LATE);
1077
+ await store.file(entry("never-decided"));
1078
+ const result = await store.retention({ olderThan: LATE }, scope, 10);
1079
+ expect(result).toEqual({ removed: 1, more: false });
1080
+ },
1081
+ },
1082
+ {
1083
+ id: "retention/narrows-to-one-conversation",
1084
+ title: "removes only the named conversation's rows when the scope names one (DK-4, GT-2)",
1085
+ async run({ store, clock, expect, scope, entry, conversation }) {
1086
+ // Retention is scoped like every other operation, not by tenant alone: a
1087
+ // host ageing out one conversation's record would otherwise take the
1088
+ // tenant's whole Docket with it.
1089
+ const rows = [
1090
+ ["a-1", "conv-1"],
1091
+ ["a-2", "conv-1"],
1092
+ ["b-1", "conv-2"],
1093
+ ];
1094
+ for (const [entryId, conversationId] of rows) {
1095
+ await store.file(entry(entryId, {
1096
+ conversationId,
1097
+ filedAt: NOON,
1098
+ expiresAt: "2026-09-06T23:59:00.000Z",
1099
+ }));
1100
+ await store.transition(entryId, scope, "pending", {
1101
+ status: "rejected",
1102
+ decision: { kind: "reject", reason: null, at: NOON },
1103
+ decidedAt: NOON,
1104
+ });
1105
+ }
1106
+ clock.set(LATE);
1107
+ const result = await store.retention({ olderThan: LATE }, conversation("conv-1"), 10);
1108
+ expect(result).toEqual({ removed: 2, more: false });
1109
+ expect(entryIds(await exported(store, scope))).toEqual(["b-1"]);
1110
+ },
1111
+ },
1112
+ {
1113
+ id: "retention/keeps-a-row-whose-terminal-instant-is-the-cut",
1114
+ title: "keeps a row terminal at exactly the cut, and removes one strictly older (DK-4)",
1115
+ async run({ store, clock, expect, scope, entry }) {
1116
+ // The policy says *older than* the instant, which excludes the instant
1117
+ // itself. A cut that took the boundary row with it would age out a record
1118
+ // the host asked to keep, and there is no way to get it back.
1119
+ await decide(store, clock, scope, entry, "at-the-cut", "reject", NOON);
1120
+ await decide(store, clock, scope, entry, "before-the-cut", "reject", "2026-09-04T08:59:59.999Z");
1121
+ clock.set(LATE);
1122
+ const result = await store.retention({ olderThan: NOON }, scope, 10);
1123
+ expect(result).toEqual({ removed: 1, more: false });
1124
+ expect(entryIds(await exported(store, scope))).toEqual(["at-the-cut"]);
1125
+ },
1126
+ },
1127
+ {
1128
+ id: "retention/a-full-pass-with-nothing-left-says-so",
1129
+ title: "says nothing remains when a pass removed exactly its limit and drained the set (DK-4)",
1130
+ async run({ store, clock, expect, scope, entry }) {
1131
+ await decide(store, clock, scope, entry, "old-1", "reject", NOON);
1132
+ await decide(store, clock, scope, entry, "old-2", "reject", NOON);
1133
+ clock.set(LATE);
1134
+ const result = await store.retention({ olderThan: LATE }, scope, 2);
1135
+ expect(result).toEqual({ removed: 2, more: false });
1136
+ expect(await exported(store, scope)).toHaveLength(0);
1137
+ },
1138
+ },
1139
+ {
1140
+ id: "retention/refuses-an-unbounded-pass",
1141
+ title: "refuses an unbounded retention pass",
1142
+ async run({ store, clock, expect, scope }) {
1143
+ clock.set(LATE);
1144
+ await expect(store.retention({ olderThan: LATE }, scope, 0)).rejects.toThrow(RangeError);
1145
+ await expect(store.retention({ olderThan: "whenever" }, scope, 5)).rejects.toThrow(RangeError);
1146
+ },
1147
+ },
1148
+ ],
1149
+ },
1150
+ {
1151
+ id: "purge",
1152
+ title: "purge removes a tenant and nothing else (DK-4)",
1153
+ cases: [
1154
+ {
1155
+ id: "purge/removes-the-tenant-and-nothing-else",
1156
+ title: "removes every row the tenant has and leaves the others untouched",
1157
+ async run({ store, expect, scope, otherScope, entry }) {
1158
+ await store.file(entry("a-1"));
1159
+ await store.file(entry("a-2"));
1160
+ await store.file(entry("b-1", { tenantId: otherScope.tenantId }));
1161
+ const purged = await store.purge(scope.tenantId);
1162
+ expect(purged).toEqual({ removed: 2 });
1163
+ expect(await exported(store, scope)).toHaveLength(0);
1164
+ expect(entryIds(await exported(store, otherScope))).toEqual(["b-1"]);
1165
+ },
1166
+ },
1167
+ {
1168
+ id: "purge/spans-every-conversation-in-the-tenant",
1169
+ title: "removes the tenant's rows from every conversation, not just one",
1170
+ async run({ store, expect, scope, otherScope, entry }) {
1171
+ // A purge takes a tenant id and not a {@link Scope} because there is no such
1172
+ // thing as purging half a tenant (DK-4). A store that narrowed it to one
1173
+ // conversation would answer a deletion request with a partial deletion and
1174
+ // report it as done.
1175
+ await store.file(entry("here-1", { conversationId: "conv-1" }));
1176
+ await store.file(entry("here-2", { conversationId: "conv-2" }));
1177
+ await store.file(entry("here-3", { conversationId: "conv-3" }));
1178
+ await store.file(entry("elsewhere", { tenantId: otherScope.tenantId }));
1179
+ expect(await store.purge(scope.tenantId)).toEqual({ removed: 3 });
1180
+ expect(await exported(store, scope)).toHaveLength(0);
1181
+ for (const conversationId of ["conv-1", "conv-2", "conv-3"]) {
1182
+ expect(await exported(store, { tenantId: scope.tenantId, conversationId })).toHaveLength(0);
1183
+ }
1184
+ expect(entryIds(await exported(store, otherScope))).toEqual(["elsewhere"]);
1185
+ },
1186
+ },
1187
+ {
1188
+ id: "purge/is-a-no-op-for-an-empty-tenant",
1189
+ title: "is a no-op for a tenant that has filed nothing",
1190
+ async run({ store, expect, scope }) {
1191
+ expect(await store.purge(`${scope.tenantId}#nobody`)).toEqual({ removed: 0 });
1192
+ },
1193
+ },
1194
+ ],
1195
+ },
1196
+ {
1197
+ id: "export",
1198
+ title: "export streams the Docket in filing order (DK-4)",
1199
+ cases: [
1200
+ {
1201
+ id: "export/yields-every-row-oldest-first",
1202
+ title: "yields every row of the scope, oldest first",
1203
+ async run({ store, expect, scope, entry }) {
1204
+ await store.file(entry("first"));
1205
+ await store.file(entry("second", { status: "approved" }));
1206
+ await store.file(entry("third", { status: "rejected" }));
1207
+ expect(entryIds(await exported(store, scope))).toEqual(["first", "second", "third"]);
1208
+ },
1209
+ },
1210
+ {
1211
+ id: "export/narrows-to-one-conversation",
1212
+ title: "narrows to one conversation when the scope names one",
1213
+ async run({ store, expect, entry, conversation }) {
1214
+ await store.file(entry("in-1"));
1215
+ await store.file(entry("elsewhere", { conversationId: "conv-2" }));
1216
+ await store.file(entry("in-2"));
1217
+ const rows = await exported(store, conversation("conv-1"));
1218
+ expect(entryIds(rows)).toEqual(["in-1", "in-2"]);
1219
+ },
1220
+ },
1221
+ {
1222
+ id: "export/applies-the-deadline",
1223
+ title: "applies the deadline to what it yields",
1224
+ async run({ store, clock, expect, scope, entry }) {
1225
+ await store.file(entry("entry-1"));
1226
+ clock.set(AFTER_DEADLINE);
1227
+ const rows = await exported(store, scope);
1228
+ expect(rows[0]?.status).toBe("expired");
1229
+ },
1230
+ },
1231
+ {
1232
+ id: "export/yields-nothing-for-an-empty-tenant",
1233
+ title: "yields nothing for a tenant that has filed nothing",
1234
+ async run({ store, expect, otherScope }) {
1235
+ expect(await exported(store, otherScope)).toHaveLength(0);
1236
+ },
1237
+ },
1238
+ {
1239
+ id: "export/the-boundary-instant-reads-expired",
1240
+ title: "yields a row at exactly its deadline as expired (DK-1, DK-4)",
1241
+ async run({ store, clock, expect, scope, entry }) {
1242
+ await store.file(entry("at-the-deadline"));
1243
+ clock.set(DEADLINE);
1244
+ const rows = await exported(store, scope);
1245
+ expect(rows).toHaveLength(1);
1246
+ expect(rows[0]?.status).toBe("expired");
1247
+ expect(rows[0]?.decidedAt).toBe(DEADLINE);
1248
+ },
1249
+ },
1250
+ {
1251
+ id: "export/yields-the-scope-and-nothing-outside-it",
1252
+ title: "yields every row the scope covers and no row outside it",
1253
+ async run({ store, expect, scope, otherScope, entry, conversation }) {
1254
+ // Both halves matter. A store that yielded a subset would let a tenant
1255
+ // asking for their record receive part of it and be told it was all of it;
1256
+ // a store that yielded a superset would hand them somebody else's.
1257
+ await store.file(entry("in-1"));
1258
+ await store.file(entry("in-2", { status: "approved" }));
1259
+ await store.file(entry("in-3", { status: "rejected" }));
1260
+ await store.file(entry("other-conversation", { conversationId: "conv-9" }));
1261
+ await store.file(entry("other-tenant", { tenantId: otherScope.tenantId }));
1262
+ expect(entryIds(await exported(store, scope))).toEqual([
1263
+ "in-1",
1264
+ "in-2",
1265
+ "in-3",
1266
+ "other-conversation",
1267
+ ]);
1268
+ expect(entryIds(await exported(store, conversation("conv-1")))).toEqual([
1269
+ "in-1",
1270
+ "in-2",
1271
+ "in-3",
1272
+ ]);
1273
+ expect(entryIds(await exported(store, otherScope))).toEqual(["other-tenant"]);
1274
+ },
1275
+ },
1276
+ ],
1277
+ },
1278
+ {
1279
+ id: "tenancy",
1280
+ title: "a tenant mismatch is a miss, not another tenant's row (AZ-2)",
1281
+ cases: [
1282
+ {
1283
+ id: "tenancy/get-in-the-wrong-tenant-is-null",
1284
+ title: "returns null for the right id in the wrong tenant",
1285
+ async run({ store, expect, scope, otherScope, entry }) {
1286
+ // A caller outside the tenant learns nothing about whether the id exists:
1287
+ // the answer is the same one a missing id gets.
1288
+ await store.file(entry("entry-1"));
1289
+ expect(await store.get("entry-1", scope)).not.toBeNull();
1290
+ expect(await store.get("entry-1", otherScope)).toBeNull();
1291
+ expect(await store.get("no-such-entry", otherScope)).toBeNull();
1292
+ },
1293
+ },
1294
+ {
1295
+ id: "tenancy/two-tenants-keep-the-same-id-apart",
1296
+ title: "keeps two tenants' entries with the same id apart",
1297
+ async run({ store, expect, scope, otherScope, entry }) {
1298
+ await store.file(entry("shared-id", { conversationId: "conv-a" }));
1299
+ await store.file(entry("shared-id", { tenantId: otherScope.tenantId, conversationId: "conv-b" }));
1300
+ expect((await store.get("shared-id", scope))?.conversationId).toBe("conv-a");
1301
+ expect((await store.get("shared-id", otherScope))?.conversationId).toBe("conv-b");
1302
+ },
1303
+ },
1304
+ {
1305
+ id: "tenancy/every-write-from-the-wrong-tenant-is-not-found",
1306
+ title: "refuses every write from the wrong tenant as not-found",
1307
+ async run({ store, expect, scope, otherScope, entry }) {
1308
+ await store.file(entry("entry-1"));
1309
+ expect(await store.transition("entry-1", otherScope, "pending", { status: "rejected" })).toBe("not-found");
1310
+ expect(await store.preserveAmendments("entry-1", otherScope, {}, { at: NOON, by: "person-7" })).toBe("not-found");
1311
+ expect(await store.recordExecution("entry-1", otherScope, "executed", null, "unexecuted")).toBe("not-found");
1312
+ expect(await store.recordSupersession("entry-1", otherScope, "entry-2")).toBe("not-found");
1313
+ expect((await store.get("entry-1", scope))?.status).toBe("pending");
1314
+ },
1315
+ },
1316
+ {
1317
+ id: "tenancy/transition-under-another-conversation-is-not-found",
1318
+ title: "refuses a transition whose scope names another conversation (GT-2)",
1319
+ async run({ store, expect, scope, entry, conversation }) {
1320
+ // A scope that names a conversation narrows every operation, writes
1321
+ // included. A store that matched on the tenant alone would let a session
1322
+ // scoped to one conversation decide another one's row.
1323
+ await store.file(entry("entry-1", { conversationId: "conv-1" }));
1324
+ expect(await store.transition("entry-1", conversation("conv-9"), "pending", {
1325
+ status: "rejected",
1326
+ })).toBe("not-found");
1327
+ expect((await store.get("entry-1", scope))?.status).toBe("pending");
1328
+ },
1329
+ },
1330
+ {
1331
+ id: "tenancy/preserve-amendments-under-another-conversation-is-not-found",
1332
+ title: "refuses preserved amendments whose scope names another conversation (GT-2)",
1333
+ async run({ store, clock, expect, scope, entry, conversation }) {
1334
+ await store.file(entry("entry-1", { conversationId: "conv-1" }));
1335
+ clock.set(AFTER_DEADLINE);
1336
+ expect(await store.preserveAmendments("entry-1", conversation("conv-9"), { status: "paid" }, {
1337
+ at: AFTER_DEADLINE,
1338
+ by: "person-7",
1339
+ })).toBe("not-found");
1340
+ expect((await store.get("entry-1", scope))?.preservedAmendments).toBeNull();
1341
+ },
1342
+ },
1343
+ {
1344
+ id: "tenancy/record-execution-under-another-conversation-is-not-found",
1345
+ title: "refuses an execution report whose scope names another conversation (GT-2)",
1346
+ async run({ store, expect, scope, entry, conversation }) {
1347
+ await store.file(entry("entry-1", { conversationId: "conv-1" }));
1348
+ await store.transition("entry-1", scope, "pending", approval("entry-1"));
1349
+ expect(await store.recordExecution("entry-1", conversation("conv-9"), "executed", null, "unexecuted")).toBe("not-found");
1350
+ expect((await store.get("entry-1", scope))?.execution).toBe("unexecuted");
1351
+ },
1352
+ },
1353
+ {
1354
+ id: "tenancy/record-supersession-under-another-conversation-is-not-found",
1355
+ title: "refuses a supersession whose scope names another conversation (GT-2)",
1356
+ async run({ store, expect, scope, entry, conversation }) {
1357
+ await store.file(entry("entry-1", { conversationId: "conv-1" }));
1358
+ await store.transition("entry-1", scope, "pending", {
1359
+ status: "rejected",
1360
+ decision: { kind: "reject", reason: null, at: NOON },
1361
+ });
1362
+ expect(await store.recordSupersession("entry-1", conversation("conv-9"), "entry-2")).toBe("not-found");
1363
+ expect((await store.get("entry-1", scope))?.lineage.supersededBy).toBeNull();
1364
+ },
1365
+ },
1366
+ {
1367
+ id: "tenancy/lists-and-exports-nothing-for-the-wrong-tenant",
1368
+ title: "lists and exports nothing for the wrong tenant",
1369
+ async run({ store, expect, otherScope, entry }) {
1370
+ await store.file(entry("entry-1"));
1371
+ await store.file(entry("entry-2", { status: "approved" }));
1372
+ expect((await store.listPending(otherScope, { limit: 10 })).items).toHaveLength(0);
1373
+ expect((await store.listApprovedUnexecuted(otherScope, { limit: 10 })).items).toHaveLength(0);
1374
+ expect(await exported(store, otherScope)).toHaveLength(0);
1375
+ expect(await store.expireDue("2027-01-01T00:00:00.000Z", otherScope, 10)).toEqual({
1376
+ expired: [],
1377
+ more: false,
1378
+ });
1379
+ },
1380
+ },
1381
+ {
1382
+ id: "tenancy/narrows-to-one-conversation-within-the-tenant",
1383
+ title: "narrows to one conversation within the tenant",
1384
+ async run({ store, expect, entry, conversation }) {
1385
+ await store.file(entry("entry-1", { conversationId: "conv-1" }));
1386
+ await store.file(entry("entry-2", { conversationId: "conv-2" }));
1387
+ const narrowed = conversation("conv-2");
1388
+ expect(await store.get("entry-1", narrowed)).toBeNull();
1389
+ expect(entryIds((await store.listPending(narrowed, { limit: 10 })).items)).toEqual([
1390
+ "entry-2",
1391
+ ]);
1392
+ },
1393
+ },
1394
+ ],
1395
+ },
1396
+ ];
1397
+ /** `count` entries filed in filing order, all of them due at {@link AFTER_DEADLINE}. */
1398
+ async function fileDueEntries(store, entry, count) {
1399
+ for (let index = 1; index <= count; index += 1) {
1400
+ await store.file(entry(`entry-${index}`, { filedAt: `2026-09-04T09:00:0${index}.000Z` }));
1401
+ }
1402
+ }
1403
+ /**
1404
+ * An entry filed and decided at `at` — and, for an approval, reported on by the
1405
+ * executor, so that it is eligible for retention at all (AZ-5).
1406
+ *
1407
+ * The clock is moved to `at` for the decision and left there; a caller ages the
1408
+ * store forward afterwards.
1409
+ */
1410
+ async function decide(store, clock, scope, entry, entryId, kind, at) {
1411
+ clock.set(at);
1412
+ await store.file(entry(entryId, { filedAt: at, expiresAt: "2026-09-06T23:59:00.000Z" }));
1413
+ await store.transition(entryId, scope, "pending", {
1414
+ status: kind === "approve" ? "approved" : "rejected",
1415
+ decision: { kind, reason: null, at },
1416
+ decidedAt: at,
1417
+ });
1418
+ if (kind === "approve") {
1419
+ await store.recordExecution(entryId, scope, "executed", null, "unexecuted");
1420
+ }
1421
+ }
1422
+ /** The order a mixed Docket rehydrates in. */
1423
+ const REHYDRATION_ORDER = ["pending-1", "pending-2", "approved-1", "approved-2"];
1424
+ /** A Docket holding, in filing order: pending, approved-executed, approved-unexecuted, rejected. */
1425
+ async function fileMixedDocket(store, scope, entry) {
1426
+ await store.file(entry("pending-1"));
1427
+ await store.file(entry("approved-executed", { status: "approved" }));
1428
+ await store.file(entry("approved-1", { status: "approved" }));
1429
+ await store.file(entry("rejected-1", { status: "rejected" }));
1430
+ await store.file(entry("pending-2"));
1431
+ await store.file(entry("approved-2", { status: "approved" }));
1432
+ await store.recordExecution("approved-executed", scope, "executed", null, "unexecuted");
1433
+ }
1434
+ /** Every page of the rehydration sequence, walked with `limit`-sized pages. */
1435
+ async function walkRehydration(store, scope, expect, limit) {
1436
+ const out = [];
1437
+ let page = { limit };
1438
+ for (;;) {
1439
+ const result = await store.rehydrate(scope, page);
1440
+ out.push(...result.items);
1441
+ if (!result.more) {
1442
+ expect(result.cursor).toBeNull();
1443
+ break;
1444
+ }
1445
+ expect(result.cursor).not.toBeNull();
1446
+ page = { cursor: result.cursor, limit };
1447
+ }
1448
+ return entryIds(out);
1449
+ }
1450
+ const SESSION_SECTIONS = [
1451
+ {
1452
+ id: "rehydration",
1453
+ title: "rehydration order (DK-5)",
1454
+ cases: [
1455
+ {
1456
+ id: "rehydration/pending-before-approved-unexecuted",
1457
+ title: "returns pending entries before approved and unexecuted ones",
1458
+ async run({ store, expect, scope, entry }) {
1459
+ await fileMixedDocket(store, scope, entry);
1460
+ const page = await store.rehydrate(scope, { limit: 10 });
1461
+ expect(entryIds(page.items)).toEqual(REHYDRATION_ORDER);
1462
+ expect(page.more).toBe(false);
1463
+ },
1464
+ },
1465
+ {
1466
+ id: "rehydration/leaves-out-settled-rows",
1467
+ title: "leaves out rows that need neither a decision nor execution",
1468
+ async run({ store, expect, scope, entry }) {
1469
+ await fileMixedDocket(store, scope, entry);
1470
+ const page = await store.rehydrate(scope, { limit: 10 });
1471
+ expect(entryIds(page.items)).not.toContain("rejected-1");
1472
+ expect(entryIds(page.items)).not.toContain("approved-executed");
1473
+ },
1474
+ },
1475
+ {
1476
+ id: "rehydration/holds-the-order-at-every-page-size",
1477
+ title: "holds the order at every page size",
1478
+ async run({ store, expect, scope, entry }) {
1479
+ await fileMixedDocket(store, scope, entry);
1480
+ for (const limit of [1, 2, 3, 4, 5, 10]) {
1481
+ expect(await walkRehydration(store, scope, expect, limit)).toEqual(REHYDRATION_ORDER);
1482
+ }
1483
+ },
1484
+ },
1485
+ {
1486
+ id: "rehydration/resumes-in-the-second-group",
1487
+ title: "resumes in the second group when a page boundary drains the first",
1488
+ async run({ store, expect, scope, entry }) {
1489
+ await fileMixedDocket(store, scope, entry);
1490
+ const first = await store.rehydrate(scope, { limit: 2 });
1491
+ const second = await store.rehydrate(scope, { cursor: first.cursor, limit: 2 });
1492
+ expect(entryIds(first.items)).toEqual(["pending-1", "pending-2"]);
1493
+ expect(first.more).toBe(true);
1494
+ expect(entryIds(second.items)).toEqual(["approved-1", "approved-2"]);
1495
+ expect(second.more).toBe(false);
1496
+ },
1497
+ },
1498
+ {
1499
+ id: "rehydration/drops-a-row-that-expired-while-away",
1500
+ title: "drops an entry that expired while the client was away",
1501
+ async run({ store, clock, expect, scope, entry }) {
1502
+ await store.file(entry("pending-1"));
1503
+ await store.file(entry("approved-1", { status: "approved" }));
1504
+ clock.set(AFTER_DEADLINE);
1505
+ const page = await store.rehydrate(scope, { limit: 10 });
1506
+ expect(entryIds(page.items)).toEqual(["approved-1"]);
1507
+ },
1508
+ },
1509
+ {
1510
+ id: "rehydration/the-boundary-instant-leaves-the-pending-group",
1511
+ title: "leaves a row out of the pending group at exactly its deadline (DK-1, DK-5)",
1512
+ async run({ store, clock, expect, scope, entry }) {
1513
+ // The deadline is inclusive of the instant itself, and rehydration reports
1514
+ // what a row reads as rather than what it says: a client reconnecting on the
1515
+ // millisecond of the deadline is not offered a decision it can no longer
1516
+ // make.
1517
+ await store.file(entry("pending-1"));
1518
+ await store.file(entry("approved-1", { status: "approved" }));
1519
+ clock.set(DEADLINE);
1520
+ const page = await store.rehydrate(scope, { limit: 10 });
1521
+ expect(entryIds(page.items)).toEqual(["approved-1"]);
1522
+ },
1523
+ },
1524
+ {
1525
+ id: "rehydration/narrows-to-one-conversation",
1526
+ title: "rehydrates one conversation when the scope names one",
1527
+ async run({ store, expect, scope, entry, conversation }) {
1528
+ await store.file(entry("here", { conversationId: "conv-2" }));
1529
+ await store.file(entry("elsewhere", { conversationId: "conv-3" }));
1530
+ await store.file(entry("here-approved", { conversationId: "conv-2", status: "approved" }));
1531
+ const page = await store.rehydrate(conversation("conv-2"), { limit: 10 });
1532
+ expect(entryIds(page.items)).toEqual(["here", "here-approved"]);
1533
+ // The row in the other conversation is still the tenant's to rehydrate.
1534
+ expect(entryIds((await store.rehydrate(scope, { limit: 10 })).items)).toEqual([
1535
+ "here",
1536
+ "elsewhere",
1537
+ "here-approved",
1538
+ ]);
1539
+ },
1540
+ },
1541
+ {
1542
+ id: "rehydration/a-full-page-with-nothing-left-says-so",
1543
+ title: "says nothing remains when a page holds exactly the limit and drains the sequence",
1544
+ async run({ store, expect, scope, entry }) {
1545
+ // A reconnecting client pages until `more` is false; a sequence whose
1546
+ // length divides evenly by the page size must still say it is drained.
1547
+ await store.file(entry("pending-1"));
1548
+ await store.file(entry("approved-1", { status: "approved" }));
1549
+ const page = await store.rehydrate(scope, { limit: 2 });
1550
+ expect(entryIds(page.items)).toEqual(["pending-1", "approved-1"]);
1551
+ expect(page.more).toBe(false);
1552
+ expect(page.cursor).toBeNull();
1553
+ },
1554
+ },
1555
+ {
1556
+ id: "rehydration/refuses-a-tampered-cursor",
1557
+ title: "refuses a cursor a caller altered by one character (DK-3)",
1558
+ async run({ store, expect, scope, entry }) {
1559
+ await fileMixedDocket(store, scope, entry);
1560
+ const page = await store.rehydrate(scope, { limit: 1 });
1561
+ await expect(store.rehydrate(scope, { cursor: tampered(page.cursor), limit: 1 })).rejects.toThrow(RangeError);
1562
+ },
1563
+ },
1564
+ {
1565
+ id: "rehydration/nothing-outstanding",
1566
+ title: "returns nothing for a session with nothing outstanding",
1567
+ async run({ store, expect, scope }) {
1568
+ const page = await store.rehydrate(scope, { limit: 10 });
1569
+ expect(page).toEqual({ items: [], cursor: null, more: false });
1570
+ },
1571
+ },
1572
+ {
1573
+ id: "rehydration/refuses-a-foreign-cursor-and-an-unbounded-page",
1574
+ title: "refuses a cursor from another list and an unbounded page",
1575
+ async run({ store, expect, scope, entry }) {
1576
+ await fileMixedDocket(store, scope, entry);
1577
+ const pending = await store.listPending(scope, { limit: 1 });
1578
+ await expect(store.rehydrate(scope, { cursor: pending.cursor, limit: 2 })).rejects.toThrow(RangeError);
1579
+ await expect(store.rehydrate(scope, { limit: 0 })).rejects.toThrow(RangeError);
1580
+ },
1581
+ },
1582
+ ],
1583
+ },
1584
+ ];
1585
+ // ---------------------------------------------------------------------------
1586
+ // Registration
1587
+ // ---------------------------------------------------------------------------
1588
+ /** Every block {@link runDocketStoreContract} registers, in registration order. */
1589
+ export const DOCKET_CONTRACT_SECTIONS = DOCKET_SECTIONS.map((section) => section.id);
1590
+ /** Every block {@link runSessionStoreContract} registers, in registration order. */
1591
+ export const SESSION_CONTRACT_SECTIONS = SESSION_SECTIONS.map((section) => section.id);
1592
+ /** Every case {@link runDocketStoreContract} registers, in registration order. */
1593
+ export const DOCKET_CONTRACT_CASES = DOCKET_SECTIONS.flatMap((section) => section.cases.map((one) => ({
1594
+ id: one.id,
1595
+ section: section.id,
1596
+ block: section.title,
1597
+ title: one.title,
1598
+ })));
1599
+ /** Every case {@link runSessionStoreContract} registers, in registration order. */
1600
+ export const SESSION_CONTRACT_CASES = SESSION_SECTIONS.flatMap((section) => section.cases.map((one) => ({
1601
+ id: one.id,
1602
+ section: section.id,
1603
+ block: section.title,
1604
+ title: one.title,
1605
+ })));
1606
+ /**
1607
+ * Register `sections` against `factory`, one block per section.
1608
+ *
1609
+ * A `skip` or `sections` entry naming nothing is a {@link RangeError} rather than a
1610
+ * silent no-op: a store that stopped running a case because its id was misspelled
1611
+ * would report a green contract run it never made.
1612
+ */
1613
+ function registerContract(sections, factory, options) {
1614
+ const { api } = options;
1615
+ const known = new Set(sections.flatMap((section) => section.cases.map((one) => one.id)));
1616
+ for (const id of options.skip ?? []) {
1617
+ if (!known.has(id)) {
1618
+ throw new RangeError(`skip names ${id}, which is not a case of this contract`);
1619
+ }
1620
+ }
1621
+ const sectionIds = new Set(sections.map((section) => section.id));
1622
+ for (const id of options.sections ?? []) {
1623
+ if (!sectionIds.has(id)) {
1624
+ throw new RangeError(`sections names ${id}, which is not a section of this contract`);
1625
+ }
1626
+ }
1627
+ const selected = options.sections === undefined
1628
+ ? sections
1629
+ : sections.filter((section) => options.sections?.includes(section.id) === true);
1630
+ const skipped = new Set(options.skip ?? []);
1631
+ const label = options.name === undefined ? "" : `${options.name}: `;
1632
+ for (const section of selected) {
1633
+ const cases = section.cases.filter((one) => !skipped.has(one.id));
1634
+ if (cases.length === 0)
1635
+ continue;
1636
+ api.describe(`${label}${section.title}`, () => {
1637
+ // One store per block, not per case: a store backed by a database is
1638
+ // expensive to build, and a second instance would share the first one's rows
1639
+ // anyway. Cases are kept apart by tenancy, which the contract requires in any
1640
+ // event (AZ-2).
1641
+ const held = { store: null };
1642
+ const clock = stubClock(NOON);
1643
+ api.beforeAll(async () => {
1644
+ held.store = await factory(clock);
1645
+ });
1646
+ api.afterAll(async () => {
1647
+ const store = held.store;
1648
+ held.store = null;
1649
+ if (store !== null && options.dispose !== undefined)
1650
+ await options.dispose(store);
1651
+ });
1652
+ for (const one of cases) {
1653
+ api.it(one.title, async () => {
1654
+ const store = held.store;
1655
+ if (store === null) {
1656
+ throw new Error(`the store factory produced nothing for ${one.id}`);
1657
+ }
1658
+ clock.set(NOON);
1659
+ await one.run(caseContext(store, clock, api.expect, one.id));
1660
+ });
1661
+ }
1662
+ });
1663
+ }
1664
+ }
1665
+ /** What a case is handed: its own tenant, the harness's clock and the runner's `expect`. */
1666
+ function caseContext(store, clock, expect, caseId) {
1667
+ const tenantId = caseId;
1668
+ const otherTenantId = `${caseId}#other`;
1669
+ return {
1670
+ store,
1671
+ clock,
1672
+ expect,
1673
+ scope: { tenantId },
1674
+ otherScope: { tenantId: otherTenantId },
1675
+ conversation: (conversationId) => ({ tenantId, conversationId }),
1676
+ entry: (entryId, overrides = {}) => sampleEntry(entryId, { tenantId, ...overrides }),
1677
+ };
1678
+ }
1679
+ /**
1680
+ * Register the Docket store contract against `factory`.
1681
+ *
1682
+ * The factory is called once per block with the harness's clock, and may be async —
1683
+ * a store backed by a database is built from a connection the caller opened. Pass
1684
+ * `dispose` to release it when the block ends.
1685
+ */
1686
+ export function runDocketStoreContract(factory, options) {
1687
+ registerContract(DOCKET_SECTIONS, factory, options);
1688
+ }
1689
+ /**
1690
+ * Register the rehydration contract (DK-5) against `factory`.
1691
+ *
1692
+ * The factory returns one object that is both the Docket and the rehydration
1693
+ * surface over it, because the cases file the rows they then rehydrate. A pair of
1694
+ * separate reference objects is joined by {@link withSessionStore}.
1695
+ */
1696
+ export function runSessionStoreContract(factory, options) {
1697
+ registerContract(SESSION_SECTIONS, factory, options);
1698
+ }
1699
+ //# sourceMappingURL=testing-store.js.map