@substrat-run/contract-tests 0.148.0 → 0.149.0

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.
Files changed (72) hide show
  1. package/dist/counter-runner.d.ts +3 -3
  2. package/dist/counter-runner.js +4 -4
  3. package/dist/counter-runner.js.map +1 -1
  4. package/dist/directory-restore-suite.d.ts.map +1 -1
  5. package/dist/directory-restore-suite.js +66 -2
  6. package/dist/directory-restore-suite.js.map +1 -1
  7. package/dist/directory-shapes.d.ts +6 -0
  8. package/dist/directory-shapes.d.ts.map +1 -1
  9. package/dist/directory-shapes.js +42 -0
  10. package/dist/directory-shapes.js.map +1 -1
  11. package/dist/entity-state-module.d.ts.map +1 -1
  12. package/dist/entity-state-module.js +3 -0
  13. package/dist/entity-state-module.js.map +1 -1
  14. package/dist/entity-trash-suite.d.ts.map +1 -1
  15. package/dist/entity-trash-suite.js +20 -0
  16. package/dist/entity-trash-suite.js.map +1 -1
  17. package/dist/held-migrations.d.ts +27 -0
  18. package/dist/held-migrations.d.ts.map +1 -0
  19. package/dist/held-migrations.js +39 -0
  20. package/dist/held-migrations.js.map +1 -0
  21. package/dist/index.d.ts +8 -2
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +9 -3
  24. package/dist/index.js.map +1 -1
  25. package/dist/migration-fence-suite.d.ts +3 -0
  26. package/dist/migration-fence-suite.d.ts.map +1 -0
  27. package/dist/migration-fence-suite.js +173 -0
  28. package/dist/migration-fence-suite.js.map +1 -0
  29. package/dist/migration-fence.d.ts +42 -0
  30. package/dist/migration-fence.d.ts.map +1 -0
  31. package/dist/migration-fence.js +66 -0
  32. package/dist/migration-fence.js.map +1 -0
  33. package/dist/modules.d.ts +2 -0
  34. package/dist/modules.d.ts.map +1 -1
  35. package/dist/modules.js +19 -0
  36. package/dist/modules.js.map +1 -1
  37. package/dist/orphan-sweep-suite.d.ts +23 -0
  38. package/dist/orphan-sweep-suite.d.ts.map +1 -0
  39. package/dist/orphan-sweep-suite.js +411 -0
  40. package/dist/orphan-sweep-suite.js.map +1 -0
  41. package/dist/scope-host-suite.d.ts.map +1 -1
  42. package/dist/scope-host-suite.js +357 -20
  43. package/dist/scope-host-suite.js.map +1 -1
  44. package/dist/scope-store-runner-suite.d.ts +7 -7
  45. package/dist/scope-store-runner-suite.d.ts.map +1 -1
  46. package/dist/scope-store-runner-suite.js +7 -7
  47. package/dist/scope-store-runner-suite.js.map +1 -1
  48. package/dist/scope-store-suite.js +11 -11
  49. package/dist/scope-store-suite.js.map +1 -1
  50. package/dist/store-driver-release-suite.d.ts +12 -0
  51. package/dist/store-driver-release-suite.d.ts.map +1 -0
  52. package/dist/store-driver-release-suite.js +561 -0
  53. package/dist/store-driver-release-suite.js.map +1 -0
  54. package/dist/upload-link-module.d.ts +4 -0
  55. package/dist/upload-link-module.d.ts.map +1 -1
  56. package/dist/upload-link-module.js +66 -4
  57. package/dist/upload-link-module.js.map +1 -1
  58. package/dist/upload-link-replay-suite.d.ts +3 -0
  59. package/dist/upload-link-replay-suite.d.ts.map +1 -0
  60. package/dist/upload-link-replay-suite.js +291 -0
  61. package/dist/upload-link-replay-suite.js.map +1 -0
  62. package/dist/upload-link-suite.d.ts.map +1 -1
  63. package/dist/upload-link-suite.js +161 -4
  64. package/dist/upload-link-suite.js.map +1 -1
  65. package/dist/vertical-events-suite.d.ts.map +1 -1
  66. package/dist/vertical-events-suite.js +46 -4
  67. package/dist/vertical-events-suite.js.map +1 -1
  68. package/package.json +3 -3
  69. package/dist/store-kind-release-suite.d.ts +0 -12
  70. package/dist/store-kind-release-suite.d.ts.map +0 -1
  71. package/dist/store-kind-release-suite.js +0 -353
  72. package/dist/store-kind-release-suite.js.map +0 -1
@@ -0,0 +1,561 @@
1
+ /**
2
+ * Contract suites for a store driver's releases, rollouts and holds (D-63, #2300).
3
+ *
4
+ * `storeDriverReleaseContractSuite` runs on both adapters: a stored release is insert-only, a
5
+ * rollback returns to the immediately previous good release (recorded, never derived from an
6
+ * ordering), a hold excludes what it must, and a rollout whose holder stopped reads as
7
+ * `diverged` to the next one.
8
+ *
9
+ * `storeDriverHoldExpiryContractSuite` is the transition the wall clock cannot show without
10
+ * waiting: a hold lapses when the HOST's clock passes it. It is mounted on the pure host, which
11
+ * takes a clock. The Durable Object host reads `Date.now()` inside the control-plane object and
12
+ * no option reaches it (#956), the same parity gap `grantExpiryContractSuite` writes down.
13
+ */
14
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
15
+ import { platformActorId } from '@substrat-run/contracts';
16
+ import { ulid } from '@substrat-run/kernel';
17
+ import { expectRefusal } from './scope-host-suite.js';
18
+ const sha = (c) => c.repeat(64);
19
+ const commit = (c) => c.repeat(40);
20
+ /** A driver id of its own per test, so tests sharing one directory never meet. */
21
+ const freshDriver = () => `k${ulid().toLowerCase().slice(-10)}`;
22
+ export function storeDriverReleaseContractSuite(adapterName, makeFixture) {
23
+ describe(`store-driver releases (D-63, #2300): ${adapterName}`, () => {
24
+ let fixture;
25
+ let host;
26
+ const staff = platformActorId.parse(ulid());
27
+ beforeAll(async () => {
28
+ fixture = await makeFixture();
29
+ host = fixture.host;
30
+ });
31
+ afterAll(async () => fixture.cleanup());
32
+ /** Store `shas` and roll each out to good, in order, as one holder. */
33
+ const rollOut = async (driver, holder, ...shas) => {
34
+ expect(await host.admin.holdStoreDriver(staff, { driver, holder, mode: 'exclusive', ttlMs: 60_000 })).toBe(true);
35
+ const rows = [];
36
+ for (const s of shas) {
37
+ await host.admin.recordStoreDriverRelease(staff, { driver, sha256: s, sourceCommit: commit('c') });
38
+ const row = await host.admin.beginStoreDriverRollout(staff, { driver, sha256: s, action: 'rollout', holder });
39
+ rows.push(await host.admin.finishStoreDriverRollout(staff, { driver, seq: row.seq, holder, state: 'good', detail: null }));
40
+ }
41
+ return rows;
42
+ };
43
+ it('stores a release once, keeps who uploaded it, and refuses the same bytes from another commit', async () => {
44
+ const driver = freshDriver();
45
+ const first = await host.admin.recordStoreDriverRelease(staff, { driver, sha256: sha('a'), sourceCommit: commit('1') });
46
+ expect(first).toMatchObject({ driver, sha256: sha('a'), sourceCommit: commit('1'), uploadedBy: staff });
47
+ const other = platformActorId.parse(ulid());
48
+ // The same release again is the row as it stands: the uploader is not rewritten.
49
+ expect(await host.admin.recordStoreDriverRelease(other, { driver, sha256: sha('a'), sourceCommit: commit('1') })).toEqual(first);
50
+ await expectRefusal(host.admin.recordStoreDriverRelease(staff, { driver, sha256: sha('a'), sourceCommit: commit('2') }), 'conflict');
51
+ expect(await host.admin.getStoreDriverRelease(staff, driver, sha('a'))).toEqual(first);
52
+ expect(await host.admin.getStoreDriverRelease(staff, driver, sha('b'))).toBeUndefined();
53
+ // The audit names the driver and the sha256 in the row, not in the vertical column.
54
+ const rows = (await host.admin.auditLog(staff, { action: 'uploadStoreDriverRelease' })).filter((r) => r.after?.driver === driver);
55
+ // Once: the second, identical upload wrote nothing.
56
+ expect(rows).toHaveLength(1);
57
+ expect(rows[0]).toMatchObject({ actor: staff, vertical: null, after: { sha256: sha('a'), sourceCommit: commit('1') } });
58
+ });
59
+ it('rolls back to the immediately previous good release, then the one before it', async () => {
60
+ const driver = freshDriver();
61
+ const holder = ulid();
62
+ await rollOut(driver, holder, sha('1'), sha('2'), sha('3'));
63
+ expect((await host.admin.storeDriverState(staff, driver)).current).toMatchObject({ sha256: sha('3'), previousGoodSha256: sha('2') });
64
+ // A rollback to anything but the previous good release is refused.
65
+ await expectRefusal(host.admin.beginStoreDriverRollout(staff, { driver, sha256: sha('1'), action: 'rollback', holder }), 'conflict');
66
+ const back = await host.admin.beginStoreDriverRollout(staff, { driver, sha256: sha('2'), action: 'rollback', holder });
67
+ expect(back).toMatchObject({ fromSha256: sha('3'), previousGoodSha256: sha('1'), state: 'rolling' });
68
+ await host.admin.finishStoreDriverRollout(staff, { driver, seq: back.seq, holder, state: 'good', detail: null });
69
+ expect((await host.admin.storeDriverState(staff, driver)).current).toMatchObject({ sha256: sha('2'), previousGoodSha256: sha('1') });
70
+ const again = await host.admin.beginStoreDriverRollout(staff, { driver, sha256: sha('1'), action: 'rollback', holder });
71
+ await host.admin.finishStoreDriverRollout(staff, { driver, seq: again.seq, holder, state: 'good', detail: null });
72
+ const state = await host.admin.storeDriverState(staff, driver);
73
+ expect(state.current).toMatchObject({ sha256: sha('1'), previousGoodSha256: null });
74
+ // Nowhere further back: refused, not a guess.
75
+ await expectRefusal(host.admin.beginStoreDriverRollout(staff, { driver, sha256: sha('1'), action: 'rollback', holder }), 'conflict');
76
+ expect(state.rollouts.map((r) => [r.action, r.sha256.slice(0, 1), r.state])).toEqual([
77
+ ['rollout', '1', 'good'],
78
+ ['rollout', '2', 'good'],
79
+ ['rollout', '3', 'good'],
80
+ ['rollback', '2', 'good'],
81
+ ['rollback', '1', 'good'],
82
+ ]);
83
+ });
84
+ it('rolls back after a diverged rollout onto the current release, never past it', async () => {
85
+ const driver = freshDriver();
86
+ const holder = ulid();
87
+ await rollOut(driver, holder, sha('1'), sha('2'));
88
+ await host.admin.recordStoreDriverRelease(staff, { driver, sha256: sha('3'), sourceCommit: commit('c') });
89
+ const bad = await host.admin.beginStoreDriverRollout(staff, { driver, sha256: sha('3'), action: 'rollout', holder });
90
+ await host.admin.finishStoreDriverRollout(staff, { driver, seq: bad.seq, holder, state: 'diverged', detail: 'vb on 2, va on 3' });
91
+ // The fleet may be mixed: undoing the bad rollout is converging back on 2.
92
+ expect(await host.admin.storeDriverState(staff, driver)).toMatchObject({
93
+ current: { sha256: sha('2'), previousGoodSha256: sha('1') },
94
+ rollbackTarget: sha('2'),
95
+ });
96
+ await expectRefusal(host.admin.beginStoreDriverRollout(staff, { driver, sha256: sha('1'), action: 'rollback', holder }), 'conflict');
97
+ const back = await host.admin.beginStoreDriverRollout(staff, { driver, sha256: sha('2'), action: 'rollback', holder });
98
+ // The diverged attempt did not change what 2's previous good release is.
99
+ expect(back).toMatchObject({ fromSha256: sha('2'), previousGoodSha256: sha('1') });
100
+ await host.admin.finishStoreDriverRollout(staff, { driver, seq: back.seq, holder, state: 'good', detail: null });
101
+ expect(await host.admin.storeDriverState(staff, driver)).toMatchObject({ current: { sha256: sha('2') }, rollbackTarget: sha('1') });
102
+ // A failed rollout returned every script, so a rollback after it goes back as usual.
103
+ const failed = await host.admin.beginStoreDriverRollout(staff, { driver, sha256: sha('3'), action: 'rollout', holder });
104
+ await host.admin.finishStoreDriverRollout(staff, { driver, seq: failed.seq, holder, state: 'failed', detail: null });
105
+ expect((await host.admin.storeDriverState(staff, driver)).rollbackTarget).toBe(sha('1'));
106
+ });
107
+ it('continues a paused rollback onto its target, and undoes a paused rollout onto the current release', async () => {
108
+ const driver = freshDriver();
109
+ const holder = ulid();
110
+ await rollOut(driver, holder, sha('1'), sha('2'));
111
+ const first = await host.admin.beginStoreDriverRollout(staff, { driver, sha256: sha('1'), action: 'rollback', holder });
112
+ await host.admin.finishStoreDriverRollout(staff, { driver, seq: first.seq, holder, state: 'paused', detail: '2 of 5' });
113
+ // Part way back to 1: the rollback continues to 1, it does not turn around onto 2.
114
+ expect((await host.admin.storeDriverState(staff, driver)).rollbackTarget).toBe(sha('1'));
115
+ await expectRefusal(host.admin.beginStoreDriverRollout(staff, { driver, sha256: sha('2'), action: 'rollback', holder }), 'conflict');
116
+ const next = await host.admin.beginStoreDriverRollout(staff, { driver, sha256: sha('1'), action: 'rollback', holder });
117
+ expect(next).toMatchObject({ fromSha256: sha('2'), previousGoodSha256: null });
118
+ await host.admin.finishStoreDriverRollout(staff, { driver, seq: next.seq, holder, state: 'good', detail: null });
119
+ expect(await host.admin.storeDriverState(staff, driver)).toMatchObject({ current: { sha256: sha('1') }, rollbackTarget: null });
120
+ // A rollback that ended diverged (a failure part way) continues to its target too.
121
+ await host.admin.recordStoreDriverRelease(staff, { driver, sha256: sha('3'), sourceCommit: commit('c') });
122
+ const three = await host.admin.beginStoreDriverRollout(staff, { driver, sha256: sha('3'), action: 'rollout', holder });
123
+ await host.admin.finishStoreDriverRollout(staff, { driver, seq: three.seq, holder, state: 'good', detail: null });
124
+ const broken = await host.admin.beginStoreDriverRollout(staff, { driver, sha256: sha('1'), action: 'rollback', holder });
125
+ await host.admin.finishStoreDriverRollout(staff, { driver, seq: broken.seq, holder, state: 'diverged', detail: 'vb failed' });
126
+ expect((await host.admin.storeDriverState(staff, driver)).rollbackTarget).toBe(sha('1'));
127
+ const again = await host.admin.beginStoreDriverRollout(staff, { driver, sha256: sha('1'), action: 'rollback', holder });
128
+ await host.admin.finishStoreDriverRollout(staff, { driver, seq: again.seq, holder, state: 'good', detail: null });
129
+ expect((await host.admin.storeDriverState(staff, driver)).current?.sha256).toBe(sha('1'));
130
+ // A rollout that paused part way is undone onto the release the fleet was on.
131
+ const paused = await host.admin.beginStoreDriverRollout(staff, { driver, sha256: sha('2'), action: 'rollout', holder });
132
+ await host.admin.finishStoreDriverRollout(staff, { driver, seq: paused.seq, holder, state: 'paused', detail: null });
133
+ expect((await host.admin.storeDriverState(staff, driver)).rollbackTarget).toBe(sha('1'));
134
+ });
135
+ it('lets no rollout begin while a rollback is unfinished, and a failed attempt does not turn the rollback around', async () => {
136
+ const driver = freshDriver();
137
+ const holder = ulid();
138
+ await rollOut(driver, holder, sha('1'), sha('2'));
139
+ await host.admin.recordStoreDriverRelease(staff, { driver, sha256: sha('3'), sourceCommit: commit('c') });
140
+ const paused = await host.admin.beginStoreDriverRollout(staff, { driver, sha256: sha('1'), action: 'rollback', holder });
141
+ await host.admin.finishStoreDriverRollout(staff, { driver, seq: paused.seq, holder, state: 'paused', detail: null });
142
+ expect(await host.admin.storeDriverState(staff, driver)).toMatchObject({ rollbackTarget: sha('1'), unfinishedRollback: sha('1') });
143
+ // No rollout, of a new release or of the current one, until the rollback is done.
144
+ for (const s of ['3', '2', '1']) {
145
+ await expectRefusal(host.admin.beginStoreDriverRollout(staff, { driver, sha256: sha(s), action: 'rollout', holder }), 'conflict');
146
+ }
147
+ // A rollback post that fails (its moves returned) changes nothing about where the rollback goes.
148
+ const failed = await host.admin.beginStoreDriverRollout(staff, { driver, sha256: sha('1'), action: 'rollback', holder });
149
+ await host.admin.finishStoreDriverRollout(staff, { driver, seq: failed.seq, holder, state: 'failed', detail: 'vc failed' });
150
+ expect(await host.admin.storeDriverState(staff, driver)).toMatchObject({ rollbackTarget: sha('1'), unfinishedRollback: sha('1') });
151
+ await expectRefusal(host.admin.beginStoreDriverRollout(staff, { driver, sha256: sha('3'), action: 'rollout', holder }), 'conflict');
152
+ // Diverged part way (an upload failed, or the request lost its hold): still unfinished, still
153
+ // continued by a rollback, and still blocking every rollout.
154
+ const diverged = await host.admin.beginStoreDriverRollout(staff, { driver, sha256: sha('1'), action: 'rollback', holder });
155
+ await host.admin.finishStoreDriverRollout(staff, { driver, seq: diverged.seq, holder, state: 'diverged', detail: null });
156
+ expect((await host.admin.storeDriverState(staff, driver)).rollbackTarget).toBe(sha('1'));
157
+ await expectRefusal(host.admin.beginStoreDriverRollout(staff, { driver, sha256: sha('3'), action: 'rollout', holder }), 'conflict');
158
+ const done = await host.admin.beginStoreDriverRollout(staff, { driver, sha256: sha('1'), action: 'rollback', holder });
159
+ await host.admin.finishStoreDriverRollout(staff, { driver, seq: done.seq, holder, state: 'good', detail: null });
160
+ expect(await host.admin.storeDriverState(staff, driver)).toMatchObject({ current: { sha256: sha('1') }, unfinishedRollback: null });
161
+ // Finished: a rollout may begin again.
162
+ const next = await host.admin.beginStoreDriverRollout(staff, { driver, sha256: sha('3'), action: 'rollout', holder });
163
+ // It diverges. Undoing it is a rollback onto the current release, 1; if that diverges too (a
164
+ // script on 3 refuses to go below its schema), going forward stays open: it is the way out.
165
+ await host.admin.finishStoreDriverRollout(staff, { driver, seq: next.seq, holder, state: 'diverged', detail: null });
166
+ expect((await host.admin.storeDriverState(staff, driver)).rollbackTarget).toBe(sha('1'));
167
+ const onto = await host.admin.beginStoreDriverRollout(staff, { driver, sha256: sha('1'), action: 'rollback', holder });
168
+ await host.admin.finishStoreDriverRollout(staff, { driver, seq: onto.seq, holder, state: 'diverged', detail: 'below its schema' });
169
+ expect(await host.admin.storeDriverState(staff, driver)).toMatchObject({ unfinishedRollback: sha('1'), current: { sha256: sha('1') } });
170
+ const forward = await host.admin.beginStoreDriverRollout(staff, { driver, sha256: sha('3'), action: 'rollout', holder });
171
+ expect(forward.state).toBe('rolling');
172
+ });
173
+ it('renews only a hold still held, and only for a rollout still rolling', async () => {
174
+ const driver = freshDriver();
175
+ const [a, b] = [ulid(), ulid()];
176
+ await host.admin.recordStoreDriverRelease(staff, { driver, sha256: sha('1'), sourceCommit: commit('c') });
177
+ // Nothing held: a renewal is not an acquisition.
178
+ expect(await host.admin.holdStoreDriver(staff, { driver, holder: a, mode: 'exclusive', ttlMs: 60_000, renew: true })).toBe(false);
179
+ expect(await host.admin.holdStoreDriver(staff, { driver, holder: a, mode: 'shared', ttlMs: 60_000, renew: true })).toBe(false);
180
+ expect(await host.admin.holdStoreDriver(staff, { driver, holder: a, mode: 'exclusive', ttlMs: 60_000 })).toBe(true);
181
+ const row = await host.admin.beginStoreDriverRollout(staff, { driver, sha256: sha('1'), action: 'rollout', holder: a });
182
+ expect(await host.admin.holdStoreDriver(staff, { driver, holder: a, mode: 'exclusive', ttlMs: 60_000, renew: true, rolloutSeq: row.seq })).toBe(true);
183
+ // A gives the driver up; B takes it and ends A's rollout by beginning its own.
184
+ await host.admin.releaseStoreDriverHold(staff, { driver, holder: a });
185
+ expect((await host.admin.storeDriverState(staff, driver)).rollouts.at(-1)?.state).toBe('diverged');
186
+ expect(await host.admin.holdStoreDriver(staff, { driver, holder: b, mode: 'exclusive', ttlMs: 60_000 })).toBe(true);
187
+ const next = await host.admin.beginStoreDriverRollout(staff, { driver, sha256: sha('1'), action: 'rollout', holder: b });
188
+ await host.admin.finishStoreDriverRollout(staff, { driver, seq: next.seq, holder: b, state: 'good', detail: null });
189
+ await host.admin.releaseStoreDriverHold(staff, { driver, holder: b });
190
+ // A asks again: its row ended, and it holds nothing, so it gets nothing back.
191
+ expect(await host.admin.holdStoreDriver(staff, { driver, holder: a, mode: 'exclusive', ttlMs: 60_000, renew: true, rolloutSeq: row.seq })).toBe(false);
192
+ expect(await host.admin.holdStoreDriver(staff, { driver, holder: a, mode: 'exclusive', ttlMs: 60_000, renew: true })).toBe(false);
193
+ // Even holding the driver again, a renewal names a rollout that is over.
194
+ expect(await host.admin.holdStoreDriver(staff, { driver, holder: a, mode: 'exclusive', ttlMs: 60_000 })).toBe(true);
195
+ expect(await host.admin.holdStoreDriver(staff, { driver, holder: a, mode: 'exclusive', ttlMs: 60_000, renew: true, rolloutSeq: row.seq })).toBe(false);
196
+ await host.admin.releaseStoreDriverHold(staff, { driver, holder: a });
197
+ });
198
+ it('leaves the current release where it was when a rollout fails, and a resume keeps the rollback target', async () => {
199
+ const driver = freshDriver();
200
+ const holder = ulid();
201
+ await rollOut(driver, holder, sha('1'), sha('2'));
202
+ await host.admin.recordStoreDriverRelease(staff, { driver, sha256: sha('3'), sourceCommit: commit('c') });
203
+ const failed = await host.admin.beginStoreDriverRollout(staff, { driver, sha256: sha('3'), action: 'rollout', holder });
204
+ await host.admin.finishStoreDriverRollout(staff, { driver, seq: failed.seq, holder, state: 'failed', detail: 'canary refused' });
205
+ expect((await host.admin.storeDriverState(staff, driver)).current).toMatchObject({ sha256: sha('2'), previousGoodSha256: sha('1') });
206
+ // Rolling the current release out again converges it, and a rollback from it still goes to 1.
207
+ const resume = await host.admin.beginStoreDriverRollout(staff, { driver, sha256: sha('2'), action: 'rollout', holder });
208
+ expect(resume.previousGoodSha256).toBe(sha('1'));
209
+ await host.admin.finishStoreDriverRollout(staff, { driver, seq: resume.seq, holder, state: 'good', detail: null });
210
+ expect((await host.admin.storeDriverState(staff, driver)).current).toMatchObject({ sha256: sha('2'), previousGoodSha256: sha('1') });
211
+ // A finished rollout cannot be finished again, and an unstored release cannot begin.
212
+ await expectRefusal(host.admin.finishStoreDriverRollout(staff, { driver, seq: resume.seq, holder, state: 'good', detail: null }), 'conflict');
213
+ await expectRefusal(host.admin.beginStoreDriverRollout(staff, { driver, sha256: sha('9'), action: 'rollout', holder }), 'not_found');
214
+ });
215
+ it('keeps the rollback on the previous good release while the current one is re-posted part way (#2312)', async () => {
216
+ const driver = freshDriver();
217
+ const holder = ulid();
218
+ await rollOut(driver, holder, sha('1'), sha('2'));
219
+ const target = async () => (await host.admin.storeDriverState(staff, driver)).rollbackTarget;
220
+ // Re-posting the current release, paged: each request that pauses or diverges leaves every
221
+ // script on 2, so a rollback still escapes to 1.
222
+ for (const state of ['paused', 'diverged']) {
223
+ const repost = await host.admin.beginStoreDriverRollout(staff, { driver, sha256: sha('2'), action: 'rollout', holder });
224
+ await host.admin.finishStoreDriverRollout(staff, { driver, seq: repost.seq, holder, state, detail: null, ...(state === 'paused' ? { walked: [] } : {}) });
225
+ expect(await target(), state).toBe(sha('1'));
226
+ }
227
+ // Its twin: a rollout of ANOTHER release that pauses leaves the fleet mixed, and undoing it
228
+ // converges on the current release.
229
+ await host.admin.recordStoreDriverRelease(staff, { driver, sha256: sha('3'), sourceCommit: commit('c') });
230
+ const other = await host.admin.beginStoreDriverRollout(staff, { driver, sha256: sha('3'), action: 'rollout', holder });
231
+ await host.admin.finishStoreDriverRollout(staff, { driver, seq: other.seq, holder, state: 'paused', detail: null, walked: [] });
232
+ expect(await target()).toBe(sha('2'));
233
+ await host.admin.releaseStoreDriverHold(staff, { driver, holder });
234
+ });
235
+ it('excludes a shared hold during an exclusive one, and an exclusive one while any hold lives', async () => {
236
+ const driver = freshDriver();
237
+ const [push, rollout, push2] = [ulid(), ulid(), ulid()];
238
+ expect(await host.admin.holdStoreDriver(staff, { driver, holder: push, mode: 'shared', ttlMs: 60_000 })).toBe(true);
239
+ // Two pushes share a driver; a rollout waits for both.
240
+ expect(await host.admin.holdStoreDriver(staff, { driver, holder: push2, mode: 'shared', ttlMs: 60_000 })).toBe(true);
241
+ expect(await host.admin.holdStoreDriver(staff, { driver, holder: rollout, mode: 'exclusive', ttlMs: 60_000 })).toBe(false);
242
+ await host.admin.releaseStoreDriverHold(staff, { driver, holder: push });
243
+ expect(await host.admin.holdStoreDriver(staff, { driver, holder: rollout, mode: 'exclusive', ttlMs: 60_000 })).toBe(false);
244
+ await host.admin.releaseStoreDriverHold(staff, { driver, holder: push2 });
245
+ expect(await host.admin.holdStoreDriver(staff, { driver, holder: rollout, mode: 'exclusive', ttlMs: 60_000 })).toBe(true);
246
+ // During the rollout, no push holds the driver, and no second rollout either.
247
+ expect(await host.admin.holdStoreDriver(staff, { driver, holder: push, mode: 'shared', ttlMs: 60_000 })).toBe(false);
248
+ expect(await host.admin.holdStoreDriver(staff, { driver, holder: ulid(), mode: 'exclusive', ttlMs: 60_000 })).toBe(false);
249
+ // The holder renews in its own mode, and may not switch mode.
250
+ expect(await host.admin.holdStoreDriver(staff, { driver, holder: rollout, mode: 'exclusive', ttlMs: 60_000 })).toBe(true);
251
+ await expectRefusal(host.admin.holdStoreDriver(staff, { driver, holder: rollout, mode: 'shared', ttlMs: 60_000 }), 'conflict');
252
+ // Another driver is not held.
253
+ expect(await host.admin.holdStoreDriver(staff, { driver: freshDriver(), holder: push, mode: 'shared', ttlMs: 60_000 })).toBe(true);
254
+ await host.admin.releaseStoreDriverHold(staff, { driver, holder: rollout });
255
+ expect(await host.admin.holdStoreDriver(staff, { driver, holder: push, mode: 'shared', ttlMs: 60_000 })).toBe(true);
256
+ });
257
+ it('refuses a rollout write by anyone not holding the driver exclusively, and a stopped rollout reads as diverged', async () => {
258
+ const driver = freshDriver();
259
+ const [a, b] = [ulid(), ulid()];
260
+ await host.admin.recordStoreDriverRelease(staff, { driver, sha256: sha('1'), sourceCommit: commit('c') });
261
+ // No hold: no rollout.
262
+ await expectRefusal(host.admin.beginStoreDriverRollout(staff, { driver, sha256: sha('1'), action: 'rollout', holder: a }), 'conflict');
263
+ // A shared hold is not enough either.
264
+ expect(await host.admin.holdStoreDriver(staff, { driver, holder: a, mode: 'shared', ttlMs: 60_000 })).toBe(true);
265
+ await expectRefusal(host.admin.beginStoreDriverRollout(staff, { driver, sha256: sha('1'), action: 'rollout', holder: a }), 'conflict');
266
+ await host.admin.releaseStoreDriverHold(staff, { driver, holder: a });
267
+ expect(await host.admin.holdStoreDriver(staff, { driver, holder: a, mode: 'exclusive', ttlMs: 60_000 })).toBe(true);
268
+ const stopped = await host.admin.beginStoreDriverRollout(staff, { driver, sha256: sha('1'), action: 'rollout', holder: a });
269
+ // A stops holding the driver mid-rollout (here released; a crash lets the lease lapse instead).
270
+ await host.admin.releaseStoreDriverHold(staff, { driver, holder: a });
271
+ await expectRefusal(host.admin.finishStoreDriverRollout(staff, { driver, seq: stopped.seq, holder: a, state: 'good', detail: null }), 'conflict');
272
+ expect(await host.admin.holdStoreDriver(staff, { driver, holder: b, mode: 'exclusive', ttlMs: 60_000 })).toBe(true);
273
+ const next = await host.admin.beginStoreDriverRollout(staff, { driver, sha256: sha('1'), action: 'rollout', holder: b });
274
+ const state = await host.admin.storeDriverState(staff, driver);
275
+ expect(state.rollouts.find((r) => r.seq === stopped.seq)?.state).toBe('diverged');
276
+ expect(state.current).toBeNull();
277
+ await host.admin.finishStoreDriverRollout(staff, { driver, seq: next.seq, holder: b, state: 'good', detail: null });
278
+ expect((await host.admin.storeDriverState(staff, driver)).current?.sha256).toBe(sha('1'));
279
+ });
280
+ it('names every vertical any version of which declared the driver, with its bindings', async () => {
281
+ const driver = freshDriver();
282
+ const slugA = `ska-${ulid().toLowerCase().slice(-8)}`;
283
+ const slugB = `skb-${ulid().toLowerCase().slice(-8)}`;
284
+ const publish = async (slug, scopeStores) => {
285
+ const id = ulid();
286
+ await host.admin.publishVersion(staff, {
287
+ id,
288
+ verticalSlug: slug,
289
+ version: `1.0.${id.slice(-6).toLowerCase()}`,
290
+ manifestDigest: `m-${id}`,
291
+ permissionDigest: 'p',
292
+ migrationDigest: 'g',
293
+ deploymentRef: null,
294
+ manifestJson: JSON.stringify({
295
+ version: '1.0.0', entry: 'index.js', compatibilityDate: '2026-07-01',
296
+ registry: { permissions: [], roles: [], entityGrants: [] },
297
+ digests: { manifest: 'm', permission: 'p', migration: 'g' },
298
+ scopeStores,
299
+ }),
300
+ });
301
+ };
302
+ for (const slug of [slugA, slugB])
303
+ await host.admin.registerVertical(staff, { slug, name: slug, source: 'cli' });
304
+ await publish(slugA, [{ binding: 'GRAPH', driver }]);
305
+ // B's later version drops the store: a scope still running the older one keeps its script.
306
+ await publish(slugB, [{ binding: 'G', driver }, { binding: 'OTHER', driver: freshDriver() }]);
307
+ await publish(slugB, []);
308
+ const declared = await host.admin.storeDriverDeclarations(staff, driver);
309
+ expect(declared.verticals).toEqual([
310
+ { vertical: slugA, stores: [{ binding: 'GRAPH', driver }] },
311
+ { vertical: slugB, stores: [{ binding: 'G', driver }] },
312
+ ]);
313
+ expect(declared.unreadable).not.toContain(slugA);
314
+ expect((await host.admin.storeDriverDeclarations(staff, freshDriver())).verticals).toEqual([]);
315
+ // A version that cannot say what it declares names its vertical as unreadable: it may hold
316
+ // the driver, and a rollout that skipped it would not know.
317
+ const slugC = `skc-${ulid().toLowerCase().slice(-8)}`;
318
+ await host.admin.registerVertical(staff, { slug: slugC, name: slugC, source: 'cli' });
319
+ const id = ulid();
320
+ await host.admin.publishVersion(staff, {
321
+ id,
322
+ verticalSlug: slugC,
323
+ version: `1.0.${id.slice(-6).toLowerCase()}`,
324
+ manifestDigest: `m-${id}`,
325
+ permissionDigest: 'p',
326
+ migrationDigest: 'g',
327
+ deploymentRef: null,
328
+ manifestJson: '{nope',
329
+ });
330
+ const after = await host.admin.storeDriverDeclarations(staff, driver);
331
+ expect(after.unreadable).toContain(slugC);
332
+ expect(after.verticals).toEqual(declared.verticals);
333
+ });
334
+ it('leases a fleet rebuild to one holder, keeps its cursor and failures, and starts over at a new fingerprint (#2309)', async () => {
335
+ const driver = freshDriver();
336
+ const [a, b] = [ulid(), ulid()];
337
+ expect(await host.admin.storeDriverRebuild(staff, driver)).toBeUndefined();
338
+ const first = await host.admin.claimStoreDriverRebuild(staff, { driver, fingerprint: 'f1', holder: a, ttlMs: 60_000 });
339
+ expect(first).toMatchObject({ driver, fingerprint: 'f1', cursor: null, failures: [], state: 'running', finishedAt: null });
340
+ expect(first.fingerprintSince).toBe(first.startedAt);
341
+ // While A's lease lives, B gets nothing, and cannot write.
342
+ expect(await host.admin.claimStoreDriverRebuild(staff, { driver, fingerprint: 'f1', holder: b, ttlMs: 60_000 })).toBeNull();
343
+ const failed = [{ vertical: 'acme', detail: 'upload failed (500)' }];
344
+ await expectRefusal(host.admin.advanceStoreDriverRebuild(staff, { driver, fingerprint: 'f1', holder: b, cursor: 'acme', failures: [], done: false, ttlMs: 60_000 }), 'conflict');
345
+ // A records progress; a write towards another fingerprint is refused, and so is `done` with a failure left.
346
+ const moved = await host.admin.advanceStoreDriverRebuild(staff, { driver, fingerprint: 'f1', holder: a, cursor: 'acme', failures: failed, done: false, ttlMs: 60_000 });
347
+ expect(moved).toMatchObject({ cursor: 'acme', failures: failed, state: 'running' });
348
+ await expectRefusal(host.admin.advanceStoreDriverRebuild(staff, { driver, fingerprint: 'f2', holder: a, cursor: 'zz', failures: [], done: false, ttlMs: 60_000 }), 'conflict');
349
+ await expectRefusal(host.admin.advanceStoreDriverRebuild(staff, { driver, fingerprint: 'f1', holder: a, cursor: 'acme', failures: failed, done: true, ttlMs: 60_000 }), 'validation_failed');
350
+ // A releases its lease: B takes it, and the walk resumes where A stopped.
351
+ await host.admin.advanceStoreDriverRebuild(staff, { driver, fingerprint: 'f1', holder: a, cursor: 'acme', failures: failed, done: false, ttlMs: 60_000, release: true });
352
+ const resumed = await host.admin.claimStoreDriverRebuild(staff, { driver, fingerprint: 'f1', holder: b, ttlMs: 60_000 });
353
+ expect(resumed).toMatchObject({ cursor: 'acme', failures: failed, startedAt: first.startedAt });
354
+ // The lapsed holder writes nothing once B holds it.
355
+ await expectRefusal(host.admin.advanceStoreDriverRebuild(staff, { driver, fingerprint: 'f1', holder: a, cursor: 'zz', failures: [], done: true, ttlMs: 60_000 }), 'conflict');
356
+ const done = await host.admin.advanceStoreDriverRebuild(staff, { driver, fingerprint: 'f1', holder: b, cursor: 'zz', failures: [], done: true, ttlMs: 60_000, release: true });
357
+ expect(done).toMatchObject({ state: 'done', cursor: 'zz', failures: [] });
358
+ expect(done.finishedAt).not.toBeNull();
359
+ // The same fingerprint again keeps it done; a new one starts over, whatever was walked.
360
+ expect(await host.admin.claimStoreDriverRebuild(staff, { driver, fingerprint: 'f1', holder: a, ttlMs: 60_000 })).toMatchObject({ state: 'done', cursor: 'zz' });
361
+ await host.admin.advanceStoreDriverRebuild(staff, { driver, fingerprint: 'f1', holder: a, cursor: 'zz', failures: [], done: true, ttlMs: 60_000, release: true });
362
+ const restarted = await host.admin.claimStoreDriverRebuild(staff, { driver, fingerprint: 'f2', holder: a, ttlMs: 60_000 });
363
+ expect(restarted).toMatchObject({ fingerprint: 'f2', cursor: null, failures: [], state: 'running', finishedAt: null });
364
+ expect(await host.admin.storeDriverRebuild(staff, driver)).toEqual(restarted);
365
+ // A restart starts a done rebuild over at the same fingerprint, and leaves a running one where it is.
366
+ await host.admin.advanceStoreDriverRebuild(staff, { driver, fingerprint: 'f2', holder: a, cursor: 'q', failures: [], done: true, ttlMs: 60_000, release: true });
367
+ const reverified = await host.admin.claimStoreDriverRebuild(staff, { driver, fingerprint: 'f2', holder: b, ttlMs: 60_000, restart: true });
368
+ expect(reverified).toMatchObject({ cursor: null, state: 'running', finishedAt: null });
369
+ // A restart keeps when the fingerprint first appeared; only a new fingerprint moves it.
370
+ expect(reverified.fingerprintSince).toBe(restarted.fingerprintSince);
371
+ await host.admin.advanceStoreDriverRebuild(staff, { driver, fingerprint: 'f2', holder: b, cursor: 'm', failures: [], done: false, ttlMs: 60_000, release: true });
372
+ expect(await host.admin.claimStoreDriverRebuild(staff, { driver, fingerprint: 'f2', holder: a, ttlMs: 60_000, restart: true })).toMatchObject({ cursor: 'm', state: 'running' });
373
+ // Another driver's rebuild is its own row.
374
+ expect(await host.admin.storeDriverRebuild(staff, freshDriver())).toBeUndefined();
375
+ await expectRefusal(host.admin.claimStoreDriverRebuild(staff, { driver: freshDriver(), fingerprint: 'f', holder: a, ttlMs: 0 }), 'validation_failed');
376
+ });
377
+ it('keeps a fleet rebuild\'s proof across a restart at the same lists, and carries a person\'s attestation only on the walk it starts (#2309)', async () => {
378
+ const driver = freshDriver();
379
+ const [a, b] = [ulid(), ulid()];
380
+ const first = await host.admin.claimStoreDriverRebuild(staff, { driver, fingerprint: 'f', holder: a, ttlMs: 60_000 });
381
+ expect(first).toMatchObject({ attestedBy: null, verifiedAt: null });
382
+ // Proof is recorded only with done.
383
+ await expectRefusal(host.admin.advanceStoreDriverRebuild(staff, { driver, fingerprint: 'f', holder: a, cursor: 'm', failures: [], done: false, verified: true, ttlMs: 60_000 }), 'validation_failed');
384
+ const proved = await host.admin.advanceStoreDriverRebuild(staff, { driver, fingerprint: 'f', holder: a, cursor: 'z', failures: [], done: true, verified: true, ttlMs: 60_000, release: true });
385
+ expect(proved.verifiedAt).toBe(proved.finishedAt);
386
+ // A restart at the same lists keeps the proof; the walk it starts is not attested.
387
+ const again = await host.admin.claimStoreDriverRebuild(staff, { driver, fingerprint: 'f', holder: b, ttlMs: 60_000, restart: true });
388
+ expect(again).toMatchObject({ state: 'running', cursor: null, verifiedAt: proved.verifiedAt, attestedBy: null });
389
+ // An attestation starts the walk over even while one runs, and carries the person.
390
+ await host.admin.advanceStoreDriverRebuild(staff, { driver, fingerprint: 'f', holder: b, cursor: 'm', failures: [], done: false, ttlMs: 60_000, release: true });
391
+ const attested = await host.admin.claimStoreDriverRebuild(staff, { driver, fingerprint: 'f', holder: a, ttlMs: 60_000, attestedBy: staff });
392
+ expect(attested).toMatchObject({ cursor: null, attestedBy: staff, verifiedAt: proved.verifiedAt });
393
+ // A pass that continues it keeps the attestation; a walk started without one clears it.
394
+ await host.admin.advanceStoreDriverRebuild(staff, { driver, fingerprint: 'f', holder: a, cursor: 'q', failures: [], done: false, ttlMs: 60_000, release: true });
395
+ expect(await host.admin.claimStoreDriverRebuild(staff, { driver, fingerprint: 'f', holder: b, ttlMs: 60_000 })).toMatchObject({ cursor: 'q', attestedBy: staff });
396
+ await host.admin.advanceStoreDriverRebuild(staff, { driver, fingerprint: 'f', holder: b, cursor: 'z', failures: [], done: true, ttlMs: 60_000, release: true });
397
+ expect(await host.admin.claimStoreDriverRebuild(staff, { driver, fingerprint: 'f', holder: a, ttlMs: 60_000, restart: true })).toMatchObject({ attestedBy: null });
398
+ // New lists clear the proof: nothing at them is proved.
399
+ await host.admin.advanceStoreDriverRebuild(staff, { driver, fingerprint: 'f', holder: a, cursor: 'z', failures: [], done: true, ttlMs: 60_000, release: true });
400
+ expect(await host.admin.claimStoreDriverRebuild(staff, { driver, fingerprint: 'g', holder: b, ttlMs: 60_000 })).toMatchObject({ verifiedAt: null, attestedBy: null });
401
+ });
402
+ it('audits a staff-run rebuild pass as rebuildStoreDriver (#2309)', async () => {
403
+ const driver = freshDriver();
404
+ await host.admin.recordStoreDriverRebuildRun(staff, { driver, outcome: 'paused', walked: ['acme'], uploaded: [] });
405
+ const rows = (await host.admin.auditLog(staff, { action: 'rebuildStoreDriver' })).filter((r) => r.after?.driver === driver);
406
+ expect(rows).toHaveLength(1);
407
+ expect(rows[0]).toMatchObject({ actor: staff, after: { driver, outcome: 'paused', walked: ['acme'], uploaded: [] } });
408
+ });
409
+ describe('a rollout paged over several requests walks from its ledger (#2312)', () => {
410
+ /** Three verticals declaring `driver`, in slug order, and the version each first declared it in. */
411
+ const fleetOf = async (driver) => {
412
+ const stem = `pw${ulid().toLowerCase().slice(-8)}`;
413
+ const slugs = [`${stem}-b`, `${stem}-c`, `${stem}-d`];
414
+ const firsts = {};
415
+ for (const slug of slugs)
416
+ firsts[slug] = await declare(slug, driver);
417
+ return { stem, slugs, firsts };
418
+ };
419
+ const declare = async (slug, driver) => {
420
+ await host.admin.registerVertical(staff, { slug, name: slug, source: 'cli' });
421
+ const id = ulid();
422
+ await host.admin.publishVersion(staff, {
423
+ id,
424
+ verticalSlug: slug,
425
+ version: `1.0.${id.slice(-6).toLowerCase()}`,
426
+ manifestDigest: `m-${id}`,
427
+ permissionDigest: 'p',
428
+ migrationDigest: 'g',
429
+ deploymentRef: null,
430
+ manifestJson: JSON.stringify({
431
+ version: '1.0.0', entry: 'index.js', compatibilityDate: '2026-07-01',
432
+ registry: { permissions: [], roles: [], entityGrants: [] },
433
+ digests: { manifest: 'm', permission: 'p', migration: 'g' },
434
+ scopeStores: [{ binding: 'G', driver }],
435
+ }),
436
+ });
437
+ return id;
438
+ };
439
+ const target = (slug, firstVersionId) => ({ vertical: slug, stores: [{ binding: 'G', firstVersionId }] });
440
+ const pending = (driver, sha256, action = 'rollout', limit = 10) => host.admin.storeDriverRolloutPending(staff, { driver, sha256, action, limit });
441
+ it('continues a paused attempt from what it walked, and starts every other attempt from nothing', async () => {
442
+ const driver = freshDriver();
443
+ const { slugs, firsts } = await fleetOf(driver);
444
+ const [b, c, d] = slugs;
445
+ const holder = ulid();
446
+ await host.admin.recordStoreDriverRelease(staff, { driver, sha256: sha('1'), sourceCommit: commit('c') });
447
+ await host.admin.recordStoreDriverRelease(staff, { driver, sha256: sha('2'), sourceCommit: commit('c') });
448
+ expect(await host.admin.holdStoreDriver(staff, { driver, holder, mode: 'exclusive', ttlMs: 60_000 })).toBe(true);
449
+ // A fresh attempt walks every declaration, in slug order, a page at a time.
450
+ expect(await pending(driver, sha('1'), 'rollout', 2)).toEqual({
451
+ verticals: [target(b, firsts[b]), target(c, firsts[c])],
452
+ pending: 3,
453
+ walked: 0,
454
+ unreadable: expect.any(Array),
455
+ });
456
+ const first = await host.admin.beginStoreDriverRollout(staff, { driver, sha256: sha('1'), action: 'rollout', holder });
457
+ // Only a paused end records what it walked: any other is refused, and ends nothing.
458
+ await expectRefusal(host.admin.finishStoreDriverRollout(staff, { driver, seq: first.seq, holder, state: 'good', detail: null, walked: [target(b, firsts[b])] }), 'validation_failed');
459
+ expect((await host.admin.storeDriverState(staff, driver)).rollouts.at(-1)?.state).toBe('rolling');
460
+ await host.admin.finishStoreDriverRollout(staff, { driver, seq: first.seq, holder, state: 'paused', detail: null, walked: [target(b, firsts[b])] });
461
+ // The same rollout continues past b; another release or the other action starts afresh.
462
+ expect(await pending(driver, sha('1'))).toMatchObject({ verticals: [target(c, firsts[c]), target(d, firsts[d])], pending: 2, walked: 1 });
463
+ expect(await pending(driver, sha('2'))).toMatchObject({ pending: 3, walked: 0 });
464
+ expect(await pending(driver, sha('1'), 'rollback')).toMatchObject({ pending: 3, walked: 0 });
465
+ // The second request of the attempt sees the first's walk as its earlier requests', and adds c.
466
+ const second = await host.admin.beginStoreDriverRollout(staff, { driver, sha256: sha('1'), action: 'rollout', holder });
467
+ expect(await host.admin.storeDriverRolloutWalked(staff, { driver, seq: second.seq, limit: 10 })).toEqual({ verticals: [target(b, firsts[b])], walked: 1 });
468
+ await host.admin.finishStoreDriverRollout(staff, { driver, seq: second.seq, holder, state: 'paused', detail: null, walked: [target(c, firsts[c])] });
469
+ expect(await pending(driver, sha('1'))).toMatchObject({ verticals: [target(d, firsts[d])], pending: 1, walked: 2 });
470
+ // A vertical deleted and registered again under the same slug declares the store anew, so it
471
+ // is walked again; one deleted for good is no longer pending, nor counted walked.
472
+ await host.admin.deleteVertical(staff, b);
473
+ const again = await declare(b, driver);
474
+ await host.admin.deleteVertical(staff, d);
475
+ expect(await pending(driver, sha('1'))).toMatchObject({ verticals: [target(b, again)], pending: 1, walked: 1 });
476
+ // A request that stops without any end (its invocation died after uploads it never
477
+ // recorded) leaves its row rolling and the ledger as it was. The next request continues
478
+ // nothing: it walks everything again rather than trust a ledger a stopped request followed.
479
+ const stopped = await host.admin.beginStoreDriverRollout(staff, { driver, sha256: sha('1'), action: 'rollout', holder });
480
+ await host.admin.releaseStoreDriverHold(staff, { driver, holder });
481
+ expect((await host.admin.storeDriverState(staff, driver)).rollouts.find((r) => r.seq === stopped.seq)?.state).toBe('diverged');
482
+ expect(await pending(driver, sha('1'))).toMatchObject({ pending: 2, walked: 0 });
483
+ expect(await host.admin.holdStoreDriver(staff, { driver, holder, mode: 'exclusive', ttlMs: 60_000 })).toBe(true);
484
+ // A failed end drops the ledger: the next request walks everything again.
485
+ const third = await host.admin.beginStoreDriverRollout(staff, { driver, sha256: sha('1'), action: 'rollout', holder });
486
+ await host.admin.finishStoreDriverRollout(staff, { driver, seq: third.seq, holder, state: 'failed', detail: 'x' });
487
+ expect(await pending(driver, sha('1'))).toMatchObject({ pending: 2, walked: 0 });
488
+ // So does a rollout of another release in between two pauses of the same one.
489
+ const p1 = await host.admin.beginStoreDriverRollout(staff, { driver, sha256: sha('1'), action: 'rollout', holder });
490
+ await host.admin.finishStoreDriverRollout(staff, { driver, seq: p1.seq, holder, state: 'paused', detail: null, walked: [target(b, again)] });
491
+ const other = await host.admin.beginStoreDriverRollout(staff, { driver, sha256: sha('2'), action: 'rollout', holder });
492
+ await host.admin.finishStoreDriverRollout(staff, { driver, seq: other.seq, holder, state: 'paused', detail: null, walked: [] });
493
+ expect(await pending(driver, sha('1'))).toMatchObject({ pending: 2, walked: 0 });
494
+ await host.admin.releaseStoreDriverHold(staff, { driver, holder });
495
+ });
496
+ it('records each page read in the access log, as the declarations read is, and refuses a page of no verticals', async () => {
497
+ const driver = freshDriver();
498
+ await fleetOf(driver);
499
+ await expectRefusal(pending(driver, sha('1'), 'rollout', 0), 'validation_failed');
500
+ await pending(driver, sha('1'), 'rollout', 2);
501
+ const reads = (await host.admin.accessLog(staff)).filter((e) => e.method === 'storeDriverRolloutPending' && e.actor === staff && String(e.params).includes(driver));
502
+ expect(reads).toHaveLength(1);
503
+ expect(reads[0]).toMatchObject({ tenantId: null, resultCount: 2 });
504
+ await expectRefusal(host.admin.storeDriverRolloutWalked(staff, { driver, seq: 99, limit: 1 }), 'not_found');
505
+ });
506
+ });
507
+ });
508
+ }
509
+ export function storeDriverHoldExpiryContractSuite(adapterName, makeFixture) {
510
+ describe(`store-driver hold expiry (#2300): ${adapterName}`, () => {
511
+ let fixture;
512
+ const staff = platformActorId.parse(ulid());
513
+ beforeAll(async () => {
514
+ fixture = await makeFixture();
515
+ });
516
+ afterAll(async () => fixture.cleanup());
517
+ it("lapses a hold on the host's clock, and a crashed rollout then reads as diverged", async () => {
518
+ const { host, clock } = fixture;
519
+ const driver = freshDriver();
520
+ const [crashed, next, push] = [ulid(), ulid(), ulid()];
521
+ await host.admin.recordStoreDriverRelease(staff, { driver, sha256: sha('1'), sourceCommit: commit('c') });
522
+ expect(await host.admin.holdStoreDriver(staff, { driver, holder: crashed, mode: 'exclusive', ttlMs: 60_000 })).toBe(true);
523
+ const stopped = await host.admin.beginStoreDriverRollout(staff, { driver, sha256: sha('1'), action: 'rollout', holder: crashed });
524
+ // The holder never comes back. Just before the lease ends the driver is still held.
525
+ clock.advance(59_999);
526
+ expect(await host.admin.holdStoreDriver(staff, { driver, holder: push, mode: 'shared', ttlMs: 1_000 })).toBe(false);
527
+ clock.advance(1);
528
+ // At its end it lapses. Read now, the rollout reads as stopped, though nothing wrote that.
529
+ expect((await host.admin.storeDriverState(staff, driver)).rollouts.at(-1)).toMatchObject({ seq: stopped.seq, state: 'diverged' });
530
+ // Its holder cannot renew it back, even with nobody else holding the driver.
531
+ expect(await host.admin.holdStoreDriver(staff, { driver, holder: crashed, mode: 'exclusive', ttlMs: 60_000, renew: true, rolloutSeq: stopped.seq })).toBe(false);
532
+ // The crashed holder can write nothing, and a push holds again.
533
+ await expectRefusal(host.admin.finishStoreDriverRollout(staff, { driver, seq: stopped.seq, holder: crashed, state: 'good', detail: null }), 'conflict');
534
+ expect(await host.admin.holdStoreDriver(staff, { driver, holder: push, mode: 'shared', ttlMs: 1_000 })).toBe(true);
535
+ expect(await host.admin.holdStoreDriver(staff, { driver, holder: next, mode: 'exclusive', ttlMs: 60_000 })).toBe(false);
536
+ clock.advance(1_000);
537
+ expect(await host.admin.holdStoreDriver(staff, { driver, holder: next, mode: 'exclusive', ttlMs: 60_000 })).toBe(true);
538
+ await host.admin.beginStoreDriverRollout(staff, { driver, sha256: sha('1'), action: 'rollout', holder: next });
539
+ const state = await host.admin.storeDriverState(staff, driver);
540
+ expect(state.rollouts.map((r) => r.state)).toEqual(['diverged', 'rolling']);
541
+ // A lifetime that is not positive is no hold.
542
+ await expectRefusal(host.admin.holdStoreDriver(staff, { driver: freshDriver(), holder: push, mode: 'shared', ttlMs: 0 }), 'validation_failed');
543
+ });
544
+ it("lapses a fleet rebuild's lease on the host's clock: another pass takes it over and the lapsed holder writes nothing (#2309)", async () => {
545
+ const { host, clock } = fixture;
546
+ const driver = freshDriver();
547
+ const [crashed, next] = [ulid(), ulid()];
548
+ await host.admin.claimStoreDriverRebuild(staff, { driver, fingerprint: 'f', holder: crashed, ttlMs: 60_000 });
549
+ clock.advance(59_999);
550
+ expect(await host.admin.claimStoreDriverRebuild(staff, { driver, fingerprint: 'f', holder: next, ttlMs: 60_000 })).toBeNull();
551
+ clock.advance(1);
552
+ // Lapsed, and nobody has claimed it: what the holder did is still recorded (and its lease renewed).
553
+ await host.admin.advanceStoreDriverRebuild(staff, { driver, fingerprint: 'f', holder: crashed, cursor: 'm', failures: [], done: false, ttlMs: 60_000 });
554
+ expect(await host.admin.claimStoreDriverRebuild(staff, { driver, fingerprint: 'f', holder: next, ttlMs: 60_000 })).toBeNull();
555
+ clock.advance(60_000);
556
+ expect(await host.admin.claimStoreDriverRebuild(staff, { driver, fingerprint: 'f', holder: next, ttlMs: 60_000 })).toMatchObject({ cursor: 'm', state: 'running' });
557
+ await expectRefusal(host.admin.advanceStoreDriverRebuild(staff, { driver, fingerprint: 'f', holder: crashed, cursor: 'z', failures: [], done: true, ttlMs: 60_000 }), 'conflict');
558
+ });
559
+ });
560
+ }
561
+ //# sourceMappingURL=store-driver-release-suite.js.map