@substrat-run/contract-tests 0.116.0 → 0.117.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.
- package/dist/capability-expiry-suite.d.ts +8 -0
- package/dist/capability-expiry-suite.d.ts.map +1 -0
- package/dist/capability-expiry-suite.js +131 -0
- package/dist/capability-expiry-suite.js.map +1 -0
- package/dist/capability-suite.d.ts +3 -0
- package/dist/capability-suite.d.ts.map +1 -0
- package/dist/capability-suite.js +498 -0
- package/dist/capability-suite.js.map +1 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/modules.d.ts +126 -0
- package/dist/modules.d.ts.map +1 -1
- package/dist/modules.js +273 -1
- package/dist/modules.js.map +1 -1
- package/dist/permission-suite.d.ts.map +1 -1
- package/dist/permission-suite.js +150 -2
- package/dist/permission-suite.js.map +1 -1
- package/dist/schedule-suite.d.ts +14 -0
- package/dist/schedule-suite.d.ts.map +1 -1
- package/dist/schedule-suite.js +22 -1
- package/dist/schedule-suite.js.map +1 -1
- package/dist/scope-host-suite.d.ts.map +1 -1
- package/dist/scope-host-suite.js +945 -12
- package/dist/scope-host-suite.js.map +1 -1
- package/dist/system-switch-suite.d.ts +16 -0
- package/dist/system-switch-suite.d.ts.map +1 -0
- package/dist/system-switch-suite.js +215 -0
- package/dist/system-switch-suite.js.map +1 -0
- package/package.json +5 -4
package/dist/scope-host-suite.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
2
2
|
import { connectorCalls, connectorTestFetch, resetConnectorCalls } from './connector-fixture.js';
|
|
3
|
-
import { connectionId, dataSubjectId, errorCodeOf, eventId, instant, moduleManifest, orgId, AUTO_ADMISSION_NOTE, permissionKey, platformActorId, principalId, scopeId, tenantId, SCOPE_QUERY_ROW_MAX, } from '@substrat-run/contracts';
|
|
4
|
-
import { isSearchIndexTable, runPlatformSweep, ulid, } from '@substrat-run/kernel';
|
|
3
|
+
import { connectionId, dataSubjectId, domainEvent, errorCodeOf, eventId, instant, moduleManifest, orgId, AUTO_ADMISSION_NOTE, permissionKey, platformActorId, platformRequestId, principalId, scopeId, tenantId, SCOPE_QUERY_ROW_MAX, } from '@substrat-run/contracts';
|
|
4
|
+
import { isSearchIndexTable, REDACTED_INTENT_MARKER, runPlatformSweep, ulid, } from '@substrat-run/kernel';
|
|
5
5
|
import { billedMod, contractTestBareOps, contractTestInitialModules, gateModManifest, lateMod, testModManifest, victimModManifest, } from './modules.js';
|
|
6
6
|
/**
|
|
7
7
|
* Assert a refusal by the CODE it declared, not by the sentence it happens to carry
|
|
@@ -376,6 +376,574 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
376
376
|
expect(settled.lastError).toMatch(/personal number field/);
|
|
377
377
|
expect(settled.payload).toEqual({ contract: 'c1' });
|
|
378
378
|
});
|
|
379
|
+
/**
|
|
380
|
+
* #1588. Every read of the intent journal returns a LIST, and the row decode behind all
|
|
381
|
+
* three was strict — so ONE row whose JSON would not parse threw out of the map and took
|
|
382
|
+
* the scope's every other intent with it. The drain could not read its own queue, and the
|
|
383
|
+
* history read, which exists so a failed intent explains itself (#618), was switched off
|
|
384
|
+
* by exactly the row that failed.
|
|
385
|
+
*
|
|
386
|
+
* Nothing module code can do produces such a row (`ctx.sql` refuses `_substrat_*` writes,
|
|
387
|
+
* #954). A restore does: `importDump` replays rows verbatim, so a dump from another world
|
|
388
|
+
* or edited by hand is the whole reproduction — the lever #1587's run-driver test uses.
|
|
389
|
+
* The dump is the scope's OWN export with only the journal's rows replaced, so the rest of
|
|
390
|
+
* the spine comes back as it was and the in-scope read can still be invoked.
|
|
391
|
+
*/
|
|
392
|
+
describe('one undecodable intent row (#1588)', () => {
|
|
393
|
+
/** Sorts before every ULID, so the pending read (ORDER BY id) meets it FIRST. */
|
|
394
|
+
const brokenPendingId = platformRequestId.parse('00000000000000000000000000');
|
|
395
|
+
const brokenSettledId = platformRequestId.parse('00000000000000000000000001');
|
|
396
|
+
const at = '2026-09-01T00:00:00.000Z';
|
|
397
|
+
const healthyPending = {
|
|
398
|
+
id: ulid(),
|
|
399
|
+
kind: 'connector:test',
|
|
400
|
+
payload: JSON.stringify({ doc: 1 }),
|
|
401
|
+
requested_by: JSON.stringify({ system: 'connector-dispatch' }),
|
|
402
|
+
status: 'pending',
|
|
403
|
+
attempts: 0,
|
|
404
|
+
requested_at: at,
|
|
405
|
+
};
|
|
406
|
+
const healthySettled = {
|
|
407
|
+
id: ulid(),
|
|
408
|
+
kind: 'connector:test',
|
|
409
|
+
payload: JSON.stringify({ doc: 2 }),
|
|
410
|
+
requested_by: JSON.stringify(alice),
|
|
411
|
+
status: 'failed',
|
|
412
|
+
attempts: 1,
|
|
413
|
+
last_error: 'HTTP 409 requires valid personal number field',
|
|
414
|
+
last_failure: JSON.stringify({ origin: 'provider', code: null, permission: null }),
|
|
415
|
+
result: JSON.stringify({ eventId: 'E1' }),
|
|
416
|
+
requested_at: at,
|
|
417
|
+
settled_at: at,
|
|
418
|
+
};
|
|
419
|
+
/** The queue's bad row: a payload nothing could act on. */
|
|
420
|
+
const brokenPending = { ...healthyPending, id: brokenPendingId, payload: 'not json at all' };
|
|
421
|
+
/** The journal's bad row: the failure is on record, beside two columns that are not. */
|
|
422
|
+
const brokenSettled = {
|
|
423
|
+
...healthySettled,
|
|
424
|
+
id: brokenSettledId,
|
|
425
|
+
last_error: 'HTTP 422 the evidence an operator came here to read',
|
|
426
|
+
last_failure: 'nope',
|
|
427
|
+
result: '{"eventId":',
|
|
428
|
+
};
|
|
429
|
+
/** A fresh scope whose journal holds exactly `rows`, planted by a restore. */
|
|
430
|
+
const restoredWith = async (rows) => {
|
|
431
|
+
const s = scopeId.parse(ulid());
|
|
432
|
+
await host.provisionScope(staff, {
|
|
433
|
+
tenantId: t1,
|
|
434
|
+
scopeId: s,
|
|
435
|
+
jurisdiction: 'eu',
|
|
436
|
+
vertical: 'connector-vertical',
|
|
437
|
+
});
|
|
438
|
+
await host.admin.activateScope(staff, t1, s);
|
|
439
|
+
const backup = await host.admin.exportScope(staff, t1, s);
|
|
440
|
+
const journal = backup.tables.find((t) => t.name === '_substrat_platform_requests');
|
|
441
|
+
// Without the table in the export this test would plant nothing and pass vacuously.
|
|
442
|
+
expect(journal).toBeDefined();
|
|
443
|
+
await host.restoreScope(staff, t1, s, {
|
|
444
|
+
...backup,
|
|
445
|
+
tables: backup.tables.map((t) => t === journal ? { ...t, rows: rows.map((r) => t.columns.map((c) => r[c] ?? null)) } : t),
|
|
446
|
+
});
|
|
447
|
+
return s;
|
|
448
|
+
};
|
|
449
|
+
/** All three reads, so each of the three decode sites is on the hook. */
|
|
450
|
+
const readAll = async (s) => ({
|
|
451
|
+
pending: await host.listPlatformRequests(t1, s),
|
|
452
|
+
history: await host.listPlatformRequestHistory(t1, s),
|
|
453
|
+
inScope: await (await host.getScope(alice, t1, s)).invoke('platform/intents'),
|
|
454
|
+
});
|
|
455
|
+
const byId = (rows, id) => rows.find((r) => r.id === id);
|
|
456
|
+
it('a clean journal reads exactly as before — no decodeError anywhere (the positive twin)', async () => {
|
|
457
|
+
const s = await restoredWith([healthyPending, healthySettled]);
|
|
458
|
+
const { pending, history, inScope } = await readAll(s);
|
|
459
|
+
expect(pending.map((r) => r.id)).toEqual([healthyPending.id]);
|
|
460
|
+
expect(history.map((r) => r.id).sort()).toEqual([healthyPending.id, healthySettled.id].sort());
|
|
461
|
+
expect(inScope.map((r) => r.id).sort()).toEqual([healthyPending.id, healthySettled.id].sort());
|
|
462
|
+
for (const row of [...pending, ...history, ...inScope]) {
|
|
463
|
+
// ABSENT rather than null, so a healthy list is the shape it always was.
|
|
464
|
+
expect(row).not.toHaveProperty('decodeError');
|
|
465
|
+
}
|
|
466
|
+
expect(byId(history, healthySettled.id)).toMatchObject({
|
|
467
|
+
payload: { doc: 2 },
|
|
468
|
+
requestedBy: alice,
|
|
469
|
+
failure: { origin: 'provider', code: null, permission: null },
|
|
470
|
+
result: { eventId: 'E1' },
|
|
471
|
+
});
|
|
472
|
+
});
|
|
473
|
+
it('returns every other row beside the bad one, and the bad one says why', async () => {
|
|
474
|
+
const clean = await readAll(await restoredWith([healthyPending, healthySettled]));
|
|
475
|
+
const s = await restoredWith([brokenPending, healthyPending, brokenSettled, healthySettled]);
|
|
476
|
+
const { pending, history, inScope } = await readAll(s);
|
|
477
|
+
// The healthy rows are all there, and read EXACTLY as they do with no bad neighbour.
|
|
478
|
+
expect(byId(pending, healthyPending.id)).toEqual(byId(clean.pending, healthyPending.id));
|
|
479
|
+
for (const [read, twin] of [
|
|
480
|
+
[history, clean.history],
|
|
481
|
+
[inScope, clean.inScope],
|
|
482
|
+
]) {
|
|
483
|
+
expect(read).toHaveLength(4);
|
|
484
|
+
expect(byId(read, healthyPending.id)).toEqual(byId(twin, healthyPending.id));
|
|
485
|
+
expect(byId(read, healthySettled.id)).toEqual(byId(twin, healthySettled.id));
|
|
486
|
+
}
|
|
487
|
+
// The drain's queue: the bad row is listed — it was listed first — and flagged, with
|
|
488
|
+
// an EMPTY payload rather than the raw text, which would read as a string payload.
|
|
489
|
+
expect(pending.map((r) => r.id)).toEqual([brokenPendingId, healthyPending.id]);
|
|
490
|
+
const queued = byId(pending, brokenPendingId);
|
|
491
|
+
expect(queued.decodeError).toMatch(/^payload: .*JSON/);
|
|
492
|
+
expect(queued.payload).toBeNull();
|
|
493
|
+
expect(queued.kind).toBe('connector:test');
|
|
494
|
+
// The journal: the failure the row records is READABLE, which is the whole point of
|
|
495
|
+
// the read — and every column that did not decode is named, not only the first.
|
|
496
|
+
for (const read of [history, inScope]) {
|
|
497
|
+
const settled = byId(read, brokenSettledId);
|
|
498
|
+
expect(settled.lastError).toBe('HTTP 422 the evidence an operator came here to read');
|
|
499
|
+
expect(settled.status).toBe('failed');
|
|
500
|
+
expect(settled.decodeError).toMatch(/^last_failure: .*; result: /);
|
|
501
|
+
// `null` beside a reason, never a silent `null` that reads as "unclassified".
|
|
502
|
+
expect(settled.failure).toBeNull();
|
|
503
|
+
expect(settled.result).toBeNull();
|
|
504
|
+
}
|
|
505
|
+
});
|
|
506
|
+
it('lets the drain settle the bad row failed — out of the queue, the evidence kept', async () => {
|
|
507
|
+
// The drain lives in control-plane-api and refuses any row carrying `decodeError`
|
|
508
|
+
// without running a handler (tested there, against this adapter's pure twin). What
|
|
509
|
+
// BOTH adapters owe it is that the refusal lands: a bad row can be settled like any
|
|
510
|
+
// other, leaves the queue, and still reads back with its reason.
|
|
511
|
+
const s = await restoredWith([brokenPending, healthyPending]);
|
|
512
|
+
const refusal = 'not executed: the intent row could not be decoded (payload: …)';
|
|
513
|
+
await host.settlePlatformRequest(t1, s, brokenPendingId, {
|
|
514
|
+
status: 'failed',
|
|
515
|
+
lastError: refusal,
|
|
516
|
+
failure: { origin: 'platform', code: 'validation_failed', permission: null },
|
|
517
|
+
});
|
|
518
|
+
expect((await host.listPlatformRequests(t1, s)).map((r) => r.id)).toEqual([healthyPending.id]);
|
|
519
|
+
const after = byId(await host.listPlatformRequestHistory(t1, s), brokenPendingId);
|
|
520
|
+
expect(after.status).toBe('failed');
|
|
521
|
+
expect(after.lastError).toBe(refusal);
|
|
522
|
+
expect(after.failure).toEqual({ origin: 'platform', code: 'validation_failed', permission: null });
|
|
523
|
+
// The stored payload is untouched by the settle, so the row still says it is not whole.
|
|
524
|
+
expect(after.decodeError).toMatch(/^payload: /);
|
|
525
|
+
});
|
|
526
|
+
});
|
|
527
|
+
/**
|
|
528
|
+
* #1636 — #1588's shape everywhere else it lived. One outbox or denial row whose JSON
|
|
529
|
+
* would not parse still took a WHOLE list with it: an entity's history and its walks,
|
|
530
|
+
* the denial log and its summary — and, worse, the outbox's executed paths, where the
|
|
531
|
+
* decode sat above each loop's per-event containment, so one bad event halted every
|
|
532
|
+
* event behind it on every pass.
|
|
533
|
+
*
|
|
534
|
+
* Planted the #1588 way: a fresh scope's OWN export, restored with one table's rows
|
|
535
|
+
* rewritten, because `importDump` replays rows verbatim and nothing else can write a
|
|
536
|
+
* spine row that module code did not (#954). The bad row's id sorts before every ULID,
|
|
537
|
+
* so each read and each loop meets it FIRST — the position that used to halt it.
|
|
538
|
+
*/
|
|
539
|
+
describe('one undecodable spine row (#1636)', () => {
|
|
540
|
+
/** Sorts before every ULID; `n` keeps several of them distinct and in order. */
|
|
541
|
+
const badId = (n = 0) => `0000000000000000000000${String(n).padStart(4, '0')}`;
|
|
542
|
+
const UNDECODED = { system: 'undecodable' };
|
|
543
|
+
/** A fresh scope, seeded through real operations, and its export. */
|
|
544
|
+
const seeded = async (vertical, seed) => {
|
|
545
|
+
const s = scopeId.parse(ulid());
|
|
546
|
+
await host.provisionScope(staff, { tenantId: t1, scopeId: s, jurisdiction: 'eu', vertical });
|
|
547
|
+
await host.admin.activateScope(staff, t1, s);
|
|
548
|
+
await seed(await host.getScope(alice, t1, s));
|
|
549
|
+
return { s, dump: await host.admin.exportScope(staff, t1, s) };
|
|
550
|
+
};
|
|
551
|
+
const rowsOf = (dump, table) => {
|
|
552
|
+
const t = dump.tables.find((x) => x.name === table);
|
|
553
|
+
// Without the table in the export these tests would plant nothing and pass vacuously.
|
|
554
|
+
expect(t).toBeDefined();
|
|
555
|
+
return t.rows.map((r) => Object.fromEntries(t.columns.map((c, i) => [c, r[i]])));
|
|
556
|
+
};
|
|
557
|
+
/** Restore `s` from `dump` with each named table's rows replaced by `replace[table]`. */
|
|
558
|
+
const restore = async (s, dump, replace) => {
|
|
559
|
+
for (const name of Object.keys(replace))
|
|
560
|
+
expect(dump.tables.some((t) => t.name === name)).toBe(true);
|
|
561
|
+
await host.restoreScope(staff, t1, s, {
|
|
562
|
+
...dump,
|
|
563
|
+
tables: dump.tables.map((t) => t.name in replace ? { ...t, rows: replace[t.name].map((r) => t.columns.map((c) => r[c] ?? null)) } : t),
|
|
564
|
+
});
|
|
565
|
+
};
|
|
566
|
+
describe('pure reads: the bad row comes back beside its neighbours, saying why', () => {
|
|
567
|
+
it('history, timeline, both walks and an invocation read (the outbox)', async () => {
|
|
568
|
+
const call = ulid();
|
|
569
|
+
const { s, dump } = await seeded('connector-vertical', async (stub) => {
|
|
570
|
+
for (let i = 0; i < 3; i++)
|
|
571
|
+
await stub.invoke('test/emit-event');
|
|
572
|
+
});
|
|
573
|
+
// One call's worth, so `invocationEvents` has something to read.
|
|
574
|
+
const outbox = rowsOf(dump, '_substrat_outbox').map((r) => r.type === 'test.happened' ? { ...r, invocation_id: call } : r);
|
|
575
|
+
const target = outbox.filter((r) => r.type === 'test.happened')[1];
|
|
576
|
+
const targetId = eventId.parse(target.id);
|
|
577
|
+
const readAll = async () => ({
|
|
578
|
+
history: (await host.admin.entityHistory(staff, t1, s, { entityType: 'test-thing', entityId: 'x1' })).entries,
|
|
579
|
+
timeline: (await (await host.getScope(alice, t1, s)).invoke('test/timeline', {
|
|
580
|
+
entityType: 'test-thing',
|
|
581
|
+
entityId: 'x1',
|
|
582
|
+
})).entries,
|
|
583
|
+
cause: await host.admin.eventCause(staff, t1, s, { eventId: targetId }),
|
|
584
|
+
effects: await host.admin.eventEffects(staff, t1, s, { eventId: targetId }),
|
|
585
|
+
call: (await host.admin.invocationEvents(staff, t1, s, { invocationId: call })).events,
|
|
586
|
+
});
|
|
587
|
+
// The positive twin: the scope's own rows, untouched — nothing says it is not whole.
|
|
588
|
+
await restore(s, dump, { _substrat_outbox: outbox });
|
|
589
|
+
const clean = await readAll();
|
|
590
|
+
expect(clean.history).toHaveLength(3);
|
|
591
|
+
for (const entry of [...clean.history, ...clean.timeline, ...clean.cause.chain, ...clean.call]) {
|
|
592
|
+
expect(entry).not.toHaveProperty('decodeError');
|
|
593
|
+
}
|
|
594
|
+
expect(clean.effects.root.event).not.toHaveProperty('decodeError');
|
|
595
|
+
// The same scope with ONE row's actor and payload unreadable.
|
|
596
|
+
await restore(s, dump, {
|
|
597
|
+
_substrat_outbox: outbox.map((r) => (r.id === target.id ? { ...r, actor: '{', payload: 'not json at all' } : r)),
|
|
598
|
+
});
|
|
599
|
+
const planted = await readAll();
|
|
600
|
+
const reason = 'actor: not valid JSON; payload: not valid JSON';
|
|
601
|
+
// Every row comes back, and every healthy one reads EXACTLY as with no bad neighbour.
|
|
602
|
+
expect(planted.history.map((e) => e.id)).toEqual(clean.history.map((e) => e.id));
|
|
603
|
+
planted.history.forEach((entry, i) => {
|
|
604
|
+
if (entry.id === target.id)
|
|
605
|
+
return;
|
|
606
|
+
expect(entry).toEqual(clean.history[i]);
|
|
607
|
+
});
|
|
608
|
+
const bad = planted.history.find((e) => e.id === target.id);
|
|
609
|
+
expect(bad.decodeError).toBe(reason);
|
|
610
|
+
expect(bad.actor).toEqual(UNDECODED);
|
|
611
|
+
// Null beside a reason — the one thing that keeps it from reading as an erasure.
|
|
612
|
+
expect(bad.payload).toBeNull();
|
|
613
|
+
expect(bad.type).toBe('test.happened');
|
|
614
|
+
expect(planted.timeline).toHaveLength(3);
|
|
615
|
+
expect(planted.timeline.find((e) => e.id === target.id)).toMatchObject({
|
|
616
|
+
actor: UNDECODED,
|
|
617
|
+
decodeError: 'actor: not valid JSON',
|
|
618
|
+
});
|
|
619
|
+
// The walks read it rather than ending in an exception, and end where they did.
|
|
620
|
+
expect(planted.cause.chain[0]).toMatchObject({ id: target.id, decodeError: reason });
|
|
621
|
+
expect(planted.cause.terminal).toBe(clean.cause.terminal);
|
|
622
|
+
expect(planted.effects.root.event.decodeError).toBe(reason);
|
|
623
|
+
expect(planted.call.map((e) => e.id)).toEqual(clean.call.map((e) => e.id));
|
|
624
|
+
expect(planted.call.find((e) => e.id === target.id).decodeError).toBe(reason);
|
|
625
|
+
});
|
|
626
|
+
it('the denial log and its summary', async () => {
|
|
627
|
+
const at = '2026-09-01T00:00:00.000Z';
|
|
628
|
+
const { s, dump } = await seeded('connector-vertical', async () => undefined);
|
|
629
|
+
const denial = (id, over = {}) => ({
|
|
630
|
+
id,
|
|
631
|
+
actor: JSON.stringify(alice),
|
|
632
|
+
permission: 'test:read',
|
|
633
|
+
tenant_id: t1,
|
|
634
|
+
scope_id: s,
|
|
635
|
+
operation: 'test/read',
|
|
636
|
+
impersonation: null,
|
|
637
|
+
invocation_id: null,
|
|
638
|
+
at,
|
|
639
|
+
drained_at: null,
|
|
640
|
+
...over,
|
|
641
|
+
});
|
|
642
|
+
const healthy = [
|
|
643
|
+
denial(ulid()),
|
|
644
|
+
denial(ulid(), { actor: JSON.stringify({ system: '@test/flow' }), permission: 'test:write' }),
|
|
645
|
+
];
|
|
646
|
+
const readAll = async () => ({
|
|
647
|
+
rows: await host.admin.listDenials(staff, t1, s),
|
|
648
|
+
summary: await host.admin.summarizeDenials(staff, t1, s),
|
|
649
|
+
});
|
|
650
|
+
await restore(s, dump, { _substrat_denials: healthy });
|
|
651
|
+
const clean = await readAll();
|
|
652
|
+
expect(clean.rows).toHaveLength(2);
|
|
653
|
+
for (const row of clean.rows)
|
|
654
|
+
expect(row).not.toHaveProperty('decodeError');
|
|
655
|
+
for (const bucket of clean.summary.buckets)
|
|
656
|
+
expect(bucket).not.toHaveProperty('decodeError');
|
|
657
|
+
const bad = denial(badId(), { actor: '{', impersonation: 'nope' });
|
|
658
|
+
// A key a module CAST rather than declared — reachable from live code, since nothing
|
|
659
|
+
// validates a checked key at runtime. The key is the evidence, so it is kept.
|
|
660
|
+
const badKey = denial(ulid(), { permission: 'Workorder:Read' });
|
|
661
|
+
await restore(s, dump, { _substrat_denials: [bad, badKey, ...healthy] });
|
|
662
|
+
const planted = await readAll();
|
|
663
|
+
expect(planted.rows).toHaveLength(4);
|
|
664
|
+
for (const row of clean.rows)
|
|
665
|
+
expect(planted.rows.find((r) => r.id === row.id)).toEqual(row);
|
|
666
|
+
const refused = planted.rows.find((r) => r.id === bad.id);
|
|
667
|
+
expect(refused.decodeError).toBe('actor: not valid JSON; impersonation: not valid JSON');
|
|
668
|
+
expect(refused.actor).toEqual(UNDECODED);
|
|
669
|
+
// Null beside a reason, never a silent null that reads as "nobody was impersonating".
|
|
670
|
+
expect(refused.impersonation).toBeNull();
|
|
671
|
+
expect(refused.permission).toBe('test:read');
|
|
672
|
+
// The malformed key is listed under the marker, the stored key quoted verbatim.
|
|
673
|
+
const castKey = planted.rows.find((r) => r.id === badKey.id);
|
|
674
|
+
expect(castKey.permission).toBe('undecodable:permission');
|
|
675
|
+
expect(castKey.decodeError).toMatch(/^permission: .* \(stored "Workorder:Read"\)$/);
|
|
676
|
+
expect(castKey.actor).toBe(alice);
|
|
677
|
+
// The summary — "the read a console opens first" — keeps both buckets, counted.
|
|
678
|
+
expect(planted.summary.total).toBe(4);
|
|
679
|
+
expect(planted.summary.groupBy).toBe('actor-permission');
|
|
680
|
+
const buckets = planted.summary.groupBy === 'actor-permission' ? planted.summary.buckets : [];
|
|
681
|
+
expect(buckets).toHaveLength(4);
|
|
682
|
+
expect(buckets.find((b) => b.decodeError === 'actor: not valid JSON')).toMatchObject({
|
|
683
|
+
actor: UNDECODED,
|
|
684
|
+
permission: 'test:read',
|
|
685
|
+
count: 1,
|
|
686
|
+
});
|
|
687
|
+
expect(buckets.find((b) => b.permission === 'undecodable:permission')).toMatchObject({
|
|
688
|
+
actor: alice,
|
|
689
|
+
count: 1,
|
|
690
|
+
decodeError: expect.stringContaining('(stored "Workorder:Read")'),
|
|
691
|
+
});
|
|
692
|
+
for (const bucket of clean.summary.buckets)
|
|
693
|
+
expect(planted.summary.buckets).toContainEqual(bucket);
|
|
694
|
+
});
|
|
695
|
+
});
|
|
696
|
+
describe('the three mappers #1641 left casting (#1643): decoded, or thrown naming the column', () => {
|
|
697
|
+
const at = '2026-09-01T00:00:00.000Z';
|
|
698
|
+
it('dead letters and deliveries: a healthy row reads whole; a broken `attempts` is refused, never typed as valid', async () => {
|
|
699
|
+
const { s, dump } = await seeded('connector-vertical', async (stub) => {
|
|
700
|
+
for (let i = 0; i < 2; i++)
|
|
701
|
+
await stub.invoke('test/emit-event');
|
|
702
|
+
});
|
|
703
|
+
const [first, second] = rowsOf(dump, '_substrat_outbox').filter((r) => r.type === 'test.happened');
|
|
704
|
+
const delivery = (event, over = {}) => ({
|
|
705
|
+
event_id: event.id,
|
|
706
|
+
consumer_module: '@test/doomed',
|
|
707
|
+
delivered_at: at,
|
|
708
|
+
error: 'gave up',
|
|
709
|
+
attempts: 3,
|
|
710
|
+
next_attempt_at: null,
|
|
711
|
+
invocation_id: null,
|
|
712
|
+
...over,
|
|
713
|
+
});
|
|
714
|
+
const readAll = async (event) => ({
|
|
715
|
+
letters: (await host.admin.deadLetters(staff, t1, s, {})).entries,
|
|
716
|
+
effects: (await host.admin.eventEffects(staff, t1, s, { eventId: eventId.parse(event.id) })).root.deliveries,
|
|
717
|
+
});
|
|
718
|
+
// The positive twin: two dead letters, one of them an EXECUTOR's — whose id is not a
|
|
719
|
+
// module id, and which a decode against a bare `moduleId` would have refused.
|
|
720
|
+
await restore(s, dump, {
|
|
721
|
+
_substrat_deliveries: [delivery(first), delivery(second, { consumer_module: 'executor:mailer' })],
|
|
722
|
+
});
|
|
723
|
+
const clean = await readAll(second);
|
|
724
|
+
expect(clean.letters).toHaveLength(2);
|
|
725
|
+
expect(clean.letters.map((l) => l.consumer).sort()).toEqual(['@test/doomed', 'executor:mailer']);
|
|
726
|
+
for (const l of clean.letters)
|
|
727
|
+
expect(l).not.toHaveProperty('decodeError');
|
|
728
|
+
expect(clean.effects).toMatchObject([{ consumer: 'executor:mailer', state: 'dead', attempts: 3 }]);
|
|
729
|
+
expect(clean.effects[0]).not.toHaveProperty('decodeError');
|
|
730
|
+
// The same rows with one `attempts` that cannot be decoded as a non-negative integer (a
|
|
731
|
+
// negative number, or text). It has no honest empty value, so the read names the column —
|
|
732
|
+
// on both reads, on both adapters.
|
|
733
|
+
for (const attempts of [-1, 'many']) {
|
|
734
|
+
await restore(s, dump, { _substrat_deliveries: [delivery(first), delivery(second, { attempts })] });
|
|
735
|
+
await expect(host.admin.deadLetters(staff, t1, s, {})).rejects.toThrow(/DeadLetter — attempts: /);
|
|
736
|
+
await expect(host.admin.eventEffects(staff, t1, s, { eventId: eventId.parse(second.id) })).rejects.toThrow(/valid EventDelivery — attempts: /);
|
|
737
|
+
}
|
|
738
|
+
// …and the row that is fine is still fine once the bad one is repaired.
|
|
739
|
+
await restore(s, dump, { _substrat_deliveries: [delivery(first), delivery(second, { attempts: 2 })] });
|
|
740
|
+
expect((await readAll(second)).letters.map((l) => l.attempts).sort()).toEqual([2, 3]);
|
|
741
|
+
});
|
|
742
|
+
it('an executor delivery is read whatever id registration let through — the reader matches the writer', async () => {
|
|
743
|
+
// `registerExecutor` accepts any string and both adapters persist `executor:${id}`, so the
|
|
744
|
+
// kernel can itself write an empty id or one with a newline. Refusing those would throw
|
|
745
|
+
// the whole page on a delivery the kernel wrote.
|
|
746
|
+
const { s, dump } = await seeded('connector-vertical', (stub) => stub.invoke('test/emit-event'));
|
|
747
|
+
const [event] = rowsOf(dump, '_substrat_outbox').filter((r) => r.type === 'test.happened');
|
|
748
|
+
const consumers = ['executor:mailer', 'executor:', 'executor:line\nbreak', 'executor:a b/ç'];
|
|
749
|
+
await restore(s, dump, {
|
|
750
|
+
_substrat_deliveries: consumers.map((consumer_module) => ({
|
|
751
|
+
event_id: event.id,
|
|
752
|
+
consumer_module,
|
|
753
|
+
delivered_at: at,
|
|
754
|
+
error: 'gave up',
|
|
755
|
+
attempts: 3,
|
|
756
|
+
next_attempt_at: null,
|
|
757
|
+
invocation_id: null,
|
|
758
|
+
})),
|
|
759
|
+
});
|
|
760
|
+
const letters = (await host.admin.deadLetters(staff, t1, s, {})).entries;
|
|
761
|
+
expect(letters.map((l) => l.consumer).sort()).toEqual([...consumers].sort());
|
|
762
|
+
const effects = (await host.admin.eventEffects(staff, t1, s, { eventId: eventId.parse(event.id) })).root.deliveries;
|
|
763
|
+
expect(effects.map((d) => d.consumer).sort()).toEqual([...consumers].sort());
|
|
764
|
+
for (const row of [...letters, ...effects])
|
|
765
|
+
expect(row).not.toHaveProperty('decodeError');
|
|
766
|
+
});
|
|
767
|
+
it('the operation summary: healthy buckets read whole; a broken time is refused naming its column', async () => {
|
|
768
|
+
const { s, dump } = await seeded('connector-vertical', async () => undefined);
|
|
769
|
+
const denial = (over = {}) => ({
|
|
770
|
+
id: ulid(),
|
|
771
|
+
actor: JSON.stringify(alice),
|
|
772
|
+
permission: 'test:read',
|
|
773
|
+
tenant_id: t1,
|
|
774
|
+
scope_id: s,
|
|
775
|
+
operation: 'test/read',
|
|
776
|
+
impersonation: null,
|
|
777
|
+
invocation_id: null,
|
|
778
|
+
at,
|
|
779
|
+
drained_at: null,
|
|
780
|
+
...over,
|
|
781
|
+
});
|
|
782
|
+
const summarize = () => host.admin.summarizeDenials(staff, t1, s, { groupBy: 'operation' });
|
|
783
|
+
await restore(s, dump, {
|
|
784
|
+
_substrat_denials: [denial(), denial(), denial({ operation: 'test/write' }), denial({ operation: null })],
|
|
785
|
+
});
|
|
786
|
+
const clean = await summarize();
|
|
787
|
+
expect(clean.groupBy).toBe('operation');
|
|
788
|
+
expect(clean.buckets).toHaveLength(3);
|
|
789
|
+
const byOp = (op) => clean.buckets.find((b) => b.operation === op);
|
|
790
|
+
expect(byOp('test/read')).toMatchObject({ count: 2, firstAt: at, lastAt: at });
|
|
791
|
+
expect(byOp('test/write')).toMatchObject({ count: 1 });
|
|
792
|
+
// The refusal outside any operation is a bucket of its own — and that null is a fact.
|
|
793
|
+
expect(byOp(null)).toMatchObject({ count: 1 });
|
|
794
|
+
for (const b of clean.buckets)
|
|
795
|
+
expect(b).not.toHaveProperty('decodeError');
|
|
796
|
+
// One denial with an empty time: the bucket it lands in cannot state when it began.
|
|
797
|
+
await restore(s, dump, {
|
|
798
|
+
_substrat_denials: [denial({ operation: 'test/write', at: '' }), denial()],
|
|
799
|
+
});
|
|
800
|
+
await expect(summarize()).rejects.toThrow(/DenialOperationBucket — first_at: /);
|
|
801
|
+
});
|
|
802
|
+
});
|
|
803
|
+
describe('executed rows: the bad event is dead-lettered, and the one behind it runs', () => {
|
|
804
|
+
it('consumer delivery', async () => {
|
|
805
|
+
const { s, dump } = await seeded('flow-vertical', (stub) => stub.invoke('flow/produce'));
|
|
806
|
+
const step1 = rowsOf(dump, '_substrat_outbox').find((r) => r.type === 'flow.step1');
|
|
807
|
+
const healthy = { ...step1, id: ulid() };
|
|
808
|
+
const deliveredIds = async () => {
|
|
809
|
+
const stub = await host.getScope(alice, t1, s);
|
|
810
|
+
// Any invoke drains the scope's consumers in its tail.
|
|
811
|
+
await stub.invoke('flow/produce');
|
|
812
|
+
return {
|
|
813
|
+
log: (await stub.invoke('flow/log')).map((r) => r.event_id),
|
|
814
|
+
deliveries: await stub.invoke('flow/deliveries'),
|
|
815
|
+
};
|
|
816
|
+
};
|
|
817
|
+
// Undelivered, the both of them: no deliveries and no consumer-side rows.
|
|
818
|
+
const fresh = { _substrat_deliveries: [], flow_log: [] };
|
|
819
|
+
await restore(s, dump, { ...fresh, _substrat_outbox: [healthy] });
|
|
820
|
+
const clean = await deliveredIds();
|
|
821
|
+
expect(clean.log).toContain(healthy.id);
|
|
822
|
+
expect(clean.deliveries.every((d) => d.error === null)).toBe(true);
|
|
823
|
+
await restore(s, dump, { ...fresh, _substrat_outbox: [{ ...step1, id: badId(), actor: '{' }, healthy] });
|
|
824
|
+
const planted = await deliveredIds();
|
|
825
|
+
// The event BEHIND the bad one is delivered — the handler ran on it — and the bad
|
|
826
|
+
// one never reached the handler at all.
|
|
827
|
+
expect(planted.log).toContain(healthy.id);
|
|
828
|
+
expect(planted.log).not.toContain(badId());
|
|
829
|
+
const dead = planted.deliveries.find((d) => d.event_id === badId());
|
|
830
|
+
expect(dead.error).toMatch(/cannot be decoded — actor: not valid JSON/);
|
|
831
|
+
expect(planted.deliveries.find((d) => d.event_id === healthy.id).error).toBeNull();
|
|
832
|
+
// …and it is a dead letter like any other, readable where operators look.
|
|
833
|
+
const letters = await host.admin.deadLetters(staff, t1, s, {});
|
|
834
|
+
expect(letters.entries.find((d) => d.eventId === badId())).toMatchObject({ consumer: '@test/flow' });
|
|
835
|
+
});
|
|
836
|
+
it('executor dispatch', async () => {
|
|
837
|
+
const tag = `after-bad-${ulid()}`;
|
|
838
|
+
const { s, dump } = await seeded('connector-vertical', (stub) => stub.invoke('connector/request-effect', { tag: `seed-${ulid()}` }));
|
|
839
|
+
const template = rowsOf(dump, '_substrat_outbox').find((r) => r.type === 'effect.requested');
|
|
840
|
+
const healthy = { ...template, id: ulid(), payload: JSON.stringify({ tag }) };
|
|
841
|
+
const fresh = { _substrat_deliveries: [] };
|
|
842
|
+
// The positive twin: one healthy event, delivered, nothing dead.
|
|
843
|
+
await restore(s, dump, { ...fresh, _substrat_outbox: [healthy] });
|
|
844
|
+
const cleanReport = await host.drainDue(t1, s);
|
|
845
|
+
expect(cleanReport).toMatchObject({ attempted: 1, delivered: 1, deadLettered: 0 });
|
|
846
|
+
expect(effected).toContain(tag);
|
|
847
|
+
const next = `after-bad-${ulid()}`;
|
|
848
|
+
const behind = { ...template, id: ulid(), payload: JSON.stringify({ tag: next }) };
|
|
849
|
+
await restore(s, dump, {
|
|
850
|
+
...fresh,
|
|
851
|
+
_substrat_outbox: [{ ...template, id: badId(), payload: 'not json at all' }, behind],
|
|
852
|
+
});
|
|
853
|
+
const report = await host.drainDue(t1, s);
|
|
854
|
+
// Both attempted; the bad one dead at once — `flaky-effector` allows five attempts,
|
|
855
|
+
// and a decode that failed once can never succeed.
|
|
856
|
+
expect(report).toMatchObject({ attempted: 2, delivered: 1, deadLettered: 1, retrying: 0 });
|
|
857
|
+
expect(effected).toContain(next);
|
|
858
|
+
const dead = (await host.admin.deadLetters(staff, t1, s, {})).entries.find((d) => d.eventId === badId());
|
|
859
|
+
expect(dead).toMatchObject({ consumer: 'executor:flaky-effector', attempts: 1 });
|
|
860
|
+
expect(dead.error).toMatch(/cannot be decoded — payload: not valid JSON/);
|
|
861
|
+
// Terminal: the next pass does not attempt it again.
|
|
862
|
+
expect(await host.drainDue(t1, s)).toMatchObject({ attempted: 0 });
|
|
863
|
+
});
|
|
864
|
+
it('the Tier-2 read steps over it, ships nothing built from stand-ins, and says so', async () => {
|
|
865
|
+
const { s, dump } = await seeded('connector-vertical', async (stub) => {
|
|
866
|
+
await stub.invoke('test/emit-event');
|
|
867
|
+
await stub.invoke('test/emit-event');
|
|
868
|
+
});
|
|
869
|
+
const rows = rowsOf(dump, '_substrat_outbox');
|
|
870
|
+
const events = rows.filter((r) => r.type === 'test.happened');
|
|
871
|
+
// The positive twin: a clean read carries no `skipped` at all.
|
|
872
|
+
await restore(s, dump, { _substrat_outbox: rows });
|
|
873
|
+
const clean = await host.admin.readUndrainedEvents(staff, t1, s, 200);
|
|
874
|
+
expect(clean.map((e) => e.id)).toEqual(expect.arrayContaining(events.map((e) => e.id)));
|
|
875
|
+
expect(clean.skipped).toBeUndefined();
|
|
876
|
+
const bad = { ...events[0], id: badId(), actor: '{' };
|
|
877
|
+
await restore(s, dump, { _substrat_outbox: [bad, ...rows] });
|
|
878
|
+
const read = await host.admin.readUndrainedEvents(staff, t1, s, 200);
|
|
879
|
+
// Every healthy row, exactly as the clean read had it; the bad one nowhere in it.
|
|
880
|
+
expect(read.map((e) => e.id)).toEqual(clean.map((e) => e.id));
|
|
881
|
+
expect([...read]).toEqual([...clean]);
|
|
882
|
+
expect(read.skipped).toEqual({ count: 1, eventIds: [bad.id] });
|
|
883
|
+
// The cost of the skip, pinned: it is never stamped, so it is still undrained — and
|
|
884
|
+
// still reported — once everything behind it has shipped. The lake never has it.
|
|
885
|
+
await host.admin.markEventsDrained(staff, t1, s, read.map((e) => e.id));
|
|
886
|
+
const after = await host.admin.readUndrainedEvents(staff, t1, s, 200);
|
|
887
|
+
expect(after).toHaveLength(0);
|
|
888
|
+
expect(after.skipped).toEqual({ count: 1, eventIds: [bad.id] });
|
|
889
|
+
});
|
|
890
|
+
// #1641 review. Two shapes the first cut let through: an optional column stored as
|
|
891
|
+
// `''` was tested for truthiness and read as ABSENT, skipping validation; and the
|
|
892
|
+
// drain's lifted columns were copied onto a validated envelope unparsed. Both are
|
|
893
|
+
// values the published schema refuses, and both must be contained like any other.
|
|
894
|
+
it('an empty optional column, or a corrupt lifted one, is contained too', async () => {
|
|
895
|
+
const tag = `behind-${ulid()}`;
|
|
896
|
+
const { s, dump } = await seeded('connector-vertical', (stub) => stub.invoke('connector/request-effect', { tag: `seed-${ulid()}` }));
|
|
897
|
+
const template = rowsOf(dump, '_substrat_outbox').find((r) => r.type === 'effect.requested');
|
|
898
|
+
const behind = { ...template, id: ulid(), payload: JSON.stringify({ tag }) };
|
|
899
|
+
const planted = [
|
|
900
|
+
// Envelope columns — every path: delivery, dispatch and the drain.
|
|
901
|
+
{ ...template, id: badId(0), impersonation: '' },
|
|
902
|
+
{ ...template, id: badId(1), authorization: '' },
|
|
903
|
+
// Lifted columns — the drain's alone.
|
|
904
|
+
{ ...template, id: badId(2), version: '' },
|
|
905
|
+
{ ...template, id: badId(3), caused_by: 'not-an-event-id' },
|
|
906
|
+
];
|
|
907
|
+
await restore(s, dump, { _substrat_deliveries: [], _substrat_outbox: [...planted, behind] });
|
|
908
|
+
// The drain: none of the four ships; the event behind them does; all four counted.
|
|
909
|
+
const read = await host.admin.readUndrainedEvents(staff, t1, s, 200);
|
|
910
|
+
expect(read.map((e) => e.id)).toEqual([behind.id]);
|
|
911
|
+
expect(read.skipped).toEqual({ count: 4, eventIds: planted.map((r) => r.id) });
|
|
912
|
+
// Dispatch: the two envelope-broken rows are dead at once and never handled; the
|
|
913
|
+
// lifted columns are not part of an executor's event, so those two deliver as before.
|
|
914
|
+
const report = await host.drainDue(t1, s);
|
|
915
|
+
expect(report).toMatchObject({ attempted: 5, delivered: 3, deadLettered: 2 });
|
|
916
|
+
expect(effected).toContain(tag);
|
|
917
|
+
const dead = (await host.admin.deadLetters(staff, t1, s, {})).entries.map((d) => d.eventId).sort();
|
|
918
|
+
expect(dead).toEqual([badId(0), badId(1)]);
|
|
919
|
+
});
|
|
920
|
+
it('the Tier-2 read is bounded: past limit × 10 bad rows in a row, a pass ships nothing', async () => {
|
|
921
|
+
const { s, dump } = await seeded('connector-vertical', (stub) => stub.invoke('test/emit-event'));
|
|
922
|
+
const rows = rowsOf(dump, '_substrat_outbox');
|
|
923
|
+
const event = rows.find((r) => r.type === 'test.happened');
|
|
924
|
+
const others = rows.filter((r) => r !== event);
|
|
925
|
+
const bad = (n) => Array.from({ length: n }, (_, i) => ({ ...event, id: badId(i), payload: '{' }));
|
|
926
|
+
// Already-drained, so the only undrained rows are the planted ones and `event`.
|
|
927
|
+
const drainedOthers = others.map((r) => ({ ...r, drained_at: '2026-09-01T00:00:00.000Z' }));
|
|
928
|
+
// One under the bound (limit 1 → ten rows looked at): the healthy row still ships.
|
|
929
|
+
await restore(s, dump, { _substrat_outbox: [...bad(9), event, ...drainedOthers] });
|
|
930
|
+
const under = await host.admin.readUndrainedEvents(staff, t1, s, 1);
|
|
931
|
+
expect(under.map((e) => e.id)).toEqual([event.id]);
|
|
932
|
+
expect(under.skipped?.count).toBe(9);
|
|
933
|
+
// At the bound: nothing ships, this pass or the next — the known limit. A spine
|
|
934
|
+
// this broken is a restore to repair; the count is what makes it visible.
|
|
935
|
+
await restore(s, dump, { _substrat_outbox: [...bad(10), event, ...drainedOthers] });
|
|
936
|
+
for (let pass = 0; pass < 2; pass++) {
|
|
937
|
+
const stalled = await host.admin.readUndrainedEvents(staff, t1, s, 1);
|
|
938
|
+
expect(stalled).toHaveLength(0);
|
|
939
|
+
expect(stalled.skipped?.count).toBe(10);
|
|
940
|
+
}
|
|
941
|
+
// A larger budget reaches past it.
|
|
942
|
+
const wider = await host.admin.readUndrainedEvents(staff, t1, s, 2);
|
|
943
|
+
expect(wider.map((e) => e.id)).toEqual([event.id]);
|
|
944
|
+
});
|
|
945
|
+
});
|
|
946
|
+
});
|
|
379
947
|
it('isolates scope storage: a write in one scope is invisible in another', async () => {
|
|
380
948
|
const stub1 = await host.getScope(alice, t1, s1);
|
|
381
949
|
const stub2 = await host.getScope(alice, t2, s2);
|
|
@@ -885,6 +1453,29 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
885
1453
|
it('rejects an unknown table', async () => {
|
|
886
1454
|
await expect(host.admin.readScopeTable(staff, t1, s1, { table: 'no_such_table', limit: 50, offset: 0 })).rejects.toThrow(/unknown table/);
|
|
887
1455
|
});
|
|
1456
|
+
// #1524: the size behind the on-demand storage reading. The growth assertion is what
|
|
1457
|
+
// separates a real size from a constant. A host that answered a fixed number, or
|
|
1458
|
+
// the size of some other database, passes "positive integer" and fails this.
|
|
1459
|
+
it('answers the scope database size in bytes, and it grows with what is written (#1524)', async () => {
|
|
1460
|
+
const stub = await host.getScope(alice, t1, s1);
|
|
1461
|
+
const before = await host.admin.scopeDatabaseSize(staff, t1, s1);
|
|
1462
|
+
expect(Number.isInteger(before)).toBe(true);
|
|
1463
|
+
expect(before).toBeGreaterThan(0);
|
|
1464
|
+
// ~1 MiB of marker rows: far past any page-rounding or free-page slack.
|
|
1465
|
+
const chunk = 'x'.repeat(64 * 1024);
|
|
1466
|
+
for (let i = 0; i < 16; i += 1)
|
|
1467
|
+
await stub.invoke('test/write-marker', { v: `${i}-${chunk}` });
|
|
1468
|
+
const after = await host.admin.scopeDatabaseSize(staff, t1, s1);
|
|
1469
|
+
expect(after - before).toBeGreaterThanOrEqual(512 * 1024);
|
|
1470
|
+
// A size of ANOTHER scope must not answer for this one.
|
|
1471
|
+
const other = await host.admin.scopeDatabaseSize(staff, t2, s2);
|
|
1472
|
+
expect(other).toBeLessThan(after);
|
|
1473
|
+
const reads = await host.admin.accessLog(staff, { tenantId: t1, method: 'scopeDatabaseSize' });
|
|
1474
|
+
expect(reads.some((r) => r.scopeId === s1)).toBe(true);
|
|
1475
|
+
});
|
|
1476
|
+
it('refuses a database size for a mismatched (tenantId, scopeId) pair (K-3, #1524)', async () => {
|
|
1477
|
+
await expect(host.admin.scopeDatabaseSize(staff, t2, s1)).rejects.toThrow();
|
|
1478
|
+
});
|
|
888
1479
|
it('fails closed on a mismatched (tenantId, scopeId) pair (K-3)', async () => {
|
|
889
1480
|
await expect(host.admin.listScopeTables(staff, t2, s1)).rejects.toThrow();
|
|
890
1481
|
await expect(host.admin.readScopeTable(staff, t2, s1, { table: 'marker', limit: 50, offset: 0 })).rejects.toThrow();
|
|
@@ -1128,6 +1719,217 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
1128
1719
|
await expect(host.admin.shredSubject(staff, t2, s1, subject)).rejects.toThrow();
|
|
1129
1720
|
await expect(host.admin.sealSubjectPayloads(staff, t2, s1, [{ subjectId: subject, plaintext: 'x' }])).rejects.toThrow();
|
|
1130
1721
|
});
|
|
1722
|
+
// -- the spine's OTHER copy of an event (#1600) -------------------------
|
|
1723
|
+
//
|
|
1724
|
+
// `_substrat_platform_requests` holds whole `DomainEvent`s. A CP-less host cannot
|
|
1725
|
+
// run a connector, so each delivery becomes a `connector:<provider>` intent whose
|
|
1726
|
+
// payload is the entire envelope, payload included — deliberately fat, because the
|
|
1727
|
+
// platform's handler needs what the in-process handler would have been handed. And
|
|
1728
|
+
// nothing ever deletes those rows: `listPlatformRequestHistory` exists precisely so
|
|
1729
|
+
// a settled one stays readable. For a year the redaction reached the outbox and not
|
|
1730
|
+
// this, so a shredded subject's name sat in the live scope database, and in every
|
|
1731
|
+
// export, backup and PITR window taken from it afterwards.
|
|
1732
|
+
//
|
|
1733
|
+
// These drive the intent through `ctx.requestPlatform` with the exact payload the
|
|
1734
|
+
// CP-less router writes (`{ executorId, event }`, `connectorDispatchPayload`), which
|
|
1735
|
+
// is the one shape both adapters can be held to — the routing itself only exists on
|
|
1736
|
+
// the Durable-Object host, and `connector-route.test.ts` asserts that what it really
|
|
1737
|
+
// writes is a payload this redaction selects.
|
|
1738
|
+
describe('the intent journal (#1600)', () => {
|
|
1739
|
+
/** The whole spine envelope, as an intent embeds it. Parsed, so the fixture IS one. */
|
|
1740
|
+
const envelopeFor = (subject, said) => domainEvent.parse({
|
|
1741
|
+
id: eventId.parse(ulid()),
|
|
1742
|
+
type: 'protocol.signatures-requested',
|
|
1743
|
+
schemaVersion: 1,
|
|
1744
|
+
occurredAt: instant.parse(new Date().toISOString()),
|
|
1745
|
+
tenantId: t1,
|
|
1746
|
+
scopeId: s1,
|
|
1747
|
+
actor: { system: '@substrat-run/engine-protocol' },
|
|
1748
|
+
entity: { entityType: 'protocol', entityId: ulid() },
|
|
1749
|
+
piiClass: 'direct',
|
|
1750
|
+
subjectId: subject,
|
|
1751
|
+
payload: { senderParty: { label: said }, parties: [{ label: said }] },
|
|
1752
|
+
});
|
|
1753
|
+
/** Enqueue one routed-connector-shaped intent and hand back its id. */
|
|
1754
|
+
const routeIntent = async (kind, subject, said) => {
|
|
1755
|
+
const scope = await host.getScope(alice, t1, s1);
|
|
1756
|
+
const id = await scope.invoke('platform/request', {
|
|
1757
|
+
kind,
|
|
1758
|
+
payload: { executorId: 'signer', event: envelopeFor(subject, said) },
|
|
1759
|
+
});
|
|
1760
|
+
return id;
|
|
1761
|
+
};
|
|
1762
|
+
const journal = async (kind) => host.listPlatformRequestHistory(t1, s1, { kind });
|
|
1763
|
+
it('redacts the routed copy of the event, and leaves the neighbour intact', async () => {
|
|
1764
|
+
// A distinct kind per test: this scope's journal is shared with every other
|
|
1765
|
+
// suite that enqueues, and `connector:<provider>` IS a family of kinds.
|
|
1766
|
+
const kind = `connector:erasure-${ulid()}`;
|
|
1767
|
+
const erased = dataSubjectId.parse(ulid());
|
|
1768
|
+
const spared = dataSubjectId.parse(ulid());
|
|
1769
|
+
const theirs = await routeIntent(kind, erased, 'Anna Ek');
|
|
1770
|
+
const neighbour = await routeIntent(kind, spared, 'Bo Lund');
|
|
1771
|
+
const before = await journal(kind);
|
|
1772
|
+
expect(JSON.stringify(before.find((r) => r.id === theirs))).toContain('Anna Ek');
|
|
1773
|
+
const receipt = await host.admin.shredSubject(staff, t1, s1, erased);
|
|
1774
|
+
const after = await journal(kind);
|
|
1775
|
+
const mine = after.find((r) => r.id === theirs);
|
|
1776
|
+
// THE property, asserted before the receipt that claims it: no trace of the
|
|
1777
|
+
// name, anywhere in the row.
|
|
1778
|
+
expect(JSON.stringify(mine)).not.toContain('Anna Ek');
|
|
1779
|
+
// And the positive twin, without which a redact-everything bug passes the
|
|
1780
|
+
// line above: the intent belonging to somebody else still says what it said,
|
|
1781
|
+
// and is still waiting to be drained.
|
|
1782
|
+
const other = after.find((r) => r.id === neighbour);
|
|
1783
|
+
expect(JSON.stringify(other)).toContain('Bo Lund');
|
|
1784
|
+
expect(other.status).toBe('pending');
|
|
1785
|
+
expect(receipt.intentsRedacted).toBe(1);
|
|
1786
|
+
// Settled afterwards so this test does not spend one of the scope's 32 pending
|
|
1787
|
+
// slots for the rest of the run.
|
|
1788
|
+
await host.settlePlatformRequest(t1, s1, neighbour, { status: 'done' });
|
|
1789
|
+
});
|
|
1790
|
+
it('keeps the envelope — the row is still there, still listed, still dated', async () => {
|
|
1791
|
+
const kind = `connector:erasure-${ulid()}`;
|
|
1792
|
+
const erased = dataSubjectId.parse(ulid());
|
|
1793
|
+
const id = await routeIntent(kind, erased, 'Anna Ek');
|
|
1794
|
+
const before = (await journal(kind)).find((r) => r.id === id);
|
|
1795
|
+
await host.admin.shredSubject(staff, t1, s1, erased);
|
|
1796
|
+
const after = (await journal(kind)).find((r) => r.id === id);
|
|
1797
|
+
// The retention is intended, so the redaction must not become a delete: what
|
|
1798
|
+
// this row proves — that something was asked of the platform, by whom, and
|
|
1799
|
+
// when — is the audit trail, and it survives the erasure of what was said.
|
|
1800
|
+
expect(after).toBeDefined();
|
|
1801
|
+
expect(after.kind).toBe(kind);
|
|
1802
|
+
expect(after.requestedAt).toBe(before.requestedAt);
|
|
1803
|
+
expect(after.requestedBy).toEqual(before.requestedBy);
|
|
1804
|
+
// Obviously redacted rather than plausible data — a reader must not need the
|
|
1805
|
+
// kind's schema to see that the content is gone.
|
|
1806
|
+
expect(after.payload).toEqual({
|
|
1807
|
+
[REDACTED_INTENT_MARKER]: expect.objectContaining({
|
|
1808
|
+
reason: 'subject-erasure',
|
|
1809
|
+
subjectId: erased,
|
|
1810
|
+
}),
|
|
1811
|
+
});
|
|
1812
|
+
});
|
|
1813
|
+
it('never leaves a redacted intent drainable — a pending one settles failed', async () => {
|
|
1814
|
+
const kind = `connector:erasure-${ulid()}`;
|
|
1815
|
+
const erased = dataSubjectId.parse(ulid());
|
|
1816
|
+
const id = await routeIntent(kind, erased, 'Anna Ek');
|
|
1817
|
+
expect((await host.listPlatformRequests(t1, s1)).some((r) => r.id === id)).toBe(true);
|
|
1818
|
+
await host.admin.shredSubject(staff, t1, s1, erased);
|
|
1819
|
+
// Redacting and leaving it pending would hand the drain a tombstone to execute;
|
|
1820
|
+
// leaving it pending and intact would keep the name. `failed` is the truthful
|
|
1821
|
+
// terminal state — the delivery did not happen and now cannot.
|
|
1822
|
+
const settled = (await journal(kind)).find((r) => r.id === id);
|
|
1823
|
+
expect(settled.status).toBe('failed');
|
|
1824
|
+
expect(settled.settledAt).not.toBeNull();
|
|
1825
|
+
expect(settled.lastError).toContain('subject erasure');
|
|
1826
|
+
const stillPending = await host.listPlatformRequests(t1, s1);
|
|
1827
|
+
expect(stillPending.some((r) => r.id === id)).toBe(false);
|
|
1828
|
+
});
|
|
1829
|
+
it('redacts what the provider said back, not only what we sent', async () => {
|
|
1830
|
+
const kind = `connector:erasure-${ulid()}`;
|
|
1831
|
+
const erased = dataSubjectId.parse(ulid());
|
|
1832
|
+
const id = await routeIntent(kind, erased, 'Anna Ek');
|
|
1833
|
+
// #618 made a provider's own sentence readable on the journal, which is exactly
|
|
1834
|
+
// why it can quote the person back at us. An erasure that emptied `payload` and
|
|
1835
|
+
// left a name two columns over is the same defect, one column to the right.
|
|
1836
|
+
await host.settlePlatformRequest(t1, s1, id, {
|
|
1837
|
+
status: 'failed',
|
|
1838
|
+
lastError: 'HTTP 409: Anna Ek requires a valid personal number field',
|
|
1839
|
+
});
|
|
1840
|
+
await host.admin.shredSubject(staff, t1, s1, erased);
|
|
1841
|
+
const after = (await journal(kind)).find((r) => r.id === id);
|
|
1842
|
+
expect(after.lastError).not.toContain('Anna Ek');
|
|
1843
|
+
// Already settled, so the redaction does not re-date it or reopen it.
|
|
1844
|
+
expect(after.status).toBe('failed');
|
|
1845
|
+
});
|
|
1846
|
+
it('keeps `result`, which is envelope — what the intent DID, not what it said', async () => {
|
|
1847
|
+
// The redaction spares `result` on purpose, and until now nothing held it to
|
|
1848
|
+
// that: for a routed dispatch it is `{ eventId }`, which names the event the
|
|
1849
|
+
// outbox already keeps a redacted row for, so it is the same class of fact as
|
|
1850
|
+
// `kind` and `requestedAt`. Asserted beside the two columns that DO go, so the
|
|
1851
|
+
// line between them is a test rather than a sentence in the PR.
|
|
1852
|
+
const kind = `connector:erasure-${ulid()}`;
|
|
1853
|
+
const erased = dataSubjectId.parse(ulid());
|
|
1854
|
+
const id = await routeIntent(kind, erased, 'Anna Ek');
|
|
1855
|
+
await host.settlePlatformRequest(t1, s1, id, {
|
|
1856
|
+
status: 'failed',
|
|
1857
|
+
result: { eventId: 'evt-01', deliveredAt: '2026-09-20T00:00:00.000Z' },
|
|
1858
|
+
lastError: 'HTTP 409: Anna Ek requires a valid personal number field',
|
|
1859
|
+
});
|
|
1860
|
+
await host.admin.shredSubject(staff, t1, s1, erased);
|
|
1861
|
+
const after = (await journal(kind)).find((r) => r.id === id);
|
|
1862
|
+
// What was said goes — both columns that could carry it.
|
|
1863
|
+
expect(JSON.stringify(after.payload)).not.toContain('Anna Ek');
|
|
1864
|
+
expect(after.lastError).not.toContain('Anna Ek');
|
|
1865
|
+
expect(after.lastError).toContain('subject erasure');
|
|
1866
|
+
// What happened stays, unchanged and whole.
|
|
1867
|
+
expect(after.result).toEqual({
|
|
1868
|
+
eventId: 'evt-01',
|
|
1869
|
+
deliveredAt: '2026-09-20T00:00:00.000Z',
|
|
1870
|
+
});
|
|
1871
|
+
});
|
|
1872
|
+
it('refuses a stale drain settling a row the erasure already redacted', async () => {
|
|
1873
|
+
// The drain reads pending rows, runs the handler, then settles — so a settle can
|
|
1874
|
+
// arrive after the erasure landed, carrying a provider's reply that quotes the
|
|
1875
|
+
// person. Settling by id alone wrote that name back into `last_error` on a row
|
|
1876
|
+
// whose payload had just been emptied. The settle is a compare-and-set on
|
|
1877
|
+
// `pending`, so the stale pass is ignored rather than undoing the erasure.
|
|
1878
|
+
//
|
|
1879
|
+
// What this does NOT claim to stop is the DELIVERY: a handler that already read
|
|
1880
|
+
// the payload has it. Only the writeback onto the redacted row is refused.
|
|
1881
|
+
const kind = `connector:erasure-${ulid()}`;
|
|
1882
|
+
const erased = dataSubjectId.parse(ulid());
|
|
1883
|
+
const id = await routeIntent(kind, erased, 'Anna Ek');
|
|
1884
|
+
await host.admin.shredSubject(staff, t1, s1, erased);
|
|
1885
|
+
await host.settlePlatformRequest(t1, s1, id, {
|
|
1886
|
+
status: 'done',
|
|
1887
|
+
result: { eventId: 'delivered-before-the-shred' },
|
|
1888
|
+
lastError: 'HTTP 409: Anna Ek requires a valid personal number field',
|
|
1889
|
+
});
|
|
1890
|
+
const after = (await journal(kind)).find((r) => r.id === id);
|
|
1891
|
+
expect(JSON.stringify(after)).not.toContain('Anna Ek');
|
|
1892
|
+
// The erasure's own settlement stands; the stale pass changed nothing at all.
|
|
1893
|
+
expect(after.status).toBe('failed');
|
|
1894
|
+
expect(after.result).toBeNull();
|
|
1895
|
+
expect(after.attempts).toBe(0);
|
|
1896
|
+
});
|
|
1897
|
+
it('spares an intent whose embedded event is classified `none`', async () => {
|
|
1898
|
+
// The outbox spares such an event even when it names the subject, so a COPY of
|
|
1899
|
+
// it judged more harshly than its original would be incoherent, not stricter.
|
|
1900
|
+
const kind = `connector:erasure-${ulid()}`;
|
|
1901
|
+
const erased = dataSubjectId.parse(ulid());
|
|
1902
|
+
const scope = await host.getScope(alice, t1, s1);
|
|
1903
|
+
const id = (await scope.invoke('platform/request', {
|
|
1904
|
+
kind,
|
|
1905
|
+
payload: {
|
|
1906
|
+
executorId: 'signer',
|
|
1907
|
+
event: {
|
|
1908
|
+
...envelopeFor(erased, 'Anna Ek'),
|
|
1909
|
+
piiClass: 'none',
|
|
1910
|
+
payload: { reference: 'nothing about anybody' },
|
|
1911
|
+
},
|
|
1912
|
+
},
|
|
1913
|
+
}));
|
|
1914
|
+
const receipt = await host.admin.shredSubject(staff, t1, s1, erased);
|
|
1915
|
+
expect(receipt.intentsRedacted).toBe(0);
|
|
1916
|
+
const after = (await journal(kind)).find((r) => r.id === id);
|
|
1917
|
+
expect(JSON.stringify(after.payload)).toContain('nothing about anybody');
|
|
1918
|
+
await host.settlePlatformRequest(t1, s1, id, { status: 'done' });
|
|
1919
|
+
});
|
|
1920
|
+
it('is idempotent — a second shred finds the tombstone and changes nothing', async () => {
|
|
1921
|
+
const kind = `connector:erasure-${ulid()}`;
|
|
1922
|
+
const erased = dataSubjectId.parse(ulid());
|
|
1923
|
+
const id = await routeIntent(kind, erased, 'Anna Ek');
|
|
1924
|
+
expect((await host.admin.shredSubject(staff, t1, s1, erased)).intentsRedacted).toBe(1);
|
|
1925
|
+
const once = (await journal(kind)).find((r) => r.id === id);
|
|
1926
|
+
// The tombstone names the subject, so it survives the candidate read and is
|
|
1927
|
+
// declined by the predicate — which is the property, not a lucky shape.
|
|
1928
|
+
expect((await host.admin.shredSubject(staff, t1, s1, erased)).intentsRedacted).toBe(0);
|
|
1929
|
+
const twice = (await journal(kind)).find((r) => r.id === id);
|
|
1930
|
+
expect(twice).toEqual(once);
|
|
1931
|
+
});
|
|
1932
|
+
});
|
|
1131
1933
|
it('records the erasure in BOTH logs — mutation and evidence-destruction', async () => {
|
|
1132
1934
|
const subject = dataSubjectId.parse(ulid());
|
|
1133
1935
|
await host.admin.shredSubject(staff, t1, s1, subject);
|
|
@@ -1460,6 +2262,103 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
1460
2262
|
const tables = await host.admin.listScopeTables(staff, t1, spineScope);
|
|
1461
2263
|
expect(tables.some((t) => t.name === '_substrat_migrations')).toBe(true);
|
|
1462
2264
|
});
|
|
2265
|
+
it('rebuilds a module table the dump did not carry — on a host that has ALREADY migrated (#1589)', async () => {
|
|
2266
|
+
// A restore replays only what the dump carries and then re-asserts the spine,
|
|
2267
|
+
// so a MODULE's own tables are dropped and not rebuilt by the restore itself.
|
|
2268
|
+
// Both adapters lean on the next migration pass to recreate them, and this is
|
|
2269
|
+
// the assertion that holds them to the same answer.
|
|
2270
|
+
//
|
|
2271
|
+
// ALREADY MIGRATED is the whole point, not scene-setting. The pure host reads
|
|
2272
|
+
// its applied-migration set on every pass, so it re-applies by construction —
|
|
2273
|
+
// but the Cloudflare ScopeDO memoises the pass itself in `migrationPromise`,
|
|
2274
|
+
// and a WARM instance kept answering "migrations are done" over a scope whose
|
|
2275
|
+
// tables the dump had just dropped. The next operation failed with a bare
|
|
2276
|
+
// `no such table`, and kept failing until an eviction or `migrateScope` — so
|
|
2277
|
+
// the restore reported success and the scope was broken (#1589). A cold host
|
|
2278
|
+
// re-derives everything and would pass this vacuously; the operation below is
|
|
2279
|
+
// what makes the host warm before the dump lands.
|
|
2280
|
+
const memoScope = scopeId.parse(ulid());
|
|
2281
|
+
await host.provisionScope(staff, {
|
|
2282
|
+
tenantId: t1,
|
|
2283
|
+
scopeId: memoScope,
|
|
2284
|
+
jurisdiction: 'eu',
|
|
2285
|
+
vertical: 'connector-vertical',
|
|
2286
|
+
});
|
|
2287
|
+
await host.admin.activateScope(staff, t1, memoScope);
|
|
2288
|
+
const warm = await host.getScope(alice, t1, memoScope);
|
|
2289
|
+
await warm.invoke('testmod/add', { id: 'before-restore', box: 'b1' });
|
|
2290
|
+
// The dump of a targeted repair: the spine and every other module's tables,
|
|
2291
|
+
// but neither `@test/mod`'s tables nor the journal rows claiming they exist.
|
|
2292
|
+
// Stripping the journal row is what makes the dump self-consistent — a dump
|
|
2293
|
+
// that dropped the table and KEPT the row would be asking for a schema no
|
|
2294
|
+
// migration pass is allowed to rebuild.
|
|
2295
|
+
const backup = await host.admin.exportScope(staff, t1, memoScope);
|
|
2296
|
+
const journal = backup.tables.find((t) => t.name === '_substrat_migrations');
|
|
2297
|
+
const moduleCol = journal.columns.indexOf('module_id');
|
|
2298
|
+
expect(backup.tables.some((t) => t.name === 'testmod_items')).toBe(true);
|
|
2299
|
+
expect(journal.rows.some((r) => r[moduleCol] === '@test/mod')).toBe(true);
|
|
2300
|
+
const stripped = {
|
|
2301
|
+
...backup,
|
|
2302
|
+
tables: backup.tables
|
|
2303
|
+
// BOTH of the module's tables: its one migration creates the pair in a
|
|
2304
|
+
// single statement list, so leaving `testmod_notes` behind would stop the
|
|
2305
|
+
// re-apply on "table testmod_notes already exists" — a different failure
|
|
2306
|
+
// wearing this one's clothes.
|
|
2307
|
+
.filter((t) => t.name !== 'testmod_items' && t.name !== 'testmod_notes')
|
|
2308
|
+
.map((t) => t.name === '_substrat_migrations'
|
|
2309
|
+
? { ...t, rows: t.rows.filter((r) => r[moduleCol] !== '@test/mod') }
|
|
2310
|
+
: t),
|
|
2311
|
+
};
|
|
2312
|
+
await host.restoreScope(staff, t1, memoScope, stripped);
|
|
2313
|
+
// Both tables are back, and the module writes on them — this is the line that
|
|
2314
|
+
// threw `no such table: testmod_items` on the hosted adapter.
|
|
2315
|
+
const after = await host.getScope(alice, t1, memoScope);
|
|
2316
|
+
await after.invoke('testmod/add', { id: 'after-restore', box: 'b1' });
|
|
2317
|
+
expect(await after.invoke('testmod/read-items')).toEqual([
|
|
2318
|
+
{ id: 'after-restore' },
|
|
2319
|
+
]);
|
|
2320
|
+
expect(await after.invoke('testmod/read-notes')).toEqual([]);
|
|
2321
|
+
// The journal records the re-applied version exactly once, so the pass after
|
|
2322
|
+
// this one has nothing to do.
|
|
2323
|
+
const frontier = await after.invoke('testmod/read-journal');
|
|
2324
|
+
expect(frontier.filter((r) => r.module_id === '@test/mod')).toEqual([
|
|
2325
|
+
{ module_id: '@test/mod', version: '0001-init' },
|
|
2326
|
+
]);
|
|
2327
|
+
});
|
|
2328
|
+
it('leaves a module table the dump DID carry exactly as the dump left it (#1589)', async () => {
|
|
2329
|
+
// The twin of the test above, and what stops "rebuild what the dump dropped"
|
|
2330
|
+
// from becoming "rebuild on every restore". A dump carrying the table and its
|
|
2331
|
+
// journal row must come back as the dump left it: re-running the migration
|
|
2332
|
+
// here would stop on "table testmod_items already exists" and fail the scope
|
|
2333
|
+
// closed — the same outage, reached from the other side — and a restore that
|
|
2334
|
+
// rebuilt the table empty would lose the rows it exists to bring back.
|
|
2335
|
+
const keptScope = scopeId.parse(ulid());
|
|
2336
|
+
await host.provisionScope(staff, {
|
|
2337
|
+
tenantId: t1,
|
|
2338
|
+
scopeId: keptScope,
|
|
2339
|
+
jurisdiction: 'eu',
|
|
2340
|
+
vertical: 'connector-vertical',
|
|
2341
|
+
});
|
|
2342
|
+
await host.admin.activateScope(staff, t1, keptScope);
|
|
2343
|
+
const warm = await host.getScope(alice, t1, keptScope);
|
|
2344
|
+
await warm.invoke('testmod/add', { id: 'kept', box: 'b1' });
|
|
2345
|
+
const backup = await host.admin.exportScope(staff, t1, keptScope);
|
|
2346
|
+
expect(backup.tables.some((t) => t.name === 'testmod_items')).toBe(true);
|
|
2347
|
+
await warm.invoke('testmod/add', { id: 'zz-diverged', box: 'b1' });
|
|
2348
|
+
await host.restoreScope(staff, t1, keptScope, backup);
|
|
2349
|
+
// The dump's row, and only it — the divergence is gone and nothing was wiped.
|
|
2350
|
+
const after = await host.getScope(alice, t1, keptScope);
|
|
2351
|
+
expect(await after.invoke('testmod/read-items')).toEqual([
|
|
2352
|
+
{ id: 'kept' },
|
|
2353
|
+
]);
|
|
2354
|
+
// …and the scope still writes: a re-applied migration would have failed the
|
|
2355
|
+
// scope closed here rather than accepting this row.
|
|
2356
|
+
await after.invoke('testmod/add', { id: 'post-restore', box: 'b1' });
|
|
2357
|
+
expect(await after.invoke('testmod/read-items')).toEqual([
|
|
2358
|
+
{ id: 'kept' },
|
|
2359
|
+
{ id: 'post-restore' },
|
|
2360
|
+
]);
|
|
2361
|
+
});
|
|
1463
2362
|
it('loads a dump whose child table sorts before its parent — FK order is not alphabetical', async () => {
|
|
1464
2363
|
// A dump is ordered by table NAME. A vertical whose child sorts first (a CRM's
|
|
1465
2364
|
// `crm_bank_accounts` before `crm_vendors`) used to fail its first insert with a
|
|
@@ -1827,6 +2726,11 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
1827
2726
|
// -- the integrations hub: connections (#101) -----------------------------
|
|
1828
2727
|
// -- model usage (#1054): meter 3's ledger, idempotent on the intent id ----------------
|
|
1829
2728
|
describe('model usage (#1054)', () => {
|
|
2729
|
+
// Window edges are derived from the real clock, never literals: the host prunes model
|
|
2730
|
+
// usage older than MODEL_USAGE_RETENTION_DAYS against Date.now(), so a fixed date is a
|
|
2731
|
+
// fixture that expires. `day(n)` is midnight UTC, `n` days after a base 40 days back.
|
|
2732
|
+
const BASE = new Date(Date.now() - 40 * 86_400_000).setUTCHours(0, 0, 0, 0);
|
|
2733
|
+
const day = (n, hour = 0) => new Date(BASE + n * 86_400_000 + hour * 3_600_000).toISOString();
|
|
1830
2734
|
const line = (at, over = {}) => ({
|
|
1831
2735
|
attribution: {
|
|
1832
2736
|
tenant: t1,
|
|
@@ -1851,23 +2755,23 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
1851
2755
|
it('records a line once per intent id, lists it, and folds the window exactly', async () => {
|
|
1852
2756
|
const first = await host.admin.recordModelUsage({
|
|
1853
2757
|
requestId: 'req-model-usage-1',
|
|
1854
|
-
line: line(
|
|
2758
|
+
line: line(day(0, 10), { listUsd: '0.75' }),
|
|
1855
2759
|
});
|
|
1856
2760
|
expect(first.recorded).toBe(true);
|
|
1857
2761
|
// The drain replays a settled-then-retried intent: nothing is billed twice.
|
|
1858
2762
|
const again = await host.admin.recordModelUsage({
|
|
1859
2763
|
requestId: 'req-model-usage-1',
|
|
1860
|
-
line: line(
|
|
2764
|
+
line: line(day(0, 10), { listUsd: '0.75' }),
|
|
1861
2765
|
});
|
|
1862
2766
|
expect(again.recorded).toBe(false);
|
|
1863
2767
|
await host.admin.recordModelUsage({
|
|
1864
2768
|
requestId: 'req-model-usage-2',
|
|
1865
|
-
line: line(
|
|
2769
|
+
line: line(day(1, 10), { listUsd: '0.5', inputTokens: 50_000, outputTokens: 5_000 }),
|
|
1866
2770
|
});
|
|
1867
2771
|
// An unpriced call: counted, never folded in as $0.
|
|
1868
2772
|
await host.admin.recordModelUsage({
|
|
1869
2773
|
requestId: 'req-model-usage-3',
|
|
1870
|
-
line: line(
|
|
2774
|
+
line: line(day(2, 10), {
|
|
1871
2775
|
listUsd: null,
|
|
1872
2776
|
model: 'cloudflare:@cf/meta/llama-3.1-8b-instruct-fast',
|
|
1873
2777
|
provider: 'cloudflare',
|
|
@@ -1879,13 +2783,13 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
1879
2783
|
// Outside the window below.
|
|
1880
2784
|
await host.admin.recordModelUsage({
|
|
1881
2785
|
requestId: 'req-model-usage-4',
|
|
1882
|
-
line: line(
|
|
2786
|
+
line: line(day(30), { listUsd: '9' }),
|
|
1883
2787
|
});
|
|
1884
|
-
const listed = await host.admin.listModelUsage(staff, { tenantId: t1, since:
|
|
2788
|
+
const listed = await host.admin.listModelUsage(staff, { tenantId: t1, since: day(0) });
|
|
1885
2789
|
expect(listed.length).toBe(4);
|
|
1886
2790
|
expect(listed[0].requestId).toBe('req-model-usage-4'); // newest first
|
|
1887
2791
|
expect(listed.every((e) => e.attribution.tenant === t1)).toBe(true);
|
|
1888
|
-
const summary = await host.admin.summarizeModelUsage(staff, { tenantId: t1, since:
|
|
2792
|
+
const summary = await host.admin.summarizeModelUsage(staff, { tenantId: t1, since: day(0), until: day(30) }, 20);
|
|
1889
2793
|
expect(summary.marginPercent).toBe(20);
|
|
1890
2794
|
expect(summary.rows.length).toBe(2);
|
|
1891
2795
|
const opus = summary.rows.find((r) => r.model === 'anthropic:claude-opus-5');
|
|
@@ -2152,14 +3056,18 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
2152
3056
|
expect(row.error).toBeNull();
|
|
2153
3057
|
expect(row.elapsedMs).toBeNull();
|
|
2154
3058
|
});
|
|
2155
|
-
it('dedupes on (requestId, unit) — a replayed drained batch writes nothing twice (#1232)', async () => {
|
|
3059
|
+
it('dedupes on (requestId, kind, unit) — a replayed drained batch writes nothing twice (#1232)', async () => {
|
|
2156
3060
|
const unit = `${ulid()}:sched/tick`;
|
|
3061
|
+
// Relative to the real clock, never a literal: the host prunes a sweep run older
|
|
3062
|
+
// than SWEEP_RUN_RETENTION_DAYS against Date.now(), so a fixed date is a fixture
|
|
3063
|
+
// that expires and takes the suite with it.
|
|
3064
|
+
const passAt = new Date(Date.now() - 3_600_000).toISOString();
|
|
2157
3065
|
const write = () => host.admin.recordSweepRun({
|
|
2158
3066
|
kind: 'schedule',
|
|
2159
3067
|
unit,
|
|
2160
3068
|
outcome: 'ok',
|
|
2161
3069
|
tenantId: t1,
|
|
2162
|
-
at:
|
|
3070
|
+
at: passAt,
|
|
2163
3071
|
requestId: '01JDEDUPEINTENTAAAAAAAAAAA',
|
|
2164
3072
|
});
|
|
2165
3073
|
await write();
|
|
@@ -2167,7 +3075,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
2167
3075
|
const rows = await host.admin.listSweepRuns(staff, { unit });
|
|
2168
3076
|
expect(rows).toHaveLength(1);
|
|
2169
3077
|
// The carried pass time survives, never overwritten by write time.
|
|
2170
|
-
expect(rows[0].at).toBe(
|
|
3078
|
+
expect(rows[0].at).toBe(passAt);
|
|
2171
3079
|
// The DIRECT path (no requestId) never dedupes: NULLs are distinct, and two
|
|
2172
3080
|
// real passes over one unit are two facts.
|
|
2173
3081
|
const direct = `${ulid()}:direct`;
|
|
@@ -2175,6 +3083,28 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
2175
3083
|
await host.admin.recordSweepRun({ kind: 'schedule', unit: direct, outcome: 'ok' });
|
|
2176
3084
|
expect(await host.admin.listSweepRuns(staff, { unit: direct })).toHaveLength(2);
|
|
2177
3085
|
});
|
|
3086
|
+
it('keeps a schedule row and a freshness row of one batch that derive the same unit (#1572)', async () => {
|
|
3087
|
+
// The drain derives a schedule's unit from its operation and a freshness unit
|
|
3088
|
+
// from its event type, and nothing keeps those apart: `orders.placed` is an
|
|
3089
|
+
// ordinary spelling for both. One batch shares one requestId, so under the old
|
|
3090
|
+
// (requestId, unit) key the second write was IGNOREd, silently.
|
|
3091
|
+
const unit = `${ulid()}:orders.placed`;
|
|
3092
|
+
const requestId = '01JKINDINTENTAAAAAAAAAAAAA';
|
|
3093
|
+
const schedule = () => host.admin.recordSweepRun({ kind: 'schedule', unit, outcome: 'skipped', operation: 'orders.placed', requestId });
|
|
3094
|
+
const freshness = () => host.admin.recordSweepRun({ kind: 'freshness', unit, outcome: 'failed', eventType: 'orders.placed', requestId });
|
|
3095
|
+
await schedule();
|
|
3096
|
+
await freshness();
|
|
3097
|
+
const both = await host.admin.listSweepRuns(staff, { unit });
|
|
3098
|
+
expect(both.map((r) => [r.kind, r.outcome]).sort()).toEqual([
|
|
3099
|
+
['freshness', 'failed'],
|
|
3100
|
+
['schedule', 'skipped'],
|
|
3101
|
+
]);
|
|
3102
|
+
// The twin: kind widened the key, it did not drop it. A replay of either entry
|
|
3103
|
+
// is still the same row and still writes nothing.
|
|
3104
|
+
await schedule();
|
|
3105
|
+
await freshness();
|
|
3106
|
+
expect(await host.admin.listSweepRuns(staff, { unit })).toHaveLength(2);
|
|
3107
|
+
});
|
|
2178
3108
|
it('bounds the recorded error — one runaway provider body never becomes a runaway row', async () => {
|
|
2179
3109
|
const unit = ulid();
|
|
2180
3110
|
await host.admin.recordSweepRun({
|
|
@@ -4223,6 +5153,9 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
4223
5153
|
await expect(host.admin.facetEvents(staff, t3, s, { groupBy: { kind: 'type' } })).rejects.toThrow(/reaped/);
|
|
4224
5154
|
await expect(host.admin.entityHistory(staff, t3, s, { entityType: 'test-thing', entityId: 'x1' })).rejects.toThrow(/reaped/);
|
|
4225
5155
|
await expect(host.admin.listScopeTables(staff, t3, s)).rejects.toThrow(/reaped/);
|
|
5156
|
+
// #1524: a size read is refused BEFORE it reaches the storage. Addressing a reaped
|
|
5157
|
+
// DO to ask its size would recreate an empty database and report that as the scope.
|
|
5158
|
+
await expect(host.admin.scopeDatabaseSize(staff, t3, s)).rejects.toThrow(/reaped/);
|
|
4226
5159
|
// Audited as reapScope against the right scope + actor.
|
|
4227
5160
|
const reapEntry = (await host.admin.auditLog(staff, { tenantId: t3 })).find((r) => r.action === 'reapScope' && r.scopeId === s);
|
|
4228
5161
|
expect(reapEntry?.actor).toBe(staff);
|