@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.
- package/dist/async-log-suite.d.ts +10 -0
- package/dist/async-log-suite.d.ts.map +1 -0
- package/dist/async-log-suite.js +226 -0
- package/dist/async-log-suite.js.map +1 -0
- package/dist/capability-suite.d.ts.map +1 -1
- package/dist/capability-suite.js +63 -0
- package/dist/capability-suite.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/inert-scope-suite.d.ts +14 -0
- package/dist/inert-scope-suite.d.ts.map +1 -0
- package/dist/inert-scope-suite.js +159 -0
- package/dist/inert-scope-suite.js.map +1 -0
- package/dist/job-run-suite.d.ts.map +1 -1
- package/dist/job-run-suite.js +27 -0
- package/dist/job-run-suite.js.map +1 -1
- package/dist/list-suite.d.ts.map +1 -1
- package/dist/list-suite.js +89 -3
- package/dist/list-suite.js.map +1 -1
- package/dist/modules.d.ts +151 -0
- package/dist/modules.d.ts.map +1 -1
- package/dist/modules.js +88 -1
- package/dist/modules.js.map +1 -1
- package/dist/scope-host-suite.d.ts.map +1 -1
- package/dist/scope-host-suite.js +285 -4
- package/dist/scope-host-suite.js.map +1 -1
- package/package.json +3 -3
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
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
|
-
|
|
2689
|
-
|
|
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, {
|