@substrat-run/contract-tests 0.146.0 → 0.147.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.
@@ -0,0 +1,13 @@
1
+ import { type ScopeStoreRunner } from '@substrat-run/kernel';
2
+ import type { ScopeHostFixture } from './scope-host-suite.js';
3
+ /**
4
+ * Scope stores on every adapter (#2213). The fixture is handed the runner, so
5
+ * both hosts answer the same counter. The reserved name is refused by the stub.
6
+ */
7
+ export declare function scopeStoreContractSuite(adapterName: string, makeFixture: (scopeStores: {
8
+ runners: {
9
+ binding: string;
10
+ runner: ScopeStoreRunner;
11
+ }[];
12
+ }) => Promise<ScopeHostFixture>): void;
13
+ //# sourceMappingURL=scope-store-suite.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scope-store-suite.d.ts","sourceRoot":"","sources":["../src/scope-store-suite.ts"],"names":[],"mappings":"AAEA,OAAO,EAAgD,KAAK,gBAAgB,EAAwB,MAAM,sBAAsB,CAAC;AAEjI,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAuG9D;;;GAGG;AACH,wBAAgB,uBAAuB,CACrC,WAAW,EAAE,MAAM,EACnB,WAAW,EAAE,CACX,WAAW,EAAE;IAAE,OAAO,EAAE;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,gBAAgB,CAAA;KAAE,EAAE,CAAA;CAAE,KACtE,OAAO,CAAC,gBAAgB,CAAC,GAC7B,IAAI,CA2jBN"}
@@ -0,0 +1,605 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2
+ import { errorCodeOf, platformActorId, principalId, scopeId, tenantId } from '@substrat-run/contracts';
3
+ import { ulid } from '@substrat-run/kernel';
4
+ import { STORE_CALL_SINK_KEY, tallyStoreCall } from '@substrat-run/kernel/invocation-line';
5
+ /**
6
+ * A counter in the process. One number per address, so two scopes and two
7
+ * verticals stay apart when the host passes the address it built.
8
+ */
9
+ function counterRunner() {
10
+ const objects = new Map();
11
+ const erased = new Map();
12
+ const wiped = new Set();
13
+ // `fail-wipe` makes every later `substrat.wipe` fail, until it is turned off again.
14
+ let failWipe = false;
15
+ const key = (address) => `${address.tenantId}/${address.vertical}/${address.scopeId}/${address.binding}`;
16
+ return {
17
+ kind: 'counter',
18
+ async start() { },
19
+ async stop() { },
20
+ async invoke(address, request) {
21
+ const current = objects.get(key(address)) ?? 0;
22
+ if (request.operation === 'read')
23
+ return { result: current };
24
+ if (request.operation === 'address') {
25
+ return {
26
+ result: {
27
+ tenantId: address.tenantId,
28
+ vertical: address.vertical,
29
+ scopeId: address.scopeId,
30
+ binding: address.binding,
31
+ },
32
+ };
33
+ }
34
+ if (request.operation === 'increment') {
35
+ const by = typeof request.input === 'object' &&
36
+ request.input !== null &&
37
+ typeof Reflect.get(request.input, 'by') === 'number'
38
+ ? Number(Reflect.get(request.input, 'by'))
39
+ : 1;
40
+ const next = current + by;
41
+ objects.set(key(address), next);
42
+ return { result: next };
43
+ }
44
+ if (request.operation === 'substrat.size')
45
+ return { result: { bytes: current } };
46
+ if (request.operation === 'fail-wipe') {
47
+ failWipe = request.input === true;
48
+ return { result: failWipe };
49
+ }
50
+ if (request.operation === 'substrat.wipe') {
51
+ if (failWipe)
52
+ return { error: { code: 'unavailable', status: 503 } };
53
+ objects.set(key(address), 0);
54
+ wiped.add(key(address));
55
+ return { result: {} };
56
+ }
57
+ if (request.operation === 'wiped')
58
+ return { result: [...wiped] };
59
+ if (request.operation === 'substrat.erase') {
60
+ const subjectId = typeof request.input === 'object' && request.input !== null
61
+ ? Reflect.get(request.input, 'subjectId')
62
+ : undefined;
63
+ if (typeof subjectId !== 'string' || subjectId.length === 0) {
64
+ return { error: { code: 'validation_failed', status: 400 } };
65
+ }
66
+ const subjects = erased.get(key(address)) ?? new Set();
67
+ const already = subjects.has(subjectId);
68
+ subjects.add(subjectId);
69
+ erased.set(key(address), subjects);
70
+ return { result: { erased: already ? 0 : 1 } };
71
+ }
72
+ if (request.operation === 'erased') {
73
+ return { result: [...(erased.get(key(address)) ?? [])] };
74
+ }
75
+ if (request.operation === 'substrat.export') {
76
+ return {
77
+ result: {
78
+ tables: [
79
+ {
80
+ name: 'counter',
81
+ ddl: 'CREATE TABLE counter (n INTEGER NOT NULL)',
82
+ columns: ['n'],
83
+ rows: [[current]],
84
+ },
85
+ ],
86
+ },
87
+ };
88
+ }
89
+ if (request.operation === 'substrat.import') {
90
+ const tables = typeof request.input === 'object' && request.input !== null
91
+ ? Reflect.get(request.input, 'tables')
92
+ : undefined;
93
+ const count = Array.isArray(tables) ? tables.length : 0;
94
+ const first = Array.isArray(tables) ? tables[0] : undefined;
95
+ const rows = typeof first === 'object' && first !== null ? Reflect.get(first, 'rows') : undefined;
96
+ const row = Array.isArray(rows) ? rows[0] : undefined;
97
+ const cell = Array.isArray(row) ? row[0] : undefined;
98
+ objects.set(key(address), typeof cell === 'number' ? cell : 0);
99
+ return { result: { tables: count } };
100
+ }
101
+ return { error: { code: 'unknown_operation', status: 400 } };
102
+ },
103
+ };
104
+ }
105
+ /**
106
+ * Scope stores on every adapter (#2213). The fixture is handed the runner, so
107
+ * both hosts answer the same counter. The reserved name is refused by the stub.
108
+ */
109
+ export function scopeStoreContractSuite(adapterName, makeFixture) {
110
+ describe(`scope stores (#2213): ${adapterName}`, () => {
111
+ let fixture;
112
+ let host;
113
+ const staff = platformActorId.parse(ulid());
114
+ const principal = principalId.parse(ulid());
115
+ const tenant = tenantId.parse(ulid());
116
+ const scopeA = scopeId.parse(ulid());
117
+ const scopeB = scopeId.parse(ulid());
118
+ const otherVertical = scopeId.parse(ulid());
119
+ beforeAll(async () => {
120
+ fixture = await makeFixture({ runners: [{ binding: 'GRAPH', runner: counterRunner() }] });
121
+ host = fixture.host;
122
+ await host.admin.createTenant(staff, { id: tenant, slug: `t-${tenant.toLowerCase()}`, name: 'T' });
123
+ await host.provisionScope(staff, { tenantId: tenant, scopeId: scopeA, vertical: 'docs' });
124
+ await host.provisionScope(staff, { tenantId: tenant, scopeId: scopeB, vertical: 'docs' });
125
+ await host.provisionScope(staff, { tenantId: tenant, scopeId: otherVertical, vertical: 'other' });
126
+ await host.admin.activateScope(staff, tenant, scopeA);
127
+ await host.admin.activateScope(staff, tenant, scopeB);
128
+ await host.admin.activateScope(staff, tenant, otherVertical);
129
+ });
130
+ afterAll(async () => {
131
+ await fixture?.cleanup();
132
+ });
133
+ /** A valid table no scope has: present after a restore only if the dump's tables loaded. */
134
+ const PROBE_TABLE = { name: 'restore_probe', ddl: 'CREATE TABLE restore_probe (id TEXT)', columns: ['id'], rows: [['x']] };
135
+ const tableNames = async (scope) => (await host.admin.exportScope(staff, tenant, scope)).tables.map((table) => table.name);
136
+ it('the runner is handed the tenant, the vertical, the scope and the binding', async () => {
137
+ const store = await host.getScopeStore(principal, tenant, scopeA, 'GRAPH');
138
+ expect(await store.invoke('address')).toEqual({
139
+ tenantId: tenant,
140
+ vertical: 'docs',
141
+ scopeId: scopeA,
142
+ binding: 'GRAPH',
143
+ });
144
+ });
145
+ it('one runner starts once when the first calls overlap', async () => {
146
+ let started = 0;
147
+ let release = () => undefined;
148
+ const gate = new Promise((resolve) => {
149
+ release = resolve;
150
+ });
151
+ const slow = {
152
+ kind: 'slow',
153
+ async start() {
154
+ started += 1;
155
+ await gate;
156
+ },
157
+ async stop() { },
158
+ async invoke() {
159
+ return { result: started };
160
+ },
161
+ };
162
+ const extra = await makeFixture({
163
+ runners: [
164
+ { binding: 'A', runner: slow },
165
+ { binding: 'B', runner: slow },
166
+ ],
167
+ });
168
+ try {
169
+ const t = tenantId.parse(ulid());
170
+ const scope = scopeId.parse(ulid());
171
+ await extra.host.admin.createTenant(staff, { id: t, slug: `t-${t.toLowerCase()}`, name: 'T' });
172
+ await extra.host.provisionScope(staff, { tenantId: t, scopeId: scope, vertical: 'docs' });
173
+ await extra.host.admin.activateScope(staff, t, scope);
174
+ const pending = Promise.all([
175
+ extra.host.getScopeStore(principal, t, scope, 'A'),
176
+ extra.host.getScopeStore(principal, t, scope, 'B'),
177
+ extra.host.getScopeStore(principal, t, scope, 'A'),
178
+ ]);
179
+ const deadline = Date.now() + 2000;
180
+ while (started === 0 && Date.now() < deadline) {
181
+ await new Promise((resolve) => setTimeout(resolve, 10));
182
+ }
183
+ expect(started).toBe(1);
184
+ release();
185
+ await pending;
186
+ expect(started).toBe(1);
187
+ }
188
+ finally {
189
+ await extra.cleanup();
190
+ }
191
+ });
192
+ it('a declared binding reaches its object', async () => {
193
+ const store = await host.getScopeStore(principal, tenant, scopeA, 'GRAPH');
194
+ expect(await store.invoke('increment', { by: 1 })).toBe(1);
195
+ expect(await store.invoke('read')).toBe(1);
196
+ });
197
+ it('an undeclared binding is refused', async () => {
198
+ const refusal = await host.getScopeStore(principal, tenant, scopeA, 'OTHER').then(() => undefined, (err) => err);
199
+ expect(errorCodeOf(refusal)).toBe('not_found');
200
+ });
201
+ it('two scopes never share an object', async () => {
202
+ const store = await host.getScopeStore(principal, tenant, scopeB, 'GRAPH');
203
+ expect(await store.invoke('read')).toBe(0);
204
+ });
205
+ it('two verticals never share an object', async () => {
206
+ const store = await host.getScopeStore(principal, tenant, otherVertical, 'GRAPH');
207
+ expect(await store.invoke('read')).toBe(0);
208
+ expect(await store.invoke('increment', { by: 5 })).toBe(5);
209
+ const docs = await host.getScopeStore(principal, tenant, scopeA, 'GRAPH');
210
+ expect(await docs.invoke('read')).toBe(1);
211
+ });
212
+ it('an administrator reads the size of each declared store', async () => {
213
+ const store = await host.getScopeStore(principal, tenant, scopeA, 'GRAPH');
214
+ const current = await store.invoke('read');
215
+ expect(await host.admin.scopeStoreSizes(staff, tenant, scopeA)).toEqual([
216
+ { binding: 'GRAPH', bytes: current },
217
+ ]);
218
+ const reads = await host.admin.accessLog(staff, { tenantId: tenant, method: 'scopeStoreSizes' });
219
+ expect(reads.some((entry) => entry.scopeId === scopeA)).toBe(true);
220
+ });
221
+ it('an unknown scope is refused before a store is asked', async () => {
222
+ const refusal = await host.admin.scopeStoreSizes(staff, tenant, scopeId.parse(ulid())).then(() => undefined, (err) => err);
223
+ expect(errorCodeOf(refusal)).toBe('not_found');
224
+ });
225
+ it('an erasure reaches the store, and a second one counts zero', async () => {
226
+ const subject = ulid();
227
+ const first = await host.admin.shredSubject(staff, tenant, scopeA, subject);
228
+ expect(first.storeErasures).toEqual([{ binding: 'GRAPH', erased: 1 }]);
229
+ const store = await host.getScopeStore(principal, tenant, scopeA, 'GRAPH');
230
+ expect(await store.invoke('erased')).toEqual([subject]);
231
+ const again = await host.admin.shredSubject(staff, tenant, scopeA, subject);
232
+ expect(again.storeErasures).toEqual([{ binding: 'GRAPH', erased: 0 }]);
233
+ });
234
+ it('a snapshot carries the store, and the fork then diverges', async () => {
235
+ const original = await host.getScopeStore(principal, tenant, scopeA, 'GRAPH');
236
+ const before = await original.invoke('read');
237
+ const fork = await host.snapshotScope(staff, tenant, scopeA);
238
+ const forked = await host.getScopeStore(principal, tenant, fork, 'GRAPH');
239
+ expect(await forked.invoke('read')).toBe(before);
240
+ await original.invoke('increment', { by: 1 });
241
+ expect(await forked.invoke('read')).toBe(before);
242
+ expect(await original.invoke('read')).toBe(Number(before) + 1);
243
+ });
244
+ it('a restore replaces the store', async () => {
245
+ const store = await host.getScopeStore(principal, tenant, scopeB, 'GRAPH');
246
+ await store.invoke('increment', { by: 4 });
247
+ const dump = await host.admin.exportScope(staff, tenant, scopeB);
248
+ expect(dump.stores).toEqual([
249
+ expect.objectContaining({ binding: 'GRAPH', kind: 'counter' }),
250
+ ]);
251
+ await store.invoke('increment', { by: 1 });
252
+ await host.restoreScope(staff, tenant, scopeB, dump);
253
+ expect(await store.invoke('read')).toBe(4);
254
+ });
255
+ it('a restore that names an unbound store writes nothing', async () => {
256
+ const store = await host.getScopeStore(principal, tenant, scopeB, 'GRAPH');
257
+ // scopeB carries the counter the tests above left behind.
258
+ const before = Number(await store.invoke('read'));
259
+ await store.invoke('increment', { by: 3 });
260
+ const dump = await host.admin.exportScope(staff, tenant, scopeB);
261
+ await store.invoke('increment', { by: 1 });
262
+ const refusal = await host.restoreScope(staff, tenant, scopeB, {
263
+ ...dump,
264
+ tables: [...dump.tables, PROBE_TABLE],
265
+ stores: [...dump.stores, { binding: 'MISSING', kind: 'counter', tables: [] }],
266
+ }).then(() => undefined, (err) => err);
267
+ expect(errorCodeOf(refusal)).toBe('not_found');
268
+ // Neither the restore of the dump's GRAPH (before + 3) nor its tables were written.
269
+ expect(await store.invoke('read')).toBe(before + 4);
270
+ expect(await tableNames(scopeB)).not.toContain(PROBE_TABLE.name);
271
+ });
272
+ // L8: a store of another kind would be handed to this kind's import, and a destination
273
+ // with no vertical has no store object to load into. Both refuse before any write.
274
+ it('a restore whose store is of another kind writes nothing', async () => {
275
+ const store = await host.getScopeStore(principal, tenant, scopeB, 'GRAPH');
276
+ const before = Number(await store.invoke('read'));
277
+ const dump = await host.admin.exportScope(staff, tenant, scopeB);
278
+ await store.invoke('increment', { by: 2 });
279
+ const refusal = await host.restoreScope(staff, tenant, scopeB, {
280
+ ...dump,
281
+ tables: [...dump.tables, PROBE_TABLE],
282
+ stores: dump.stores.map((one) => ({ ...one, kind: 'other' })),
283
+ }).then(() => undefined, (err) => err);
284
+ expect(errorCodeOf(refusal)).toBe('conflict');
285
+ expect(await store.invoke('read')).toBe(before + 2);
286
+ expect(await tableNames(scopeB)).not.toContain(PROBE_TABLE.name);
287
+ });
288
+ // CodeRabbit on #2244: the host does not parse the dump it is handed, so a store with no
289
+ // kind skipped the kind comparison above and was imported.
290
+ it('a restore whose store names no kind writes nothing', async () => {
291
+ const store = await host.getScopeStore(principal, tenant, scopeB, 'GRAPH');
292
+ const before = Number(await store.invoke('read'));
293
+ const dump = await host.admin.exportScope(staff, tenant, scopeB);
294
+ await store.invoke('increment', { by: 2 });
295
+ const refusal = await host.restoreScope(staff, tenant, scopeB, {
296
+ ...dump,
297
+ tables: [...dump.tables, PROBE_TABLE],
298
+ stores: dump.stores.map(({ binding, tables }) => ({ binding, tables })),
299
+ }).then(() => undefined, (err) => err);
300
+ expect(errorCodeOf(refusal)).toBe('validation_failed');
301
+ expect(await store.invoke('read')).toBe(before + 2);
302
+ expect(await tableNames(scopeB)).not.toContain(PROBE_TABLE.name);
303
+ });
304
+ it('a dump carrying stores is refused, not dropped, by a scope with no vertical', async () => {
305
+ const bare = scopeId.parse(ulid());
306
+ await host.provisionScope(staff, { tenantId: tenant, scopeId: bare });
307
+ await host.admin.activateScope(staff, tenant, bare);
308
+ const dump = await host.admin.exportScope(staff, tenant, scopeA);
309
+ expect(dump.stores.length).toBeGreaterThan(0);
310
+ const restored = await host.restoreScope(staff, tenant, bare, { ...dump, tables: [...dump.tables, PROBE_TABLE] }).then(() => undefined, (err) => err);
311
+ expect(errorCodeOf(restored)).toBe('conflict');
312
+ expect(await tableNames(bare)).not.toContain(PROBE_TABLE.name);
313
+ const dest = scopeId.parse(ulid());
314
+ const imported = await host.importScope(staff, { tenantId: tenant, scopeId: dest }, dump).then(() => undefined, (err) => err);
315
+ expect(errorCodeOf(imported)).toBe('conflict');
316
+ expect(await host.admin.getScopeRecord(staff, tenant, dest)).toBeUndefined();
317
+ });
318
+ // The scope's tables load before its stores: a dump whose tables cannot load leaves the
319
+ // store as it was, rather than replaced under tables that never arrived.
320
+ it('a restore whose tables cannot load leaves the store as it was', async () => {
321
+ const store = await host.getScopeStore(principal, tenant, scopeA, 'GRAPH');
322
+ const dump = await host.admin.exportScope(staff, tenant, scopeA);
323
+ await store.invoke('increment', { by: 5 });
324
+ const now = Number(await store.invoke('read'));
325
+ const refusal = await host.restoreScope(staff, tenant, scopeA, {
326
+ ...dump,
327
+ tables: [...dump.tables, { name: 'unloadable', ddl: 'CREATE TABLE something_else (id TEXT)', columns: ['id'], rows: [] }],
328
+ }).then(() => undefined, (err) => err);
329
+ expect(refusal).toBeDefined();
330
+ expect(await store.invoke('read')).toBe(now);
331
+ });
332
+ it('a dump that names an unbound store is refused before a scope is created', async () => {
333
+ const dump = await host.admin.exportScope(staff, tenant, scopeA);
334
+ const dest = scopeId.parse(ulid());
335
+ const refusal = await host.importScope(staff, {
336
+ tenantId: tenant,
337
+ scopeId: dest,
338
+ vertical: 'docs',
339
+ }, { ...dump, stores: [...dump.stores, { binding: 'MISSING', kind: 'counter', tables: [] }] }).then(() => undefined, (err) => err);
340
+ expect(errorCodeOf(refusal)).toBe('not_found');
341
+ expect(await host.admin.getScopeRecord(staff, tenant, dest)).toBeUndefined();
342
+ });
343
+ it('deleting a fork whose wipe fails leaves the fork and its storage', async () => {
344
+ const fork = await host.snapshotScope(staff, tenant, scopeA);
345
+ const seeded = await host.admin.exportScope(staff, tenant, fork);
346
+ await host.restoreScope(staff, tenant, fork, { ...seeded, tables: [...seeded.tables, PROBE_TABLE] });
347
+ const store = await host.getScopeStore(principal, tenant, fork, 'GRAPH');
348
+ await store.invoke('fail-wipe', true);
349
+ try {
350
+ const refusal = await host.deleteSnapshot(staff, tenant, fork).then(() => undefined, (err) => err);
351
+ expect(refusal).toBeDefined();
352
+ expect(await host.admin.getScopeRecord(staff, tenant, fork)).toBeDefined();
353
+ expect(await tableNames(fork)).toContain(PROBE_TABLE.name);
354
+ }
355
+ finally {
356
+ await store.invoke('fail-wipe', false);
357
+ }
358
+ await host.deleteSnapshot(staff, tenant, fork);
359
+ });
360
+ it('deleting a fork wipes its store', async () => {
361
+ const fork = await host.snapshotScope(staff, tenant, scopeA);
362
+ await host.deleteSnapshot(staff, tenant, fork);
363
+ const live = await host.getScopeStore(principal, tenant, scopeA, 'GRAPH');
364
+ const names = await live.invoke('wiped');
365
+ expect(names).toContain(`${tenant}/docs/${fork}/GRAPH`);
366
+ });
367
+ // L7: the wipe runs before the scope's storage goes, so a wipe that fails leaves the
368
+ // scope archived and its data where it was, and the reap converges on a retry.
369
+ it('a reap whose wipe fails leaves the scope archived and its data intact', async () => {
370
+ const doomed = scopeId.parse(ulid());
371
+ await host.provisionScope(staff, { tenantId: tenant, scopeId: doomed, vertical: 'docs' });
372
+ await host.admin.activateScope(staff, tenant, doomed);
373
+ const store = await host.getScopeStore(principal, tenant, doomed, 'GRAPH');
374
+ await store.invoke('increment', { by: 7 });
375
+ // A table in the scope's own storage, so its survival is visible as well as the store's.
376
+ const seeded = await host.admin.exportScope(staff, tenant, doomed);
377
+ await host.restoreScope(staff, tenant, doomed, { ...seeded, tables: [...seeded.tables, PROBE_TABLE] });
378
+ expect(await tableNames(doomed)).toContain(PROBE_TABLE.name);
379
+ await host.admin.archiveScope(staff, tenant, doomed);
380
+ await store.invoke('fail-wipe', true);
381
+ try {
382
+ const refusal = await host.admin.reapScope(staff, tenant, doomed).then(() => undefined, (err) => err);
383
+ expect(refusal).toBeDefined();
384
+ expect((await host.admin.getScopeRecord(staff, tenant, doomed))?.status).toBe('archived');
385
+ const dump = await host.admin.exportScope(staff, tenant, doomed);
386
+ expect(dump.stores.find((one) => one.binding === 'GRAPH')?.tables[0]?.rows).toEqual([[7]]);
387
+ expect(dump.tables.map((table) => table.name)).toContain(PROBE_TABLE.name);
388
+ }
389
+ finally {
390
+ await store.invoke('fail-wipe', false);
391
+ }
392
+ await host.admin.reapScope(staff, tenant, doomed);
393
+ expect((await host.admin.getScopeRecord(staff, tenant, doomed))?.status).toBe('reaped');
394
+ });
395
+ it('reaping a scope wipes its store', async () => {
396
+ await host.admin.archiveScope(staff, tenant, scopeB);
397
+ await host.admin.reapScope(staff, tenant, scopeB);
398
+ const live = await host.getScopeStore(principal, tenant, scopeA, 'GRAPH');
399
+ const names = await live.invoke('wiped');
400
+ expect(names).toContain(`${tenant}/docs/${scopeB}/GRAPH`);
401
+ });
402
+ it('a store call is reported without its input, and a thrown observer does not fail it', async () => {
403
+ const reports = [];
404
+ const store = await host.getScopeStore(principal, tenant, scopeA, 'GRAPH', {
405
+ onStoreCall: (report) => reports.push(report),
406
+ });
407
+ const secret = 'store-call-secret-value';
408
+ await store.invoke('increment', { by: 1, note: secret });
409
+ expect(reports).toHaveLength(1);
410
+ expect(reports[0]).toMatchObject({ binding: 'GRAPH', operation: 'increment', outcome: 'ok' });
411
+ expect(reports[0]?.durationMs).toBeGreaterThanOrEqual(0);
412
+ expect(Object.keys(reports[0] ?? {}).sort()).toEqual(['binding', 'durationMs', 'operation', 'outcome']);
413
+ expect(JSON.stringify(reports)).not.toContain(secret);
414
+ const refusal = await store.invoke('missing', { note: secret }).then(() => undefined, (err) => err);
415
+ expect(refusal).toBeDefined();
416
+ expect(reports[1]).toMatchObject({ binding: 'GRAPH', operation: 'missing', outcome: 'error' });
417
+ expect(JSON.stringify(reports)).not.toContain(secret);
418
+ const loud = await host.getScopeStore(principal, tenant, scopeA, 'GRAPH', {
419
+ onStoreCall() {
420
+ throw new Error('observer');
421
+ },
422
+ });
423
+ expect(await loud.invoke('read')).toEqual(expect.any(Number));
424
+ });
425
+ it('with no observer, a call lands on the request now running; a passed one wins (#2245)', async () => {
426
+ // The platform entry installs this slot (vertical-host's `runRecordingStoreCalls`);
427
+ // here a fixed tally stands in for the request's record.
428
+ const key = Symbol.for(STORE_CALL_SINK_KEY);
429
+ const g = globalThis;
430
+ const before = g[key];
431
+ const current = {};
432
+ g[key] = { current: () => (call) => tallyStoreCall(current, call) };
433
+ try {
434
+ const secret = 'store-call-secret-value';
435
+ const store = await host.getScopeStore(principal, tenant, scopeA, 'GRAPH');
436
+ await store.invoke('increment', { by: 1, note: secret });
437
+ await store.invoke('missing', { note: secret }).catch(() => undefined);
438
+ expect(current.storeCalls).toEqual([
439
+ { binding: 'GRAPH', operation: 'increment', outcome: 'ok', durationMs: expect.any(Number) },
440
+ { binding: 'GRAPH', operation: 'missing', outcome: 'error', durationMs: expect.any(Number) },
441
+ ]);
442
+ expect(JSON.stringify(current)).not.toContain(secret);
443
+ const heard = [];
444
+ const own = await host.getScopeStore(principal, tenant, scopeA, 'GRAPH', { onStoreCall: (r) => heard.push(r) });
445
+ await own.invoke('read');
446
+ expect(heard.map((r) => r.operation)).toEqual(['read']);
447
+ expect(current.storeCalls).toHaveLength(2);
448
+ }
449
+ finally {
450
+ g[key] = before;
451
+ }
452
+ });
453
+ it('a call outside any request is not logged and does not throw (#2245)', async () => {
454
+ const key = Symbol.for(STORE_CALL_SINK_KEY);
455
+ const g = globalThis;
456
+ const before = g[key];
457
+ try {
458
+ g[key] = undefined;
459
+ const store = await host.getScopeStore(principal, tenant, scopeA, 'GRAPH');
460
+ expect(await store.invoke('read')).toEqual(expect.any(Number));
461
+ g[key] = { current: () => undefined };
462
+ expect(await store.invoke('read')).toEqual(expect.any(Number));
463
+ g[key] = {
464
+ current() {
465
+ throw new Error('slot');
466
+ },
467
+ };
468
+ expect(await store.invoke('read')).toEqual(expect.any(Number));
469
+ }
470
+ finally {
471
+ g[key] = before;
472
+ }
473
+ });
474
+ it('an operation name that is not a fixed label is refused before the call, and not reported (#2245)', async () => {
475
+ const reports = [];
476
+ const store = await host.getScopeStore(principal, tenant, scopeA, 'GRAPH', { onStoreCall: (r) => reports.push(r) });
477
+ const count = await store.invoke('read');
478
+ reports.length = 0;
479
+ for (const name of ['incr:ement', 'incr ement', 'incr@ement', 'i'.repeat(65)]) {
480
+ const refusal = await store.invoke(name).then(() => undefined, (err) => err);
481
+ expect(errorCodeOf(refusal), name).toBe('validation_failed');
482
+ }
483
+ expect(reports).toEqual([]);
484
+ expect(await store.invoke('read')).toBe(count);
485
+ });
486
+ it('a reserved operation is refused through the stub', async () => {
487
+ const store = await host.getScopeStore(principal, tenant, scopeA, 'GRAPH');
488
+ const before = await store.invoke('read');
489
+ const refusal = await store.invoke('substrat.wipe').then(() => undefined, (err) => err);
490
+ expect(errorCodeOf(refusal)).toBe('forbidden');
491
+ expect(await store.invoke('read')).toBe(before);
492
+ });
493
+ /*
494
+ * #2207: a restore re-erases every tombstoned subject. Each case restores into a scope of
495
+ * its own, so the count is exact: one `substrat.erase` per subject on the one binding.
496
+ */
497
+ describe('a restore replays erasures', () => {
498
+ const fresh = async (t = tenant) => {
499
+ const scope = scopeId.parse(ulid());
500
+ await host.provisionScope(staff, { tenantId: t, scopeId: scope, vertical: 'docs' });
501
+ await host.admin.activateScope(staff, t, scope);
502
+ return scope;
503
+ };
504
+ const erasedIn = async (scope, t = tenant) => (await host.getScopeStore(principal, t, scope, 'GRAPH')).invoke('erased');
505
+ it('a scope with no store, on a host with stores, restores as it always did', async () => {
506
+ const plain = scopeId.parse(ulid());
507
+ await host.provisionScope(staff, { tenantId: tenant, scopeId: plain });
508
+ await host.admin.activateScope(staff, tenant, plain);
509
+ await host.admin.shredSubject(staff, tenant, plain, ulid());
510
+ const dump = { ...(await host.admin.exportScope(staff, tenant, plain)), scopeId: scopeId.parse(ulid()) };
511
+ expect(await host.restoreScope(staff, tenant, plain, dump)).toBeUndefined();
512
+ });
513
+ it('with a store, no tombstone and a known source, answers nothing: none had to be replayed', async () => {
514
+ const scope = await fresh();
515
+ const dump = await host.admin.exportScope(staff, tenant, scope);
516
+ expect(await host.restoreScope(staff, tenant, scope, dump)).toBeUndefined();
517
+ });
518
+ it('re-erases a subject tombstoned in the restored scope', async () => {
519
+ const scope = await fresh();
520
+ const dump = await host.admin.exportScope(staff, tenant, scope);
521
+ const subject = ulid();
522
+ await host.admin.shredSubject(staff, tenant, scope, subject);
523
+ expect(await host.restoreScope(staff, tenant, scope, dump)).toEqual({
524
+ storeErasuresReplayed: 1,
525
+ storeErasureIncomplete: false,
526
+ });
527
+ });
528
+ it("onto another scope, re-erases the source's marks and the target's own", async () => {
529
+ const source = await fresh();
530
+ const target = await fresh();
531
+ const fromSource = ulid();
532
+ const fromTarget = ulid();
533
+ await host.admin.shredSubject(staff, tenant, source, fromSource);
534
+ await host.admin.shredSubject(staff, tenant, target, fromTarget);
535
+ const dump = await host.admin.exportScope(staff, tenant, source);
536
+ expect(await host.restoreScope(staff, tenant, target, dump)).toEqual({
537
+ storeErasuresReplayed: 2,
538
+ storeErasureIncomplete: false,
539
+ });
540
+ expect(await erasedIn(target)).toEqual(expect.arrayContaining([fromSource, fromTarget]));
541
+ });
542
+ it('from a source this host does not know, restores and says the replay is incomplete', async () => {
543
+ const target = await fresh();
544
+ const store = await host.getScopeStore(principal, tenant, target, 'GRAPH');
545
+ await store.invoke('increment', { by: 7 });
546
+ const own = ulid();
547
+ await host.admin.shredSubject(staff, tenant, target, own);
548
+ const dump = { ...(await host.admin.exportScope(staff, tenant, target)), scopeId: scopeId.parse(ulid()) };
549
+ await store.invoke('increment', { by: 1 });
550
+ expect(await host.restoreScope(staff, tenant, target, dump)).toEqual({
551
+ storeErasuresReplayed: 1,
552
+ storeErasureIncomplete: true,
553
+ storeErasureSourceUnknown: true,
554
+ });
555
+ // The data landed, and the target's own mark was still replayed.
556
+ expect(await store.invoke('read')).toBe(7);
557
+ expect(await erasedIn(target)).toContain(own);
558
+ });
559
+ it("never reads another tenant's marks: that source is unknown here", async () => {
560
+ const other = tenantId.parse(ulid());
561
+ await host.admin.createTenant(staff, { id: other, slug: `t-${other.toLowerCase()}`, name: 'Other' });
562
+ const elsewhere = await fresh(other);
563
+ const theirs = ulid();
564
+ await host.admin.shredSubject(staff, other, elsewhere, theirs);
565
+ const target = await fresh();
566
+ const dump = { ...(await host.admin.exportScope(staff, tenant, target)), scopeId: elsewhere };
567
+ expect(await host.restoreScope(staff, tenant, target, dump)).toEqual({
568
+ storeErasuresReplayed: 0,
569
+ storeErasureIncomplete: true,
570
+ storeErasureSourceUnknown: true,
571
+ });
572
+ expect(await erasedIn(target)).not.toContain(theirs);
573
+ });
574
+ it('from a reaped source, still replays its marks: a reap keeps the directory row', async () => {
575
+ const source = await fresh();
576
+ const subject = ulid();
577
+ await host.admin.shredSubject(staff, tenant, source, subject);
578
+ const dump = await host.admin.exportScope(staff, tenant, source);
579
+ await host.admin.archiveScope(staff, tenant, source);
580
+ await host.admin.reapScope(staff, tenant, source);
581
+ const target = await fresh();
582
+ expect(await host.restoreScope(staff, tenant, target, dump)).toEqual({
583
+ storeErasuresReplayed: 1,
584
+ storeErasureIncomplete: false,
585
+ });
586
+ expect(await erasedIn(target)).toContain(subject);
587
+ });
588
+ it('from a deleted snapshot, restores and says the replay is incomplete: the row is gone', async () => {
589
+ const fork = await host.snapshotScope(staff, tenant, await fresh());
590
+ const subject = ulid();
591
+ await host.admin.shredSubject(staff, tenant, fork, subject);
592
+ const dump = await host.admin.exportScope(staff, tenant, fork);
593
+ await host.deleteSnapshot(staff, tenant, fork);
594
+ const target = await fresh();
595
+ expect(await host.restoreScope(staff, tenant, target, dump)).toEqual({
596
+ storeErasuresReplayed: 0,
597
+ storeErasureIncomplete: true,
598
+ storeErasureSourceUnknown: true,
599
+ });
600
+ expect(await erasedIn(target)).not.toContain(subject);
601
+ });
602
+ });
603
+ });
604
+ }
605
+ //# sourceMappingURL=scope-store-suite.js.map