@substrat-run/contract-tests 0.108.0 → 0.110.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/facet-recency-suite.d.ts +14 -0
- package/dist/facet-recency-suite.d.ts.map +1 -0
- package/dist/facet-recency-suite.js +107 -0
- package/dist/facet-recency-suite.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/modules.d.ts.map +1 -1
- package/dist/modules.js +26 -0
- package/dist/modules.js.map +1 -1
- package/dist/scope-host-suite.d.ts.map +1 -1
- package/dist/scope-host-suite.js +217 -0
- package/dist/scope-host-suite.js.map +1 -1
- package/package.json +3 -3
package/dist/scope-host-suite.js
CHANGED
|
@@ -362,6 +362,165 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
362
362
|
const journal2 = await again.invoke('testmod/read-journal');
|
|
363
363
|
expect(journal2.filter((r) => r.module_id === '@test/mod')).toHaveLength(1);
|
|
364
364
|
});
|
|
365
|
+
it('drains events at-least-once, and a re-mark cannot rewrite when they shipped (#1334)', async () => {
|
|
366
|
+
// Tier 2 (master-plan §5.3) is fed exclusively by this stream, and
|
|
367
|
+
// `drained_at` has been on the outbox since it shipped with nothing writing
|
|
368
|
+
// it. These two verbs are what a drain is built from.
|
|
369
|
+
//
|
|
370
|
+
// Its OWN scope, not the shared `s1`. A drain read is "everything undrained,
|
|
371
|
+
// up to a limit", so every event any earlier test left behind is part of this
|
|
372
|
+
// test's input — and the closing assertion, that exactly one event is
|
|
373
|
+
// undrained after a new emit, is only true while that backlog stays under the
|
|
374
|
+
// page size. Nothing about this test's subject needs the shared fixture, and
|
|
375
|
+
// depending on how full it is makes the failure arrive later, in someone
|
|
376
|
+
// else's PR.
|
|
377
|
+
const sDrain = scopeId.parse(ulid());
|
|
378
|
+
await host.provisionScope(staff, {
|
|
379
|
+
tenantId: t1,
|
|
380
|
+
scopeId: sDrain,
|
|
381
|
+
jurisdiction: 'eu',
|
|
382
|
+
vertical: 'connector-vertical',
|
|
383
|
+
});
|
|
384
|
+
await host.admin.activateScope(staff, t1, sDrain);
|
|
385
|
+
const stub = await host.getScope(alice, t1, sDrain);
|
|
386
|
+
const subject = ulid();
|
|
387
|
+
await stub.invoke('test/emit-event');
|
|
388
|
+
await stub.invoke('test/emit-event', { subject });
|
|
389
|
+
const first = await host.admin.readUndrainedEvents(staff, t1, sDrain, 200);
|
|
390
|
+
expect(first.length).toBeGreaterThanOrEqual(2);
|
|
391
|
+
// Oldest first and stable — a drain resumes where it stopped rather than
|
|
392
|
+
// re-reading from the top.
|
|
393
|
+
expect([...first].sort((a, b) => (a.id < b.id ? -1 : 1)).map((e) => e.id)).toEqual(first.map((e) => e.id));
|
|
394
|
+
// The dimensions the lake exists to carry ride along: the emitting operation
|
|
395
|
+
// and the version, which #1250 deliberately keeps OFF the envelope.
|
|
396
|
+
const emitted = first.find((e) => e.type === 'test.happened');
|
|
397
|
+
expect(emitted.operation).toBe('test/emit-event');
|
|
398
|
+
expect('version' in emitted).toBe(true);
|
|
399
|
+
// And the erasure key rides along, so a shred can still reach the copy that
|
|
400
|
+
// left the scope. Absent on an event with no data subject — the key is a
|
|
401
|
+
// fact about the event, not a field the drain invents.
|
|
402
|
+
expect(first.find((e) => e.subjectId === subject)).toBeDefined();
|
|
403
|
+
// Reading does NOT mark: a sink that fails must leave the events replayable,
|
|
404
|
+
// which is the whole reason read and mark are separate verbs.
|
|
405
|
+
const again = await host.admin.readUndrainedEvents(staff, t1, sDrain, 200);
|
|
406
|
+
expect(again.map((e) => e.id)).toEqual(first.map((e) => e.id));
|
|
407
|
+
const marked = await host.admin.markEventsDrained(staff, t1, sDrain, first.map((e) => e.id));
|
|
408
|
+
expect(marked).toBe(first.length);
|
|
409
|
+
const afterMark = await host.admin.readUndrainedEvents(staff, t1, sDrain, 200);
|
|
410
|
+
expect(afterMark.map((e) => e.id)).not.toEqual(expect.arrayContaining(first.map((e) => e.id)));
|
|
411
|
+
// K-24's rule one tier down: domain payloads leaving the platform are an
|
|
412
|
+
// EGRESS, so the admin log carries a receipt naming who declared it and how
|
|
413
|
+
// many rows it covered. Without one there is no durable record of who shipped
|
|
414
|
+
// a customer's events off the platform.
|
|
415
|
+
const receipt = (await host.admin.auditLog(staff, { tenantId: t1 })).find((r) => r.action === 'drainEvents' && r.scopeId === sDrain);
|
|
416
|
+
expect(receipt?.actor).toBe(staff);
|
|
417
|
+
expect((receipt?.after).drained).toBe(first.length);
|
|
418
|
+
// Marking is idempotent, and a replayed batch must not move an earlier
|
|
419
|
+
// drain's timestamp forward — a lake row's "when did this ship" would
|
|
420
|
+
// otherwise drift every time a retry passed over it.
|
|
421
|
+
await expect(host.admin.markEventsDrained(staff, t1, sDrain, first.map((e) => e.id))).resolves.toBe(0);
|
|
422
|
+
// …and the re-mark writes NO second receipt. An admin log that grows a row
|
|
423
|
+
// per retry claims egresses that never happened, which is worse than silence:
|
|
424
|
+
// the log is the evidence, and inflated evidence is not evidence.
|
|
425
|
+
const receipts = (await host.admin.auditLog(staff, { tenantId: t1 })).filter((r) => r.action === 'drainEvents' && r.scopeId === sDrain);
|
|
426
|
+
expect(receipts).toHaveLength(1);
|
|
427
|
+
// A new event after the drain is picked up; the drained ones are not.
|
|
428
|
+
await stub.invoke('test/emit-event');
|
|
429
|
+
const next = await host.admin.readUndrainedEvents(staff, t1, sDrain, 200);
|
|
430
|
+
expect(next.length).toBe(1);
|
|
431
|
+
expect(first.map((e) => e.id)).not.toContain(next[0].id);
|
|
432
|
+
// An empty mark is a no-op, not an error — a drain with nothing to ship.
|
|
433
|
+
await expect(host.admin.markEventsDrained(staff, t1, sDrain, [])).resolves.toBe(0);
|
|
434
|
+
// But "nothing to mark" and "you may not address this scope" stay different
|
|
435
|
+
// answers. The empty batch must not become a shortcut past K-3: a mismatched
|
|
436
|
+
// pair that quietly returns 0 makes a caller's typo read as a successful drain.
|
|
437
|
+
await expect(host.admin.markEventsDrained(staff, t2, sDrain, [])).rejects.toThrow(/unknown scope/);
|
|
438
|
+
await expect(host.admin.markEventsDrained(staff, t1, scopeId.parse(ulid()), [])).rejects.toThrow(/unknown scope/);
|
|
439
|
+
});
|
|
440
|
+
it('facets the outbox, and keeps an ERASED payload out of the null bucket (#1239)', async () => {
|
|
441
|
+
const stub = await host.getScope(alice, t1, s1);
|
|
442
|
+
const subject = ulid();
|
|
443
|
+
// Three events: two whose payload carries `secret`, one that never had it.
|
|
444
|
+
await stub.invoke('test/emit-event', { subject, secret: 'kept' });
|
|
445
|
+
await stub.invoke('test/emit-event', { subject: ulid(), secret: 'kept' });
|
|
446
|
+
await stub.invoke('test/emit-event');
|
|
447
|
+
const byType = await host.admin.facetEvents(staff, t1, s1, { groupBy: { kind: 'type' } });
|
|
448
|
+
const happened = byType.buckets.find((b) => b.value === 'test.happened');
|
|
449
|
+
expect(happened.count).toBeGreaterThanOrEqual(3);
|
|
450
|
+
// An envelope grouping cannot be erased, so the count is structurally zero.
|
|
451
|
+
expect(byType.erased).toBe(0);
|
|
452
|
+
const before = await host.admin.facetEvents(staff, t1, s1, {
|
|
453
|
+
groupBy: { kind: 'payload', field: 'secret' },
|
|
454
|
+
type: 'test.happened',
|
|
455
|
+
});
|
|
456
|
+
expect(before.erased).toBe(0);
|
|
457
|
+
expect(before.buckets.find((b) => b.value === 'kept').count).toBe(2);
|
|
458
|
+
// The event that never carried the field is a genuine null bucket — an
|
|
459
|
+
// absence in the data, which is a different fact from an erasure.
|
|
460
|
+
expect(before.buckets.find((b) => b.value === null).count).toBeGreaterThanOrEqual(1);
|
|
461
|
+
// Now erase one subject: the shred keeps the row and drops the payload.
|
|
462
|
+
await host.admin.shredSubject(staff, t1, s1, dataSubjectId.parse(subject));
|
|
463
|
+
const after = await host.admin.facetEvents(staff, t1, s1, {
|
|
464
|
+
groupBy: { kind: 'payload', field: 'secret' },
|
|
465
|
+
type: 'test.happened',
|
|
466
|
+
});
|
|
467
|
+
// THE RULE: the erased event is counted apart, and did NOT join the null
|
|
468
|
+
// bucket. Folding it in would show a reader a clean distribution with no
|
|
469
|
+
// hint that part of the history was redacted.
|
|
470
|
+
expect(after.erased).toBe(1);
|
|
471
|
+
expect(after.buckets.find((b) => b.value === 'kept').count).toBe(1);
|
|
472
|
+
expect(after.buckets.find((b) => b.value === null)?.count ?? 0).toBe(before.buckets.find((b) => b.value === null).count);
|
|
473
|
+
// And the denominator still counts it: the event happened, whatever it said.
|
|
474
|
+
expect(after.total).toBe(before.total);
|
|
475
|
+
});
|
|
476
|
+
it('carries WHEN each bucket last saw an event, not only how many (#1234)', async () => {
|
|
477
|
+
const stub = await host.getScope(alice, t1, s1);
|
|
478
|
+
await stub.invoke('test/emit-event');
|
|
479
|
+
await stub.invoke('test/emit-event');
|
|
480
|
+
const byType = await host.admin.facetEvents(staff, t1, s1, { groupBy: { kind: 'type' } });
|
|
481
|
+
const happened = byType.buckets.find((b) => b.value === 'test.happened');
|
|
482
|
+
// Recency is the question a count cannot answer, and the one the flow map asks:
|
|
483
|
+
// a consumer with a big number that stopped months ago is the failure volume hides.
|
|
484
|
+
expect(happened.lastSeen).toEqual(expect.any(String));
|
|
485
|
+
// And it is genuinely the MAXIMUM, not just some row's timestamp. `until` is
|
|
486
|
+
// exclusive, so faceting up to `lastSeen` must drop every event that carries it —
|
|
487
|
+
// which is a property of the query rather than a hope about the clock, and needs
|
|
488
|
+
// no control over time to assert.
|
|
489
|
+
const upTo = await host.admin.facetEvents(staff, t1, s1, {
|
|
490
|
+
groupBy: { kind: 'type' },
|
|
491
|
+
until: happened.lastSeen,
|
|
492
|
+
});
|
|
493
|
+
const earlier = upTo.buckets.find((b) => b.value === 'test.happened');
|
|
494
|
+
expect(earlier?.count ?? 0).toBeLessThan(happened.count);
|
|
495
|
+
// Whatever survives is strictly older, so its own recency moved back with it.
|
|
496
|
+
if (earlier?.lastSeen)
|
|
497
|
+
expect(earlier.lastSeen < happened.lastSeen).toBe(true);
|
|
498
|
+
});
|
|
499
|
+
it('renders one bucket per rendered value, and does not invent an erasure (#1239)', async () => {
|
|
500
|
+
const stub = await host.getScope(alice, t1, s1);
|
|
501
|
+
// SQLite keeps these in separate storage classes: `json_extract` hands back an
|
|
502
|
+
// INTEGER for the first and TEXT for the second, so grouping the raw result and
|
|
503
|
+
// stringifying afterwards produces two buckets both labelled "1".
|
|
504
|
+
await stub.invoke('test/emit-facet-edge', { code: 1 });
|
|
505
|
+
await stub.invoke('test/emit-facet-edge', { code: '1' });
|
|
506
|
+
// Legal and ordinary: an event with no payload at all. It is stored as the same
|
|
507
|
+
// SQL NULL a shred writes, and nothing about it has been erased.
|
|
508
|
+
await stub.invoke('test/emit-facet-edge', { omit: true });
|
|
509
|
+
const facet = await host.admin.facetEvents(staff, t1, s1, {
|
|
510
|
+
groupBy: { kind: 'payload', field: 'code' },
|
|
511
|
+
type: 'test.facet-edge',
|
|
512
|
+
});
|
|
513
|
+
// ONE bucket for "1", carrying both events — not two that split the count.
|
|
514
|
+
const ones = facet.buckets.filter((b) => b.value === '1');
|
|
515
|
+
expect(ones).toHaveLength(1);
|
|
516
|
+
expect(ones[0].count).toBe(2);
|
|
517
|
+
// The payload-less event is an extraction-null, NOT a redaction. Reporting it
|
|
518
|
+
// as erased would tell a reader that history was withheld from them when it
|
|
519
|
+
// was simply never written.
|
|
520
|
+
expect(facet.erased).toBe(0);
|
|
521
|
+
expect(facet.buckets.find((b) => b.value === null).count).toBe(1);
|
|
522
|
+
expect(facet.total).toBe(3);
|
|
523
|
+
});
|
|
365
524
|
it('answers one record’s history through the platform (#1235)', async () => {
|
|
366
525
|
// `readHistory` has been the sanctioned read since #800 and, until this
|
|
367
526
|
// verb, nothing above the scope could call it — the whole point of #1235.
|
|
@@ -3426,6 +3585,57 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
3426
3585
|
}
|
|
3427
3586
|
});
|
|
3428
3587
|
// -- tenant registry + lifecycle (control-plane.md §4.1) -----------------
|
|
3588
|
+
/**
|
|
3589
|
+
* #1237 — placed at the end of the flow cases deliberately. These provision their
|
|
3590
|
+
* own scopes (the shared one is where the dispatch cases assert exact row counts),
|
|
3591
|
+
* and a scope provisioned earlier adds directory audit rows that push an older
|
|
3592
|
+
* entry out of a bounded `auditLog` window upstream.
|
|
3593
|
+
*/
|
|
3594
|
+
it('records WHY a consumer-emitted event exists, and nothing where there is no cause (#1237)', async () => {
|
|
3595
|
+
// Its own scope: the shared one is where the dispatch tests assert exact row
|
|
3596
|
+
// counts, and an extra `flow/produce` there would move them.
|
|
3597
|
+
const sCause = scopeId.parse(ulid());
|
|
3598
|
+
await host.provisionScope(staff, { tenantId: t1, scopeId: sCause, vertical: 'flow-vertical' });
|
|
3599
|
+
await host.admin.activateScope(staff, t1, sCause);
|
|
3600
|
+
const stub = await host.getScope(alice, t1, sCause);
|
|
3601
|
+
await stub.invoke('flow/produce');
|
|
3602
|
+
const rows = (await stub.invoke('flow/causes'));
|
|
3603
|
+
const step1 = rows.find((r) => r.type === 'flow.step1');
|
|
3604
|
+
const step2 = rows.find((r) => r.type === 'flow.step2');
|
|
3605
|
+
// The operation's own event has no cause. Null here is the ordinary case — an
|
|
3606
|
+
// operation emitted it directly — not a gap in the recording.
|
|
3607
|
+
expect(step1.operation).toBe('flow/produce');
|
|
3608
|
+
expect(step1.caused_by).toBeNull();
|
|
3609
|
+
// THE EDGE. A consumer emit records no operation, so before this column the
|
|
3610
|
+
// trail from step2 back to step1 was simply not written down and a backwards
|
|
3611
|
+
// walk stopped at the first consumer hop.
|
|
3612
|
+
expect(step2.operation).toBeNull();
|
|
3613
|
+
expect(step2.caused_by).toBe(step1.id);
|
|
3614
|
+
// And the pair is what identifies a consumer emit at all: `operation` alone is
|
|
3615
|
+
// null both for a consumer and for a row written before that column existed.
|
|
3616
|
+
expect(step2.operation === null && step2.caused_by !== null).toBe(true);
|
|
3617
|
+
});
|
|
3618
|
+
it('does not leak a delivered event\'s id onto a later unrelated emit (#1237)', async () => {
|
|
3619
|
+
// The failure mode of a field held on the host across a dispatch: left set, the
|
|
3620
|
+
// next operation's own event is stamped as caused by whatever was delivered
|
|
3621
|
+
// last — a recorded fact that is false, which is worse than recording nothing.
|
|
3622
|
+
const sLeak = scopeId.parse(ulid());
|
|
3623
|
+
await host.provisionScope(staff, { tenantId: t1, scopeId: sLeak, vertical: 'flow-vertical' });
|
|
3624
|
+
await host.admin.activateScope(staff, t1, sLeak);
|
|
3625
|
+
const stub = await host.getScope(alice, t1, sLeak);
|
|
3626
|
+
await stub.invoke('flow/produce'); // runs consumers, which set the field
|
|
3627
|
+
await stub.invoke('flow/produce'); // must not inherit it
|
|
3628
|
+
const rows = (await stub.invoke('flow/causes'));
|
|
3629
|
+
const producedDirectly = rows.filter((r) => r.type === 'flow.step1');
|
|
3630
|
+
expect(producedDirectly).toHaveLength(2);
|
|
3631
|
+
for (const row of producedDirectly)
|
|
3632
|
+
expect(row.caused_by).toBeNull();
|
|
3633
|
+
// …and both consumer emits still name their own cause, so clearing the field
|
|
3634
|
+
// did not simply turn the whole feature off.
|
|
3635
|
+
const reactions = rows.filter((r) => r.type === 'flow.step2');
|
|
3636
|
+
expect(reactions).toHaveLength(2);
|
|
3637
|
+
expect(new Set(reactions.map((r) => r.caused_by)).size).toBe(2);
|
|
3638
|
+
});
|
|
3429
3639
|
it('creates a tenant record, idempotently; only real creates are audited', async () => {
|
|
3430
3640
|
await host.admin.createTenant(staff, { id: t3, slug: 'acme-co', name: 'Acme Co' });
|
|
3431
3641
|
await host.admin.createTenant(staff, { id: t3, slug: 'acme-co', name: 'Acme Co' }); // no-op
|
|
@@ -3534,6 +3744,13 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
3534
3744
|
expect(rec.status).toBe('reaped');
|
|
3535
3745
|
// getScope fails closed on the reaped scope, exactly like a missing one.
|
|
3536
3746
|
await expect(host.getScope(alice, t3, s)).rejects.toThrow(/scope not active|unknown scope/);
|
|
3747
|
+
// And so does every read that would OPEN that storage. The tombstone row still
|
|
3748
|
+
// resolves, so an existence check alone lets these through — and opening a
|
|
3749
|
+
// reaped scope does not read an empty database, it CREATES one, answering
|
|
3750
|
+
// "this scope has no events" where the truth is "this scope is gone".
|
|
3751
|
+
await expect(host.admin.facetEvents(staff, t3, s, { groupBy: { kind: 'type' } })).rejects.toThrow(/reaped/);
|
|
3752
|
+
await expect(host.admin.entityHistory(staff, t3, s, { entityType: 'test-thing', entityId: 'x1' })).rejects.toThrow(/reaped/);
|
|
3753
|
+
await expect(host.admin.listScopeTables(staff, t3, s)).rejects.toThrow(/reaped/);
|
|
3537
3754
|
// Audited as reapScope against the right scope + actor.
|
|
3538
3755
|
const reapEntry = (await host.admin.auditLog(staff, { tenantId: t3 })).find((r) => r.action === 'reapScope' && r.scopeId === s);
|
|
3539
3756
|
expect(reapEntry?.actor).toBe(staff);
|