@substrat-run/contract-tests 0.120.0 → 0.121.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,6 +1,6 @@
1
1
  import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
2
2
  import { dataSubjectId, eventId, moduleManifest, permissionKey, platformActorId, principalId, scopeId, tenantId, z, } from '@substrat-run/contracts';
3
- import { assertAllowed, runPlatformSweep, ulid, } from '@substrat-run/kernel';
3
+ import { assertAllowed, crossVerticalHealth, runPlatformSweep, ulid, } from '@substrat-run/kernel';
4
4
  // -- the two fixture verticals ------------------------------------------------
5
5
  //
6
6
  // Named as the verticals of one tenant would be: a CRM that owns customers, and a board-room
@@ -28,8 +28,14 @@ export const crmExportModManifest = moduleManifest.parse({
28
28
  { type: 'crm.customer-created', schemaVersion: 1 },
29
29
  { type: 'crm.customer-noted', schemaVersion: 1 },
30
30
  { type: 'crm.customer-touched', schemaVersion: 1 },
31
+ // #1705 PR 2: not exported. Its local consumer answers with an exported type, which is
32
+ // how the kick's signal is shown to count a consumer's emit in the invoke's tail.
33
+ { type: 'crm.customer-flagged', schemaVersion: 1 },
34
+ ],
35
+ consumes: [
36
+ { from: BOARD_VERTICAL, type: 'board.association-linked', schemaVersion: 1 },
37
+ { type: 'crm.customer-flagged', schemaVersion: 1 },
31
38
  ],
32
- consumes: [{ from: BOARD_VERTICAL, type: 'board.association-linked', schemaVersion: 1 }],
33
39
  exports: [
34
40
  { type: 'crm.customer-created', schemaVersion: 1, readPermission: 'customer:read' },
35
41
  { type: 'crm.customer-touched', schemaVersion: 1, readPermission: 'customer:read' },
@@ -93,6 +99,30 @@ const crmTouch = async (ctx, input) => {
93
99
  payload: { id: input.id },
94
100
  });
95
101
  };
102
+ /** Emits an exported type, then throws: the kick's signal must not survive the rollback. */
103
+ const crmCreateThenFail = async (ctx, input) => {
104
+ assertAllowed(await ctx.check(key('customer:write')));
105
+ const id = ulid();
106
+ ctx.emit({
107
+ type: 'crm.customer-created',
108
+ schemaVersion: 1,
109
+ entity: { entityType: 'customer', entityId: id },
110
+ piiClass: 'none',
111
+ payload: { id, name: input.name },
112
+ });
113
+ throw new Error('crm/create-then-fail fails after emitting, by design');
114
+ };
115
+ /** Emits a type crm does NOT export; its local consumer answers with one it does. */
116
+ const crmFlag = async (ctx, input) => {
117
+ assertAllowed(await ctx.check(key('customer:write')));
118
+ ctx.emit({
119
+ type: 'crm.customer-flagged',
120
+ schemaVersion: 1,
121
+ entity: { entityType: 'customer', entityId: input.id },
122
+ piiClass: 'none',
123
+ payload: { id: input.id },
124
+ });
125
+ };
96
126
  export const crmExportMod = {
97
127
  manifest: crmExportModManifest,
98
128
  migrations: [{ version: '0001-init', sql: 'CREATE TABLE crm_customers (id TEXT PRIMARY KEY, name TEXT NOT NULL)' }],
@@ -100,6 +130,21 @@ export const crmExportMod = {
100
130
  'crm/create': crmCreate,
101
131
  'crm/note': crmNote,
102
132
  'crm/touch': crmTouch,
133
+ 'crm/create-then-fail': crmCreateThenFail,
134
+ 'crm/flag': crmFlag,
135
+ },
136
+ consumers: {
137
+ // In the invoke's post-commit tail: an exported type committed by a consumer, not the operation.
138
+ 'crm.customer-flagged': async (ctx, event) => {
139
+ const { id } = z.object({ id: z.string() }).parse(event.payload);
140
+ ctx.emit({
141
+ type: 'crm.customer-created',
142
+ schemaVersion: 1,
143
+ entity: { entityType: 'customer', entityId: id },
144
+ piiClass: 'none',
145
+ payload: { id, name: 'Flagged' },
146
+ });
147
+ },
103
148
  },
104
149
  imports: {
105
150
  [BOARD_VERTICAL]: {
@@ -156,6 +201,7 @@ const boardRead = async (ctx) => {
156
201
  imports: ctx.sql.query('SELECT event_id, source_vertical, source_scope_id, type, hops, withheld FROM _substrat_imports ORDER BY event_id'),
157
202
  deliveries: ctx.sql.query('SELECT event_id, consumer_module, error FROM _substrat_deliveries ORDER BY event_id, consumer_module'),
158
203
  outbox: ctx.sql.query('SELECT id, type, caused_by, actor FROM _substrat_outbox ORDER BY id'),
204
+ replays: ctx.sql.query('SELECT replay_id, kind, event_id, consumer_module FROM _substrat_import_replays ORDER BY kind, event_id'),
159
205
  };
160
206
  };
161
207
  export const boardImportMod = {
@@ -252,12 +298,18 @@ export function verticalEventsContractSuite(adapterName, makeFixture) {
252
298
  // fills from the registry, so the suite also proves that a scope it drops is never called.
253
299
  const current = new Set();
254
300
  beforeEach(() => current.clear());
255
- const reach = {
256
- candidates: (scopes) => scopes.filter((s) => current.has(s.tenantId)),
301
+ const inProcess = {
257
302
  importState: async (t, s) => (await hostOf(t, s)).admin.importState(staff, t, s),
258
303
  readExports: async (t, s, input) => (await hostOf(t, s)).admin.readExportedEvents(staff, t, s, input),
259
304
  deliver: async (t, s, batch) => (await hostOf(t, s)).deliverToPeer(t, s, batch),
260
305
  };
306
+ // Resolved per call, because the fixture is built in `beforeAll`.
307
+ const reach = {
308
+ candidates: (scopes) => scopes.filter((s) => current.has(s.tenantId)),
309
+ importState: (t, s) => (fx.transport ?? inProcess).importState(t, s),
310
+ readExports: (t, s, input) => (fx.transport ?? inProcess).readExports(t, s, input),
311
+ deliver: (t, s, batch) => (fx.transport ?? inProcess).deliver(t, s, batch),
312
+ };
261
313
  const newTenant = async () => {
262
314
  const t = tenantId.parse(ulid());
263
315
  await fx.producer.admin.createTenant(staff, { id: t, slug: `ve-${t.toLowerCase()}`, name: 'Vertical events' });
@@ -280,6 +332,7 @@ export function verticalEventsContractSuite(adapterName, makeFixture) {
280
332
  grantedBy: writer,
281
333
  });
282
334
  }
335
+ await fx.afterInstall?.(t, s, vertical);
283
336
  return s;
284
337
  };
285
338
  const crm = async (t, s, op, input) => (await fx.producer.getScope(writer, t, s)).invoke(op, input);
@@ -302,6 +355,28 @@ export function verticalEventsContractSuite(adapterName, makeFixture) {
302
355
  });
303
356
  return { report, runs };
304
357
  };
358
+ const lever = (t, s, move) => fx.lever ? fx.lever(t, s, move) : fx.consumer.admin.moveImportCursor(staff, t, s, move);
359
+ const replay = (after) => ({
360
+ mode: 'replay',
361
+ from: CRM_VERTICAL,
362
+ after: after === null ? null : eventId.parse(after),
363
+ acknowledge: 'rerun-handlers',
364
+ reason: 'the board app lost a day of associations',
365
+ });
366
+ const skip = (through) => ({
367
+ mode: 'skip',
368
+ from: CRM_VERTICAL,
369
+ through: through === 'now' ? 'now' : eventId.parse(through),
370
+ acknowledge: 'skip-events',
371
+ reason: 'the board app starts from today',
372
+ });
373
+ const refusal = (x) => x.then(() => undefined, (e) => e);
374
+ /**
375
+ * Let the clock pass the millisecond an event was minted in. "Skip to now" passes over earlier
376
+ * milliseconds only, so a test that needs an event skipped must not share the skip's. A timer,
377
+ * not a spin: on workerd the clock does not move without I/O.
378
+ */
379
+ const nextMillisecond = () => new Promise((r) => setTimeout(r, 2));
305
380
  const edgesOf = (report, t) => (report.crossVertical?.edges ?? []).filter((e) => e.tenantId === t);
306
381
  /** The edge INTO one consumer scope. crm imports from board too, so a tenant has two. */
307
382
  const into = (report, consumer) => (report.crossVertical?.edges ?? []).find((e) => e.consumer.scopeId === consumer);
@@ -329,6 +404,60 @@ export function verticalEventsContractSuite(adapterName, makeFixture) {
329
404
  expect(again.every((e) => e.state === 'idle' && e.delivered === 0)).toBe(true);
330
405
  expect((await board(t, c)).associations).toHaveLength(2);
331
406
  });
407
+ it('the stub reports a committed exported type, and nothing else — the router kick\'s signal (#1705 PR 2)', async () => {
408
+ const t = await newTenant();
409
+ const p = await install(t, CRM_VERTICAL);
410
+ await install(t, BOARD_VERTICAL);
411
+ const seen = [];
412
+ const stub = await fx.producer.getScope(writer, t, p, { onExportedEvents: (n) => seen.push(n) });
413
+ const id = (await stub.invoke('crm/create', { name: 'Kicked' })).id;
414
+ expect(seen).toEqual([1]);
415
+ // A type crm emits but does not export is no reason to run its edges.
416
+ await stub.invoke('crm/note', { id });
417
+ expect(seen).toEqual([1]);
418
+ // Nor is an invoke that committed nothing: refused before it could emit.
419
+ const refused = await fx.producer.getScope(reader, t, p, { onExportedEvents: (n) => seen.push(n) });
420
+ await expect(refused.invoke('crm/create', { name: 'Refused' })).rejects.toThrow();
421
+ expect(seen).toEqual([1]);
422
+ // Each exported type counts, whichever operation committed it.
423
+ await stub.invoke('crm/touch', { id });
424
+ expect(seen).toEqual([1, 1]);
425
+ });
426
+ it('the kick\'s signal: never for a rolled-back invoke or a read-only session, and a consumer\'s tail counts (#1705 PR 2)', async () => {
427
+ const t = await newTenant();
428
+ const p = await install(t, CRM_VERTICAL);
429
+ const seen = [];
430
+ const stub = await fx.producer.getScope(writer, t, p, { onExportedEvents: (n) => seen.push(n) });
431
+ // Emitted an exported type, then threw: the event rolled back, so there is nothing to kick.
432
+ await expect(stub.invoke('crm/create-then-fail', { name: 'Gone' })).rejects.toThrow(/by design/);
433
+ expect(seen).toEqual([]);
434
+ // The operation emits an unexported type; its local consumer, in the tail, an exported one.
435
+ await stub.invoke('crm/flag', { id: ulid() });
436
+ expect(seen).toEqual([1]);
437
+ // A read-only support session commits nothing, whatever the operation emits.
438
+ const readOnly = await fx.producer.admin.beginImpersonation(staff, {
439
+ tenantId: t,
440
+ scopeId: p,
441
+ principal: writer,
442
+ reason: 'ticket #1705 — checking the kick signal',
443
+ mode: 'read-only',
444
+ });
445
+ // The kernel refuses its emit outright (K-42), so the invoke fails and nothing is raised.
446
+ const ro = await fx.producer.getImpersonatedScope(readOnly.id, t, p, { onExportedEvents: (n) => seen.push(n) });
447
+ await expect(ro.invoke('crm/create', { name: 'Looked at' })).rejects.toThrow(/read-only/);
448
+ expect(seen).toEqual([1]);
449
+ // The twin: a WRITE session that commits the same operation does raise it.
450
+ const write = await fx.producer.admin.beginImpersonation(staff, {
451
+ tenantId: t,
452
+ scopeId: p,
453
+ principal: writer,
454
+ reason: 'ticket #1705 — the write twin',
455
+ mode: 'write',
456
+ });
457
+ const rw = await fx.producer.getImpersonatedScope(write.id, t, p, { onExportedEvents: (n) => seen.push(n) });
458
+ await rw.invoke('crm/create', { name: 'Written' });
459
+ expect(seen).toEqual([1, 1]);
460
+ });
332
461
  it('never crosses a tenant: each consumer receives its own tenant\'s producer, and none from another', async () => {
333
462
  const t = await newTenant();
334
463
  const u = await newTenant();
@@ -559,6 +688,464 @@ export function verticalEventsContractSuite(adapterName, makeFixture) {
559
688
  await sweep();
560
689
  expect((await board(t, c)).associations.map((r) => r.crm_id)).toEqual([during]);
561
690
  });
691
+ // -- the replay lever (#1705 PR 3) ---------------------------------------------------------
692
+ it('replay from the start: every handler runs again, and the first delivery\'s record is moved aside, not deleted', async () => {
693
+ const t = await newTenant();
694
+ const p = await install(t, CRM_VERTICAL);
695
+ const c = await install(t, BOARD_VERTICAL);
696
+ await create(t, p, 'Alpha');
697
+ await create(t, p, 'Beta');
698
+ await sweep();
699
+ const before = await board(t, c);
700
+ const created = (v) => v.outbox.filter((o) => o.type === 'board.association-created');
701
+ expect(created(before)).toHaveLength(2);
702
+ const moved = await lever(t, c, replay(null));
703
+ expect(moved).toMatchObject({ mode: 'replay', cursor: null, source: { vertical: CRM_VERTICAL, scopeId: p } });
704
+ expect(moved.previous).not.toBeNull();
705
+ expect(moved.archived).toEqual({ journal: 2, deliveries: 2 });
706
+ // The evidence of the first delivery stays, under the act that moved it.
707
+ const aside = await board(t, c);
708
+ expect(aside.replays.filter((r) => r.kind === 'journal').map((r) => r.event_id)).toEqual(before.imports.map((i) => i.event_id));
709
+ expect(aside.replays.filter((r) => r.kind === 'delivery')).toHaveLength(2);
710
+ expect(new Set(aside.replays.map((r) => r.replay_id))).toEqual(new Set([moved.replayId]));
711
+ // ...and it has left the LIVE journal, so the redelivery journals each event afresh: an event
712
+ // first withheld for its version is decided again rather than kept as withheld forever.
713
+ expect(aside.imports).toEqual([]);
714
+ expect(aside.deliveries).toEqual([]);
715
+ // The admin log names the act, its reason and what it moved.
716
+ const log = await fx.consumer.admin.auditLog(staff, { tenantId: t, action: ['moveImportCursor'] });
717
+ expect(log.map((e) => e.after.phase).sort()).toEqual(['applied', 'intent']);
718
+ expect(log.every((e) => e.after.replayId === moved.replayId)).toBe(true);
719
+ // The next pass delivers both again, and each handler RUNS again: that is what a replay is.
720
+ const { report } = await sweep();
721
+ expect(into(report, c)).toMatchObject({ state: 'delivered', delivered: 2, duplicates: 0 });
722
+ const after = await board(t, c);
723
+ expect(created(after)).toHaveLength(4);
724
+ expect(after.associations.map((r) => r.name)).toEqual(['Alpha', 'Beta']);
725
+ expect(after.imports.map((i) => i.event_id)).toEqual(before.imports.map((i) => i.event_id));
726
+ });
727
+ it('replay from a point: only the events after it run again', async () => {
728
+ const t = await newTenant();
729
+ const p = await install(t, CRM_VERTICAL);
730
+ const c = await install(t, BOARD_VERTICAL);
731
+ await create(t, p, 'Kept');
732
+ await create(t, p, 'Replayed');
733
+ await sweep();
734
+ const [first, second] = (await board(t, c)).imports;
735
+ const moved = await lever(t, c, replay(first.event_id));
736
+ expect(moved).toMatchObject({ cursor: first.event_id, archived: { journal: 1, deliveries: 1 } });
737
+ await sweep();
738
+ const view = await board(t, c);
739
+ // The positive twin of the whole-history replay: the first event's handler did not run again.
740
+ const createdFor = (id) => view.outbox.filter((o) => o.type === 'board.association-created' && o.caused_by === id).length;
741
+ expect(createdFor(first.event_id)).toBe(1);
742
+ expect(createdFor(second.event_id)).toBe(2);
743
+ });
744
+ it('skip to now: nothing before the skip is delivered, what comes after is, and a replay reaches back', async () => {
745
+ const t = await newTenant();
746
+ const p = await install(t, CRM_VERTICAL);
747
+ const c = await install(t, BOARD_VERTICAL);
748
+ await create(t, p, 'Before the skip');
749
+ await nextMillisecond();
750
+ const moved = await lever(t, c, skip('now'));
751
+ expect(moved).toMatchObject({ mode: 'skip', previous: null, archived: { journal: 0, deliveries: 0 } });
752
+ expect(moved.cursor).not.toBeNull();
753
+ await sweep();
754
+ expect((await board(t, c)).associations).toEqual([]);
755
+ await create(t, p, 'After the skip');
756
+ await sweep();
757
+ expect((await board(t, c)).associations.map((r) => r.name)).toEqual(['After the skip']);
758
+ // Recoverable: the producer's outbox kept what was skipped.
759
+ await lever(t, c, replay(null));
760
+ await sweep();
761
+ expect((await board(t, c)).associations.map((r) => r.name).sort()).toEqual(['After the skip', 'Before the skip']);
762
+ });
763
+ it('a pass that read before a move is refused by the watermark\'s compare-and-set, and cannot undo it', async () => {
764
+ const t = await newTenant();
765
+ const p = await install(t, CRM_VERTICAL);
766
+ const c = await install(t, BOARD_VERTICAL);
767
+ await create(t, p, 'Read before the skip');
768
+ await nextMillisecond();
769
+ const read = await fx.producer.admin.readExportedEvents(staff, t, p, {
770
+ consumer: BOARD_VERTICAL,
771
+ after: null,
772
+ wants: [{ type: 'crm.customer-created', schemaVersion: 1 }],
773
+ limit: 100,
774
+ });
775
+ const moved = await lever(t, c, skip('now'));
776
+ const late = await fx.consumer.deliverToPeer(t, c, {
777
+ source: { vertical: CRM_VERTICAL, scopeId: p },
778
+ after: null,
779
+ next: read.next,
780
+ events: read.events,
781
+ withheld: read.withheld,
782
+ });
783
+ expect(late).toMatchObject({ stale: true, delivered: 0, cursor: moved.cursor });
784
+ expect((await board(t, c)).associations).toEqual([]);
785
+ });
786
+ it('the lever holds each mode to its direction, and a replay needs something to replay', async () => {
787
+ const t = await newTenant();
788
+ const p = await install(t, CRM_VERTICAL);
789
+ const c = await install(t, BOARD_VERTICAL);
790
+ // Nothing taken yet: there is nothing to run again.
791
+ expect(String(await refusal(lever(t, c, replay(null))))).toMatch(/nothing to replay/);
792
+ await create(t, p, 'One');
793
+ await create(t, p, 'Two');
794
+ await sweep();
795
+ const [first, second] = (await board(t, c)).imports;
796
+ // A skip behind the watermark is a replay, and a replay ahead of it is a skip.
797
+ expect(String(await refusal(lever(t, c, skip(first.event_id))))).toMatch(/skip moves the watermark forward/);
798
+ expect(String(await refusal(lever(t, c, replay(ulid()))))).toMatch(/replay moves the watermark back/);
799
+ // Neither refusal moved anything: the next pass is idle.
800
+ expect(into((await sweep()).report, c)).toMatchObject({ state: 'idle' });
801
+ // The positive twins, each in its own direction.
802
+ await expect(lever(t, c, replay(first.event_id))).resolves.toMatchObject({ cursor: first.event_id });
803
+ await expect(lever(t, c, skip(second.event_id))).resolves.toMatchObject({ cursor: second.event_id });
804
+ });
805
+ it('a skip cannot be aimed past now: the future is refused, and the edge still reads behind', async () => {
806
+ const t = await newTenant();
807
+ const p = await install(t, CRM_VERTICAL);
808
+ const c = await install(t, BOARD_VERTICAL);
809
+ await create(t, p, 'Waiting');
810
+ // The greatest ULID there is: a watermark past every event ever to be written.
811
+ expect(String(await refusal(lever(t, c, skip('7ZZZZZZZZZZZZZZZZZZZZZZZZZ'))))).toMatch(/at most now.*future/);
812
+ expect((await fx.consumer.admin.importState(staff, t, c)).cursors).toEqual([]);
813
+ expect(await healthOf(t, c)).toMatchObject({ state: 'behind' });
814
+ // The twin: to now, it moves, and an event written after it still arrives.
815
+ await nextMillisecond();
816
+ await lever(t, c, skip('now'));
817
+ await create(t, p, 'After');
818
+ await sweep();
819
+ expect((await board(t, c)).associations.map((r) => r.name)).toEqual(['After']);
820
+ });
821
+ it('skip to now never drops an event of its own millisecond: on the boundary it delivers', async () => {
822
+ const t = await newTenant();
823
+ const p = await install(t, CRM_VERTICAL);
824
+ const c = await install(t, BOARD_VERTICAL);
825
+ const moved = await lever(t, c, skip('now'));
826
+ // Whatever is written next, however soon, sorts after the watermark: it cannot have been
827
+ // minted in a millisecond earlier than the skip's.
828
+ await create(t, p, 'Right after');
829
+ await sweep();
830
+ expect((await board(t, c)).associations.map((r) => r.name)).toEqual(['Right after']);
831
+ expect(moved.cursor < ulid()).toBe(true);
832
+ });
833
+ it('the replay history is part of the scope: a dump carries it, and a restore puts it back or takes it away', async () => {
834
+ const t = await newTenant();
835
+ const p = await install(t, CRM_VERTICAL);
836
+ const c = await install(t, BOARD_VERTICAL);
837
+ await create(t, p, 'Dumped');
838
+ await sweep();
839
+ const beforeReplay = await fx.consumer.admin.exportScope(staff, t, c);
840
+ const moved = await lever(t, c, replay(null));
841
+ const afterReplay = await fx.consumer.admin.exportScope(staff, t, c);
842
+ const table = afterReplay.tables.find((x) => x.name === '_substrat_import_replays');
843
+ expect(table?.rows.length).toBe(2);
844
+ // Restoring the dump from before the replay takes the history away, as it rewinds the data.
845
+ await fx.consumer.restoreScope(staff, t, c, beforeReplay);
846
+ expect((await board(t, c)).replays).toEqual([]);
847
+ // Restoring the dump from after it puts the history back, under the same act.
848
+ await fx.consumer.restoreScope(staff, t, c, afterReplay);
849
+ const back = (await board(t, c)).replays;
850
+ expect(back).toHaveLength(2);
851
+ expect(new Set(back.map((r) => r.replay_id))).toEqual(new Set([moved.replayId]));
852
+ });
853
+ it('the lever moves only an edge the consumer imports: a skip cannot plant a watermark for one that does not exist', async () => {
854
+ const t = await newTenant();
855
+ await install(t, CRM_VERTICAL);
856
+ const c = await install(t, BOARD_VERTICAL);
857
+ // Installed in the tenant, and imported by nobody.
858
+ await install(t, 'acme/ledger');
859
+ const plant = { ...skip('now'), from: 'acme/ledger' };
860
+ expect(String(await refusal(lever(t, c, plant)))).toMatch(/imports nothing from 'acme\/ledger'/);
861
+ expect((await fx.consumer.admin.importState(staff, t, c)).cursors).toEqual([]);
862
+ // The twin: the edge it does import moves.
863
+ await expect(lever(t, c, skip('now'))).resolves.toMatchObject({ source: { vertical: CRM_VERTICAL } });
864
+ });
865
+ it('a replay without its acknowledgement never reaches the store', async () => {
866
+ const t = await newTenant();
867
+ const p = await install(t, CRM_VERTICAL);
868
+ const c = await install(t, BOARD_VERTICAL);
869
+ await create(t, p, 'Acknowledged');
870
+ await sweep();
871
+ const { acknowledge: _, ...bare } = replay(null);
872
+ await expect(lever(t, c, bare)).rejects.toThrow();
873
+ const wrong = { ...replay(null), acknowledge: 'skip-events' };
874
+ await expect(lever(t, c, wrong)).rejects.toThrow();
875
+ expect((await board(t, c)).replays).toEqual([]);
876
+ // The twin: acknowledged, it moves.
877
+ await expect(lever(t, c, replay(null))).resolves.toMatchObject({ archived: { journal: 1 } });
878
+ });
879
+ it('the lever never crosses a tenant: another tenant\'s scope is not found, and the producer is the consumer\'s own tenant\'s', async () => {
880
+ const t = await newTenant();
881
+ const u = await newTenant();
882
+ const pt = await install(t, CRM_VERTICAL);
883
+ const ct = await install(t, BOARD_VERTICAL);
884
+ const pu = await install(u, CRM_VERTICAL);
885
+ const cu = await install(u, BOARD_VERTICAL);
886
+ await create(t, pt, 'T');
887
+ await create(u, pu, 'U');
888
+ await sweep();
889
+ const cursorOf = async (tt, cc) => (await fx.consumer.admin.importState(staff, tt, cc)).cursors.map((x) => [x.source, x.cursor]);
890
+ const uBefore = await cursorOf(u, cu);
891
+ // u's consumer scope named under t: not found, and nothing moved anywhere.
892
+ expect(String(await refusal(lever(t, cu, replay(null))))).toMatch(/unknown scope|not found|conflict/i);
893
+ expect(await cursorOf(u, cu)).toEqual(uBefore);
894
+ // t's replay resolves t's producer, and moves t's edge only.
895
+ const moved = await lever(t, ct, replay(null));
896
+ expect(moved.source.scopeId).toBe(pt);
897
+ expect(await cursorOf(u, cu)).toEqual(uBefore);
898
+ // A tenant with no producer has no edge to move.
899
+ const v = await newTenant();
900
+ const cv = await install(v, BOARD_VERTICAL);
901
+ expect(String(await refusal(lever(v, cv, skip('now'))))).toMatch(/not installed in this tenant/);
902
+ });
903
+ // -- edge health (#1705 PR 3) --------------------------------------------------------------
904
+ const health = (t, override = {}) => crossVerticalHealth(fx.consumer, {
905
+ actor: staff,
906
+ tenantId: t,
907
+ crossVertical: { reach: { ...reach, ...override } },
908
+ ...(fx.door ? { door: fx.door } : {}),
909
+ });
910
+ const healthOf = async (t, c, override = {}) => (await health(t, override)).edges.find((e) => e.consumer.scopeId === c);
911
+ it('edge health: behind before a pass (with its lag), caught up after, and the pass that delivered', async () => {
912
+ const t = await newTenant();
913
+ const p = await install(t, CRM_VERTICAL);
914
+ const c = await install(t, BOARD_VERTICAL);
915
+ await create(t, p, 'Waiting');
916
+ const before = await healthOf(t, c);
917
+ expect(before).toMatchObject({
918
+ state: 'behind',
919
+ producer: { vertical: CRM_VERTICAL, scopeId: p },
920
+ watermark: null,
921
+ lastDelivered: null,
922
+ });
923
+ expect(before?.oldestPending).not.toBeNull();
924
+ expect(before?.lagMs).toBeGreaterThanOrEqual(0);
925
+ // The probe delivered nothing: the view is a read.
926
+ expect((await board(t, c)).associations).toEqual([]);
927
+ await sweep();
928
+ const after = await healthOf(t, c);
929
+ expect(after).toMatchObject({ state: 'caught-up', oldestPending: null, lagMs: null, reason: null });
930
+ expect(after?.watermark).not.toBeNull();
931
+ // A door that cannot be read changes nothing about a caught-up edge: its reason stays null.
932
+ const unreadDoor = await crossVerticalHealth(fx.consumer, {
933
+ actor: staff,
934
+ tenantId: t,
935
+ crossVertical: { reach },
936
+ door: async () => {
937
+ throw new Error('door unreadable');
938
+ },
939
+ });
940
+ expect(unreadDoor.edges.find((e) => e.consumer.scopeId === c)).toMatchObject({ state: 'caught-up', reason: null });
941
+ // What the consumer asks for and the producer does not export, reported beside the state.
942
+ // (The sweep-run history is covered where the rows are durable: the control plane's route.)
943
+ expect(after?.unexported).toEqual([{ type: 'crm.customer-noted', schemaVersion: 1 }]);
944
+ });
945
+ it('edge health: paused by the producer, and paused at the consumer\'s door, each saying why', async () => {
946
+ const t = await newTenant();
947
+ const p = await install(t, CRM_VERTICAL);
948
+ const c = await install(t, BOARD_VERTICAL);
949
+ await create(t, p, 'Held');
950
+ await fx.producer.admin.revokeFromPeer(staff, { vertical: BOARD_VERTICAL, node: { tenantId: t, scopeId: p }, reason: 'stop exports' });
951
+ expect(await healthOf(t, c)).toMatchObject({
952
+ state: 'paused',
953
+ reason: expect.stringContaining(`does not grant vertical:${BOARD_VERTICAL} customer:read`),
954
+ });
955
+ await fx.producer.admin.restoreToPeer(staff, { vertical: BOARD_VERTICAL, node: { tenantId: t, scopeId: p }, reason: 'ok' });
956
+ // The twin: granted again, the edge is merely behind.
957
+ expect(await healthOf(t, c)).toMatchObject({ state: 'behind' });
958
+ await fx.consumer.admin.revokeFromPeer(staff, {
959
+ vertical: CRM_VERTICAL,
960
+ node: { tenantId: t, scopeId: c },
961
+ reason: 'board stops taking CRM changes',
962
+ });
963
+ expect(await healthOf(t, c)).toMatchObject({
964
+ state: 'paused',
965
+ reason: expect.stringContaining(`has '${CRM_VERTICAL}' switched off`),
966
+ });
967
+ // Paused at the door, the backlog is still dated.
968
+ expect((await healthOf(t, c))?.oldestPending).not.toBeNull();
969
+ });
970
+ it('edge health: a producer missing from the tenant is unresolved, and a side that cannot be asked is unavailable, never healthy', async () => {
971
+ const t = await newTenant();
972
+ const c = await install(t, BOARD_VERTICAL);
973
+ expect(await healthOf(t, c)).toMatchObject({
974
+ state: 'unresolved',
975
+ reason: `'${CRM_VERTICAL}' is not installed in this tenant`,
976
+ });
977
+ const u = await newTenant();
978
+ const pu = await install(u, CRM_VERTICAL);
979
+ const cu = await install(u, BOARD_VERTICAL);
980
+ await create(u, pu, 'Unknown');
981
+ // The consumer cannot be asked.
982
+ const noConsumer = await healthOf(u, cu, {
983
+ importState: async () => {
984
+ throw new Error('the deployment serving this scope predates cross-vertical events — redeploy it');
985
+ },
986
+ });
987
+ expect(noConsumer).toMatchObject({ state: 'unavailable', producer: { vertical: '*' }, reason: expect.stringMatching(/redeploy/) });
988
+ // The producer cannot be asked.
989
+ const noProducer = await healthOf(u, cu, {
990
+ readExports: async () => {
991
+ throw new Error('vertical unreachable');
992
+ },
993
+ });
994
+ expect(noProducer).toMatchObject({ state: 'unavailable', producer: { vertical: CRM_VERTICAL }, reason: expect.stringMatching(/unreachable/) });
995
+ // The twin: asked, it answers.
996
+ expect(await healthOf(u, cu)).toMatchObject({ state: 'behind' });
997
+ });
998
+ it('edge health focused on one app shows its edges into it and out of it, and nothing else', async () => {
999
+ const t = await newTenant();
1000
+ const p = await install(t, CRM_VERTICAL);
1001
+ const c = await install(t, BOARD_VERTICAL);
1002
+ const focused = (focus) => crossVerticalHealth(fx.consumer, {
1003
+ actor: staff,
1004
+ tenantId: t,
1005
+ focus,
1006
+ crossVertical: { reach },
1007
+ ...(fx.door ? { door: fx.door } : {}),
1008
+ });
1009
+ const all = await health(t);
1010
+ // crm imports from board and board from crm: two edges in the tenant.
1011
+ expect(all.edges).toHaveLength(2);
1012
+ // On the board app, both touch it: board ← crm (into), crm ← board (out of).
1013
+ const onBoard = await focused(c);
1014
+ expect(onBoard.edges.map((e) => `${e.consumer.scopeId}:${e.producer.vertical}`).sort()).toEqual([`${c}:${CRM_VERTICAL}`, `${p}:${BOARD_VERTICAL}`].sort());
1015
+ // A scope that is no install at all has no edges, and asks nothing.
1016
+ expect((await focused(scopeId.parse(ulid()))).edges).toEqual([]);
1017
+ // A consumer that cannot be asked stays on its PRODUCER's view, as a failure there: tagged
1018
+ // with the producer, since it names none itself, so a per-app filter keeps it.
1019
+ const unreachable = await crossVerticalHealth(fx.consumer, {
1020
+ actor: staff,
1021
+ tenantId: t,
1022
+ focus: p,
1023
+ crossVertical: {
1024
+ reach: {
1025
+ ...reach,
1026
+ importState: (tt, s) => s === c ? Promise.reject(new Error('the board deployment is down')) : reach.importState(tt, s),
1027
+ },
1028
+ },
1029
+ ...(fx.door ? { door: fx.door } : {}),
1030
+ });
1031
+ const out = unreachable.edges.find((e) => e.consumer.scopeId === c);
1032
+ expect(out).toMatchObject({ state: 'unavailable', producer: { vertical: CRM_VERTICAL, scopeId: p } });
1033
+ expect(out?.reason).toMatch(/board deployment is down/);
1034
+ // Unfocused, the same failure names no producer: the tenant view shows it under '*'.
1035
+ const tenantWide = await health(t, {
1036
+ importState: (tt, s) => (s === c ? Promise.reject(new Error('down')) : reach.importState(tt, s)),
1037
+ });
1038
+ expect(tenantWide.edges.find((e) => e.consumer.scopeId === c)).toMatchObject({ producer: { vertical: '*', scopeId: null } });
1039
+ });
1040
+ it('edge health reads each edge\'s history on its own: a noisy edge cannot push out a quiet one\'s last delivery', async () => {
1041
+ const t = await newTenant();
1042
+ const p = await install(t, CRM_VERTICAL);
1043
+ const c = await install(t, BOARD_VERTICAL);
1044
+ const quiet = `${c}:${CRM_VERTICAL}`;
1045
+ const noisy = `${p}:${BOARD_VERTICAL}`;
1046
+ const row = (unit, outcome, scope) => ({
1047
+ kind: 'vertical-events',
1048
+ unit,
1049
+ outcome,
1050
+ tenantId: t,
1051
+ scopeId: scope,
1052
+ operation: 'sweep.vertical-events:test',
1053
+ error: outcome === 'ok' ? null : 'the producer is unreachable',
1054
+ });
1055
+ await fx.consumer.admin.recordSweepRun(row(quiet, 'ok', c));
1056
+ // Far more than any one tenant-wide window: the quiet edge's row is the oldest by far.
1057
+ for (let i = 0; i < 250; i++)
1058
+ await fx.consumer.admin.recordSweepRun(row(noisy, 'failed', p));
1059
+ const view = await health(t);
1060
+ expect(view.history.available).toBe(true);
1061
+ expect(view.edges.find((e) => e.consumer.scopeId === c)?.lastDelivered).not.toBeNull();
1062
+ expect(view.edges.find((e) => e.consumer.scopeId === p)).toMatchObject({
1063
+ lastDelivered: null,
1064
+ lastProblem: { outcome: 'failed', error: 'the producer is unreachable' },
1065
+ });
1066
+ });
1067
+ it('edge health never crosses a tenant: one tenant\'s view names only its own edges', async () => {
1068
+ const t = await newTenant();
1069
+ const u = await newTenant();
1070
+ await install(t, CRM_VERTICAL);
1071
+ const ct = await install(t, BOARD_VERTICAL);
1072
+ await install(u, CRM_VERTICAL);
1073
+ const cu = await install(u, BOARD_VERTICAL);
1074
+ const view = await health(t);
1075
+ expect(view.edges.some((e) => e.consumer.scopeId === ct)).toBe(true);
1076
+ expect(view.edges.every((e) => e.tenantId === t)).toBe(true);
1077
+ expect(view.edges.some((e) => e.consumer.scopeId === cu)).toBe(false);
1078
+ });
1079
+ // -- the promote refusal (#1705 PR 3) ------------------------------------------------------
1080
+ it('a promote that drops or re-versions an export an installed app imports is refused, and acknowledged it passes', async () => {
1081
+ // Fresh slugs: a channel is per vertical and fleet-wide, so this test must own its pair.
1082
+ const tag = ulid().slice(-8).toLowerCase();
1083
+ const producer = `acme/ex-${tag}`;
1084
+ const consumer = `acme/in-${tag}`;
1085
+ const TYPE = 'crm.customer-created';
1086
+ for (const slug of [producer, consumer]) {
1087
+ await fx.consumer.admin.registerVertical(staff, { slug, name: slug, source: 'cli' });
1088
+ }
1089
+ const registry = (extra) => JSON.stringify({ registry: { permissions: [], roles: [], entityGrants: [], ...extra } });
1090
+ const exporting = (v) => registry(v === null ? {} : { exports: [{ type: TYPE, schemaVersion: v, readPermission: 'customer:read', declaredBy: ['@test/x'] }] });
1091
+ const publish = async (slug, manifestJson, perm = 'p') => {
1092
+ const id = ulid();
1093
+ await fx.consumer.admin.publishVersion(staff, {
1094
+ id,
1095
+ verticalSlug: slug,
1096
+ version: `1.0.${id.slice(-4).toLowerCase()}`,
1097
+ manifestDigest: `m-${id}`,
1098
+ permissionDigest: perm,
1099
+ migrationDigest: 'g',
1100
+ deploymentRef: null,
1101
+ manifestJson,
1102
+ });
1103
+ await fx.consumer.admin.admitVersion(staff, id);
1104
+ return id;
1105
+ };
1106
+ const prodOf = async (slug) => (await fx.consumer.admin.listChannels(staff, slug)).find((ch) => ch.channel === 'prod')?.versionId;
1107
+ // The producer exports TYPE v1. A tenant runs it beside a consumer whose version imports it.
1108
+ const v1 = await publish(producer, exporting(1));
1109
+ await fx.consumer.admin.promoteVersion(staff, producer, 'prod', v1);
1110
+ const imports = registry({ imports: [{ from: producer, type: TYPE, schemaVersion: 1, declaredBy: ['@test/y'] }] });
1111
+ const consumerVersion = await publish(consumer, imports);
1112
+ const t = await newTenant();
1113
+ const u = await newTenant();
1114
+ const bindAt = async (tenant, slug, version) => {
1115
+ const sc = scopeId.parse(ulid());
1116
+ await fx.consumer.provisionScope(staff, { tenantId: tenant, scopeId: sc, vertical: slug });
1117
+ await fx.consumer.admin.activateScope(staff, tenant, sc);
1118
+ if (version)
1119
+ await fx.consumer.admin.bindScopeVersion(staff, tenant, sc, version);
1120
+ return sc;
1121
+ };
1122
+ await bindAt(t, producer, v1);
1123
+ const ct = await bindAt(t, consumer, consumerVersion);
1124
+ // A tenant that runs the consumer but not the producer has no edge, so nothing of it breaks.
1125
+ await bindAt(u, consumer, consumerVersion);
1126
+ // The twin first: a version that keeps the export promotes with no new acknowledgement.
1127
+ const same = await publish(producer, exporting(1));
1128
+ await expect(fx.consumer.admin.promotionImpact(staff, producer, 'prod', same)).resolves.toEqual([]);
1129
+ await fx.consumer.admin.promoteVersion(staff, producer, 'prod', same);
1130
+ // Dropped: refused, the channel unmoved, and the refusal counts without naming a tenant.
1131
+ const dropped = await publish(producer, exporting(null));
1132
+ const refused = await refusal(fx.consumer.admin.promoteVersion(staff, producer, 'prod', dropped));
1133
+ expect(String(refused)).toMatch(/drops or re-versions 1 exported event type\(s\) that 1 installed app\(s\) in 1 tenant\(s\)/);
1134
+ expect(String(refused)).not.toContain(t);
1135
+ expect(await prodOf(producer)).toBe(same);
1136
+ // The listing is the read beside it, and names exactly the app in the producer's tenant.
1137
+ expect(await fx.consumer.admin.promotionImpact(staff, producer, 'prod', dropped)).toEqual([
1138
+ { tenantId: t, scopeId: ct, vertical: consumer, version: consumerVersion, type: TYPE, schemaVersion: 1, incoming: null },
1139
+ ]);
1140
+ // Re-versioned is a break too, and says what it became.
1141
+ const bumped = await publish(producer, exporting(2));
1142
+ expect((await fx.consumer.admin.promotionImpact(staff, producer, 'prod', bumped))[0]).toMatchObject({ incoming: 2 });
1143
+ // Acknowledged, it promotes, and the admin log records the acknowledgement.
1144
+ await fx.consumer.admin.promoteVersion(staff, producer, 'prod', dropped, { exportBreak: true });
1145
+ expect(await prodOf(producer)).toBe(dropped);
1146
+ const log = await fx.consumer.admin.auditLog(staff, { action: 'promoteVersion' });
1147
+ expect(log.some((e) => JSON.stringify(e.after).includes('"exportBreak":true'))).toBe(true);
1148
+ });
562
1149
  it('a fork is neither read nor fed, and two primary installs are refused rather than guessed between', async () => {
563
1150
  const t = await newTenant();
564
1151
  const p = await install(t, CRM_VERTICAL);