@substrat-run/contract-tests 0.135.0 → 0.136.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.
@@ -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
3
  import { connectionId, dataSubjectId, domainEvent, errorCodeOf, eventId, instant, moduleManifest, orgId, AUTO_ADMISSION_NOTE, permissionKey, platformActorId, platformRequestId, principalId, scopeId, tenantId, SCOPE_QUERY_ROW_MAX, KERNEL_AUTHORED_EVENT_TYPES, SWEEP_RUNS_KIND, SCOPE_TABLE_PAGE_MAX, } from '@substrat-run/contracts';
4
- import { isSearchIndexTable, platformIntentFailureMessage, REDACTED_DELIVERY_NOTE, REDACTED_FAILURE_NOTE, REDACTED_INTENT_MARKER, runPlatformSweep, startPlatformSweeper, ulid, } from '@substrat-run/kernel';
4
+ import { INERT_SCOPE_REASON, isSearchIndexTable, platformIntentFailureMessage, REDACTED_DELIVERY_NOTE, REDACTED_FAILURE_NOTE, REDACTED_INTENT_MARKER, runPlatformSweep, startPlatformSweeper, createUlid, 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,253 @@ 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
+ * #1686. The platform's drain walks every active scope, and a fork, a snapshot or a preview
381
+ * is one, so an intent copied while still pending would be executed a second time from the
382
+ * copy: an email sent twice, a connector delivery repeated. `listPlatformRequests` is the
383
+ * drain's one read, so what it returns on the copy is exactly what a drain would run.
384
+ */
385
+ describe("a copy never runs the source's pending intents (#1686)", () => {
386
+ let source;
387
+ let id;
388
+ const pendingAt = async (s) => (await host.listPlatformRequests(t1, s)).map((r) => r.id);
389
+ const historyAt = async (s) => (await host.listPlatformRequestHistory(t1, s)).find((r) => r.id === id);
390
+ /** The copy: the intent is not drainable there, and its journal says why. */
391
+ const notCarriedAt = async (copy) => {
392
+ expect(await pendingAt(copy)).not.toContain(id);
393
+ expect(await historyAt(copy)).toMatchObject({
394
+ status: 'failed',
395
+ failure: { origin: 'platform', code: 'precondition_failed', permission: null },
396
+ lastError: expect.stringMatching(new RegExp(`^not carried: copied from scope ${source}`)),
397
+ settledAt: expect.any(String),
398
+ });
399
+ // The twin: the source still holds it pending, for its own drain.
400
+ expect(await pendingAt(source)).toContain(id);
401
+ };
402
+ beforeAll(async () => {
403
+ source = scopeId.parse(ulid());
404
+ await host.provisionScope(staff, { tenantId: t1, scopeId: source, jurisdiction: 'eu', vertical: 'connector-vertical' });
405
+ await host.admin.activateScope(staff, t1, source);
406
+ id = await (await host.getScope(alice, t1, source)).invoke('platform/request', {
407
+ kind: 'provision-sibling',
408
+ payload: { slug: 'copied' },
409
+ });
410
+ expect(await pendingAt(source)).toContain(id);
411
+ });
412
+ it('a fork (importScope) settles it not-carried; the source keeps it pending', async () => {
413
+ const fork = scopeId.parse(ulid());
414
+ await host.importScope(staff, { tenantId: t1, scopeId: fork, jurisdiction: 'eu', vertical: 'connector-vertical' }, await host.admin.exportScope(staff, t1, source));
415
+ await notCarriedAt(fork);
416
+ });
417
+ it('a snapshot (snapshotScope): the same', async () => {
418
+ await notCarriedAt(await host.snapshotScope(staff, t1, source, { kind: 'preview' }));
419
+ });
420
+ it("a restore of the source's backup onto another scope: the same", async () => {
421
+ const other = scopeId.parse(ulid());
422
+ await host.provisionScope(staff, { tenantId: t1, scopeId: other, jurisdiction: 'eu', vertical: 'connector-vertical' });
423
+ await host.admin.activateScope(staff, t1, other);
424
+ await host.restoreScope(staff, t1, other, await host.admin.exportScope(staff, t1, source));
425
+ await notCarriedAt(other);
426
+ });
427
+ it('a restore into the scope the backup came from keeps it pending, payload and all', async () => {
428
+ await host.restoreScope(staff, t1, source, await host.admin.exportScope(staff, t1, source));
429
+ const back = (await host.listPlatformRequests(t1, source)).find((r) => r.id === id);
430
+ expect(back).toMatchObject({ status: 'pending', payload: { slug: 'copied' }, lastError: null, settledAt: null });
431
+ });
432
+ });
433
+ /**
434
+ * #1686, the rest of the queue. Delivery is "no delivery row yet", so an event the source
435
+ * emitted and nobody reached is owed work in the copy too, and an executor retry is due work.
436
+ * The dump below is the source's own export with the journal set back: the consumer's row
437
+ * for one event removed (never delivered), one executor delivery made due for a retry, and
438
+ * another removed (never attempted).
439
+ */
440
+ describe('a copy never runs work the source queued (#1686)', () => {
441
+ let source;
442
+ let queued;
443
+ let step1;
444
+ const count = (tag) => effected.filter((e) => e === tag).length;
445
+ const logOf = async (s) => (await (await host.getScope(alice, t1, s)).invoke('flow/log')).map((r) => r.event_id);
446
+ /**
447
+ * The events whose executor delivery a copy journaled INERT (#2005): its own work, which
448
+ * reached dispatch — so `emittedHere` let it through — and was then held, because a copy
449
+ * causes no outbound effects. The source's work never reaches dispatch at all.
450
+ */
451
+ const inertIn = async (s) => (await host.executorDeadLetters(t1, s)).filter((d) => d.error === INERT_SCOPE_REASON).map((d) => d.eventId);
452
+ const eventIdOf = async (s, entityId) => {
453
+ const outbox = (await host.admin.exportScope(staff, t1, s)).tables.find((t) => t.name === '_substrat_outbox');
454
+ return String(outbox.rows.find((r) => r[outbox.columns.indexOf('entity_id')] === entityId)[outbox.columns.indexOf('id')]);
455
+ };
456
+ const activeScope = async () => {
457
+ const s = scopeId.parse(ulid());
458
+ await host.provisionScope(staff, { tenantId: t1, scopeId: s, jurisdiction: 'eu', vertical: 'connector-vertical' });
459
+ await host.admin.activateScope(staff, t1, s);
460
+ return s;
461
+ };
462
+ beforeAll(async () => {
463
+ source = await activeScope();
464
+ const stub = await host.getScope(alice, t1, source);
465
+ await stub.invoke('flow/produce');
466
+ await stub.invoke('connector/request-effect', { tag: 'copy-retry' });
467
+ await stub.invoke('connector/request-effect', { tag: 'copy-unattempted' });
468
+ // The source ran all three: the consumer logged step1, the executor effected both.
469
+ expect(count('copy-retry')).toBe(1);
470
+ expect(count('copy-unattempted')).toBe(1);
471
+ const dump = await host.admin.exportScope(staff, t1, source);
472
+ const outbox = dump.tables.find((t) => t.name === '_substrat_outbox');
473
+ const col = (name) => outbox.columns.indexOf(name);
474
+ const idOf = (type, entityId) => String(outbox.rows.find((r) => r[col('type')] === type && r[col('entity_id')] === entityId)[col('id')]);
475
+ step1 = idOf('flow.step1', 'f1');
476
+ const retry = idOf('effect.requested', 'copy-retry');
477
+ const unattempted = idOf('effect.requested', 'copy-unattempted');
478
+ expect(await logOf(source)).toContain(step1);
479
+ queued = {
480
+ ...dump,
481
+ tables: dump.tables.map((t) => {
482
+ // The consumer's own record of the delivery goes with the delivery row.
483
+ if (t.name === 'flow_log')
484
+ return { ...t, rows: [] };
485
+ if (t.name !== '_substrat_deliveries')
486
+ return t;
487
+ const at = (name) => t.columns.indexOf(name);
488
+ const rows = t.rows
489
+ .filter((r) => !(r[at('event_id')] === step1 && r[at('consumer_module')] === '@test/flow'))
490
+ .filter((r) => r[at('event_id')] !== unattempted)
491
+ .map((r) => {
492
+ if (r[at('event_id')] !== retry)
493
+ return r;
494
+ const due = [...r];
495
+ due[at('error')] = 'boom';
496
+ due[at('attempts')] = 1;
497
+ due[at('next_attempt_at')] = '2000-01-01T00:00:00.000Z';
498
+ return due;
499
+ });
500
+ return { ...t, rows };
501
+ }),
502
+ };
503
+ });
504
+ it('a fork: no consumer reaches the copied event and no executor runs, on dispatch or on drainDue', async () => {
505
+ const fork = scopeId.parse(ulid());
506
+ await host.importScope(staff, { tenantId: t1, scopeId: fork, jurisdiction: 'eu', vertical: 'connector-vertical' }, queued);
507
+ const stub = await host.getScope(alice, t1, fork);
508
+ await stub.invoke('flow/log'); // an invocation in the copy dispatches
509
+ await host.drainDue(t1, fork);
510
+ expect(count('copy-retry')).toBe(1);
511
+ expect(count('copy-unattempted')).toBe(1);
512
+ // The consumer never ran on the copied event.
513
+ expect(await logOf(fork)).toEqual([]);
514
+ expect(await host.listPlatformRequests(t1, fork)).toEqual([]);
515
+ // The retry reads settled, saying why, rather than vanishing.
516
+ const settled = (await host.executorDeadLetters(t1, fork)).find((d) => d.executorId === 'flaky-effector');
517
+ expect(settled?.error).toMatch(new RegExp(`^not carried: copied from scope ${source}`));
518
+ // Nor does it ship the source's events to Tier 2 again: the drain's read is empty here,
519
+ // while the source's own read still holds them.
520
+ expect(await host.admin.readUndrainedEvents(staff, t1, fork, 200)).toHaveLength(0);
521
+ expect((await host.admin.readUndrainedEvents(staff, t1, source, 200)).map((e) => e.id)).toContain(step1);
522
+ // The twin: work the copy creates itself flows as it always did, to Tier 2 included.
523
+ await stub.invoke('flow/produce');
524
+ expect((await host.admin.readUndrainedEvents(staff, t1, fork, 200)).map((e) => e.type)).toContain('flow.step1');
525
+ const own = await logOf(fork);
526
+ expect(own).toHaveLength(2); // its own step1, and the step2 that consumer emitted
527
+ expect(own).not.toContain(step1);
528
+ // Its own executor work reaches dispatch too, and is held there: a fork is inert (#2005).
529
+ await stub.invoke('connector/request-effect', { tag: 'fork-own' });
530
+ expect(count('fork-own')).toBe(0);
531
+ expect(await inertIn(fork)).toEqual([await eventIdOf(fork, 'fork-own')]);
532
+ });
533
+ it("the copy's own events still sort above a copied event stamped by a clock that ran ahead", async () => {
534
+ // A copied event from a source whose clock was ahead: without the loader re-seeding the
535
+ // copy's id floor from the outbox it brought, the copy's next event would sort below the
536
+ // mark and be withheld as if it were the source's.
537
+ const ahead = createUlid()(Date.parse('2099-01-01T00:00:00.000Z'));
538
+ const withAhead = {
539
+ ...queued,
540
+ tables: queued.tables.map((t) => {
541
+ if (t.name !== '_substrat_outbox')
542
+ return t;
543
+ const at = (name) => t.columns.indexOf(name);
544
+ const template = t.rows.find((r) => r[at('type')] === 'effect.requested');
545
+ const future = [...template];
546
+ future[at('id')] = ahead;
547
+ future[at('entity_id')] = 'copy-ahead';
548
+ future[at('payload')] = JSON.stringify({ tag: 'copy-ahead' });
549
+ return { ...t, rows: [...t.rows, future] };
550
+ }),
551
+ };
552
+ const fork = scopeId.parse(ulid());
553
+ await host.importScope(staff, { tenantId: t1, scopeId: fork, jurisdiction: 'eu', vertical: 'connector-vertical' }, withAhead);
554
+ await (await host.getScope(alice, t1, fork)).invoke('connector/request-effect', { tag: 'fork-after-ahead' });
555
+ // The copy's own event reached dispatch (held inert there, #2005); the copied one did not.
556
+ expect(await inertIn(fork)).toEqual([await eventIdOf(fork, 'fork-after-ahead')]);
557
+ expect(count('fork-after-ahead')).toBe(0);
558
+ expect(count('copy-ahead')).toBe(0);
559
+ });
560
+ it('a snapshot: the same', async () => {
561
+ const snap = scopeId.parse(ulid());
562
+ await host.importScope(staff, { tenantId: t1, scopeId: snap, jurisdiction: 'eu', vertical: 'connector-vertical' }, queued);
563
+ await host.drainDue(t1, snap);
564
+ await (await host.getScope(alice, t1, snap)).invoke('flow/log');
565
+ expect(count('copy-retry')).toBe(1);
566
+ expect(count('copy-unattempted')).toBe(1);
567
+ expect(await logOf(snap)).toEqual([]);
568
+ });
569
+ /** A scope's export with one executor delivery removed, so that event is owed again. */
570
+ const owing = async (s, entityId) => {
571
+ const dump = await host.admin.exportScope(staff, t1, s);
572
+ const outbox = dump.tables.find((t) => t.name === '_substrat_outbox');
573
+ const id = outbox.rows.find((r) => r[outbox.columns.indexOf('entity_id')] === entityId)[outbox.columns.indexOf('id')];
574
+ return {
575
+ ...dump,
576
+ tables: dump.tables.map((t) => t.name === '_substrat_deliveries' ? { ...t, rows: t.rows.filter((r) => r[t.columns.indexOf('event_id')] !== id) } : t),
577
+ };
578
+ };
579
+ it("a copy of a copy runs neither source's work; the copy's own backup, returned, runs only its own", async () => {
580
+ const first = scopeId.parse(ulid());
581
+ await host.importScope(staff, { tenantId: t1, scopeId: first, jurisdiction: 'eu', vertical: 'connector-vertical' }, queued);
582
+ await (await host.getScope(alice, t1, first)).invoke('connector/request-effect', { tag: 'first-own' });
583
+ // A copy's own work reaches dispatch and is held inert there (#2005), never effected.
584
+ const firstOwn = await eventIdOf(first, 'first-own');
585
+ expect(await inertIn(first)).toEqual([firstOwn]);
586
+ const firstsBackup = await owing(first, 'first-own');
587
+ // copy → copy: the second copy's mark is above the first copy's own events too.
588
+ const second = scopeId.parse(ulid());
589
+ await host.importScope(staff, { tenantId: t1, scopeId: second, jurisdiction: 'eu', vertical: 'connector-vertical' }, firstsBackup);
590
+ await (await host.getScope(alice, t1, second)).invoke('flow/log');
591
+ await host.drainDue(t1, second);
592
+ expect(count('copy-retry')).toBe(1);
593
+ expect(count('copy-unattempted')).toBe(1);
594
+ // The first copy's own event is the second's INHERITED work: it never reaches dispatch.
595
+ expect(await inertIn(second)).toEqual([]);
596
+ await (await host.getScope(alice, t1, second)).invoke('connector/request-effect', { tag: 'second-own' });
597
+ expect(await inertIn(second)).toEqual([await eventIdOf(second, 'second-own')]);
598
+ // The first copy's backup returned to it: its own owed event runs, and the source's
599
+ // still do not, because the copy's origin came back with its backup.
600
+ await host.restoreScope(staff, t1, first, firstsBackup);
601
+ expect(await inertIn(first)).toEqual([]); // the backup had its delivery row removed
602
+ await (await host.getScope(alice, t1, first)).invoke('flow/log');
603
+ await host.drainDue(t1, first);
604
+ // Owed again and the first copy's own, so it reaches dispatch again: held inert (#2005).
605
+ expect(await inertIn(first)).toEqual([firstOwn]);
606
+ expect(count('first-own')).toBe(0);
607
+ expect(count('copy-retry')).toBe(1);
608
+ expect(count('copy-unattempted')).toBe(1);
609
+ expect(await logOf(first)).toEqual([]);
610
+ });
611
+ it('a return (the same dump restored into its own scope) runs all of it — from a dump that predates the origin table too', async () => {
612
+ // A backup taken before `_substrat_copy_origin` existed: the loader builds the table from
613
+ // the kernel's DDL, as every wake does on an existing scope, and no row means nothing here
614
+ // was copied.
615
+ const legacy = { ...queued, tables: queued.tables.filter((t) => t.name !== '_substrat_copy_origin') };
616
+ expect(legacy.tables.length).toBe(queued.tables.length - 1);
617
+ await host.restoreScope(staff, t1, source, legacy);
618
+ expect((await host.admin.listScopeTables(staff, t1, source)).map((t) => t.name)).toContain('_substrat_copy_origin');
619
+ await (await host.getScope(alice, t1, source)).invoke('flow/log');
620
+ await host.drainDue(t1, source);
621
+ expect(count('copy-retry')).toBe(2);
622
+ expect(count('copy-unattempted')).toBe(2);
623
+ expect(await logOf(source)).toContain(step1);
624
+ });
625
+ });
379
626
  /**
380
627
  * #1588. Every read of the intent journal returns a LIST, and the row decode behind all
381
628
  * three was strict — so ONE row whose JSON would not parse threw out of the map and took
@@ -2684,9 +2931,12 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
2684
2931
  const dump = await host.admin.exportScope(staff, t1, s1);
2685
2932
  const copy = scopeId.parse(ulid());
2686
2933
  await host.importScope(staff, { tenantId: t1, scopeId: copy, jurisdiction: 'eu', vertical: 'connector-vertical' }, dump);
2687
- // Same tables and row counts as the source (the spine came across too).
2688
- const src = (await host.admin.listScopeTables(staff, t1, s1))
2689
- .map((t) => `${t.name}:${t.rowCount}`)
2934
+ // Same tables and row counts as the source (the spine came across too), but for the one
2935
+ // row every copy adds: where it came from, and the mark (#1686, and #2005: an empty copy
2936
+ // holds it too, which is how a host with no directory knows a copy).
2937
+ const srcTables = await host.admin.listScopeTables(staff, t1, s1);
2938
+ const src = srcTables
2939
+ .map((t) => (t.name === '_substrat_copy_origin' ? `${t.name}:1` : `${t.name}:${t.rowCount}`))
2690
2940
  .sort();
2691
2941
  const dst = (await host.admin.listScopeTables(staff, t1, copy))
2692
2942
  .map((t) => `${t.name}:${t.rowCount}`)
@@ -4454,6 +4704,37 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
4454
4704
  await host.admin.bindScopeVersion(staff, t1, s, v1); // legacy unconditional caller
4455
4705
  expect((await host.admin.getScopeRecord(staff, t1, s)).verticalVersionId).toBe(v1);
4456
4706
  });
4707
+ // #1722 (Codex #2008 r7): the admin-log record of a staff resolution of a kept copy. The
4708
+ // resolution itself runs in the vertical's store; the directory only records it, in full.
4709
+ it('records a kept-copy resolution in the admin log, before and after, and refuses an unknown scope', async () => {
4710
+ const s = scopeId.parse(ulid());
4711
+ await host.provisionScope(staff, { tenantId: t1, scopeId: s, vertical: 'demo' });
4712
+ await host.admin.recordKeptCopyResolution(staff, t1, s, {
4713
+ action: 'restore-forward',
4714
+ script: 'demo-v1',
4715
+ liveScript: 'demo-v2',
4716
+ keptAt: '2026-10-03T12:00:00.000Z',
4717
+ revisionBefore: '41',
4718
+ revisionAfter: '7',
4719
+ });
4720
+ const [row] = await host.admin.auditLog(staff, { action: 'resolveKeptCopy', scopeId: s });
4721
+ expect(row).toMatchObject({
4722
+ actor: staff,
4723
+ action: 'resolveKeptCopy',
4724
+ before: { script: 'demo-v1', keptAt: '2026-10-03T12:00:00.000Z', revision: '41' },
4725
+ after: { action: 'restore-forward', liveScript: 'demo-v2', revision: '7' },
4726
+ });
4727
+ const other = scopeId.parse(ulid());
4728
+ await expect(host.admin.recordKeptCopyResolution(staff, t1, other, {
4729
+ action: 'discard',
4730
+ script: 'demo-v1',
4731
+ liveScript: null,
4732
+ keptAt: '2026-10-03T12:00:00.000Z',
4733
+ revisionBefore: null,
4734
+ revisionAfter: null,
4735
+ })).rejects.toThrow(/unknown scope/);
4736
+ expect(await host.admin.auditLog(staff, { action: 'resolveKeptCopy', scopeId: other })).toEqual([]);
4737
+ });
4457
4738
  it('publishes a version as pending, and refuses to bind it until admitted', async () => {
4458
4739
  const versionId = ulid();
4459
4740
  await host.admin.registerVertical(staff, {