@substrat-run/contract-tests 0.120.0 → 0.122.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/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/list-suite.d.ts.map +1 -1
- package/dist/list-suite.js +13 -0
- package/dist/list-suite.js.map +1 -1
- package/dist/modules.d.ts.map +1 -1
- package/dist/modules.js +10 -0
- package/dist/modules.js.map +1 -1
- package/dist/scope-host-suite.d.ts.map +1 -1
- package/dist/scope-host-suite.js +101 -0
- package/dist/scope-host-suite.js.map +1 -1
- package/dist/sql-limits-suite.d.ts +3 -0
- package/dist/sql-limits-suite.d.ts.map +1 -0
- package/dist/sql-limits-suite.js +149 -0
- package/dist/sql-limits-suite.js.map +1 -0
- package/dist/system-switch-suite.d.ts.map +1 -1
- package/dist/system-switch-suite.js +148 -6
- package/dist/system-switch-suite.js.map +1 -1
- package/dist/vertical-events-suite.d.ts +33 -2
- package/dist/vertical-events-suite.d.ts.map +1 -1
- package/dist/vertical-events-suite.js +793 -4
- package/dist/vertical-events-suite.js.map +1 -1
- package/package.json +3 -3
|
@@ -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
|
|
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,666 @@ 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
|
+
});
|
|
1149
|
+
// #1756's fixtures. The registry decides what a version exports and imports, so each version
|
|
1150
|
+
// is a manifest published and admitted, and each app a scope provisioned, activated and bound.
|
|
1151
|
+
const BIND_TYPE = 'crm.customer-created';
|
|
1152
|
+
const registryJson = (extra) => JSON.stringify({ registry: { permissions: [], roles: [], entityGrants: [], ...extra } });
|
|
1153
|
+
const exportingJson = (v) => registryJson(v === null ? {} : { exports: [{ type: BIND_TYPE, schemaVersion: v, readPermission: 'customer:read', declaredBy: ['@test/x'] }] });
|
|
1154
|
+
const importingJson = (from) => registryJson({ imports: [{ from, type: BIND_TYPE, schemaVersion: 1, declaredBy: ['@test/y'] }] });
|
|
1155
|
+
const publishManifest = async (slug, manifestJson, migrationDigest = 'g') => {
|
|
1156
|
+
const id = ulid();
|
|
1157
|
+
await fx.consumer.admin.publishVersion(staff, {
|
|
1158
|
+
id, verticalSlug: slug, version: `1.0.${id.slice(-4).toLowerCase()}`, manifestDigest: `m-${id}`,
|
|
1159
|
+
permissionDigest: 'p', migrationDigest, deploymentRef: null, manifestJson,
|
|
1160
|
+
});
|
|
1161
|
+
await fx.consumer.admin.admitVersion(staff, id);
|
|
1162
|
+
return id;
|
|
1163
|
+
};
|
|
1164
|
+
const bindAt = async (tenant, slug, version, extra = {}) => {
|
|
1165
|
+
const sc = scopeId.parse(ulid());
|
|
1166
|
+
await fx.consumer.provisionScope(staff, { tenantId: tenant, scopeId: sc, vertical: slug, ...extra });
|
|
1167
|
+
await fx.consumer.admin.activateScope(staff, tenant, sc);
|
|
1168
|
+
if (version)
|
|
1169
|
+
await fx.consumer.admin.bindScopeVersion(staff, tenant, sc, version);
|
|
1170
|
+
return sc;
|
|
1171
|
+
};
|
|
1172
|
+
const boundTo = async (tenant, sc) => (await fx.consumer.admin.getScopeRecord(staff, tenant, sc))?.verticalVersionId;
|
|
1173
|
+
it('a bind that drops or re-versions an export an app in its tenant imports is refused, and acknowledged it binds (#1756)', async () => {
|
|
1174
|
+
const tag = ulid().slice(-8).toLowerCase();
|
|
1175
|
+
const producer = `acme/bx-${tag}`;
|
|
1176
|
+
const consumer = `acme/bi-${tag}`;
|
|
1177
|
+
for (const slug of [producer, consumer]) {
|
|
1178
|
+
await fx.consumer.admin.registerVertical(staff, { slug, name: slug, source: 'cli' });
|
|
1179
|
+
}
|
|
1180
|
+
const v1 = await publishManifest(producer, exportingJson(1));
|
|
1181
|
+
const kept = await publishManifest(producer, exportingJson(1));
|
|
1182
|
+
// Crosses a migration too, so a bind asking for a snapshot would take one.
|
|
1183
|
+
const dropped = await publishManifest(producer, exportingJson(null), 'g2');
|
|
1184
|
+
const bumped = await publishManifest(producer, exportingJson(2));
|
|
1185
|
+
const consumerVersion = await publishManifest(consumer, importingJson(producer));
|
|
1186
|
+
// Tenant t runs the producer at v1 beside an app that imports BIND_TYPE v1 from it. Tenant u
|
|
1187
|
+
// runs the same pair, so a break in t that named u's app would show here.
|
|
1188
|
+
const t = await newTenant();
|
|
1189
|
+
const u = await newTenant();
|
|
1190
|
+
const pt = await bindAt(t, producer, v1);
|
|
1191
|
+
const ct = await bindAt(t, consumer, consumerVersion);
|
|
1192
|
+
await bindAt(u, producer, v1);
|
|
1193
|
+
await bindAt(u, consumer, consumerVersion);
|
|
1194
|
+
// The twin first: a version that keeps the export binds with no acknowledgement.
|
|
1195
|
+
await expect(fx.consumer.admin.bindingImpact(staff, t, pt, kept)).resolves.toEqual([]);
|
|
1196
|
+
await fx.consumer.admin.bindScopeVersion(staff, t, pt, kept);
|
|
1197
|
+
expect(await boundTo(t, pt)).toBe(kept);
|
|
1198
|
+
// Dropped: refused, the pointer unmoved, and the listing names t's app and nothing of u's.
|
|
1199
|
+
const refused = await refusal(fx.consumer.admin.bindScopeVersion(staff, t, pt, dropped));
|
|
1200
|
+
expect(String(refused)).toMatch(/this bind drops or re-versions 1 exported event type\(s\) that 1 installed app\(s\) in this tenant/);
|
|
1201
|
+
expect(await boundTo(t, pt)).toBe(kept);
|
|
1202
|
+
expect(await fx.consumer.admin.bindingImpact(staff, t, pt, dropped)).toEqual([
|
|
1203
|
+
{ tenantId: t, scopeId: ct, vertical: consumer, version: consumerVersion, type: BIND_TYPE, schemaVersion: 1, incoming: null },
|
|
1204
|
+
]);
|
|
1205
|
+
// Refused with a snapshot asked for too, on a bind that crosses a migration: the refusal
|
|
1206
|
+
// comes first, so no archive is taken.
|
|
1207
|
+
const before = (await fx.consumer.admin.listScopes(staff, { tenantId: t })).length;
|
|
1208
|
+
await refusal(fx.consumer.admin.bindScopeVersion(staff, t, pt, dropped, { snapshot: true }));
|
|
1209
|
+
expect((await fx.consumer.admin.listScopes(staff, { tenantId: t })).length).toBe(before);
|
|
1210
|
+
// Re-versioned is a break too, and says what it became.
|
|
1211
|
+
expect((await fx.consumer.admin.bindingImpact(staff, t, pt, bumped))[0]).toMatchObject({ scopeId: ct, incoming: 2 });
|
|
1212
|
+
// A tenant whose producer has no app importing from it binds the same version unrefused.
|
|
1213
|
+
const lone = await newTenant();
|
|
1214
|
+
const pl = await bindAt(lone, producer, v1);
|
|
1215
|
+
await fx.consumer.admin.bindScopeVersion(staff, lone, pl, dropped);
|
|
1216
|
+
expect(await boundTo(lone, pl)).toBe(dropped);
|
|
1217
|
+
// A first bind runs nothing before it, so it promised nothing.
|
|
1218
|
+
const fresh = await bindAt(t, producer);
|
|
1219
|
+
await fx.consumer.admin.bindScopeVersion(staff, t, fresh, dropped);
|
|
1220
|
+
expect(await boundTo(t, fresh)).toBe(dropped);
|
|
1221
|
+
// A fork and a preview are never an edge's producer, so nothing they run can break one.
|
|
1222
|
+
for (const extra of [
|
|
1223
|
+
{ kind: 'archive', forkedFrom: pt, forkedAt: new Date().toISOString() },
|
|
1224
|
+
{ kind: 'preview' },
|
|
1225
|
+
]) {
|
|
1226
|
+
const copy = await bindAt(t, producer, kept, extra);
|
|
1227
|
+
await fx.consumer.admin.bindScopeVersion(staff, t, copy, dropped);
|
|
1228
|
+
expect(await boundTo(t, copy)).toBe(dropped);
|
|
1229
|
+
}
|
|
1230
|
+
// Acknowledged, it binds, and the admin log records the acknowledgement. The snapshot the
|
|
1231
|
+
// refused binds above did not take is taken here, which is what made their count mean
|
|
1232
|
+
// something.
|
|
1233
|
+
const beforeAck = (await fx.consumer.admin.listScopes(staff, { tenantId: t })).length;
|
|
1234
|
+
await fx.consumer.admin.bindScopeVersion(staff, t, pt, dropped, { snapshot: true, acknowledge: { exportBreak: true } });
|
|
1235
|
+
expect(await boundTo(t, pt)).toBe(dropped);
|
|
1236
|
+
expect((await fx.consumer.admin.listScopes(staff, { tenantId: t })).length).toBe(beforeAck + 1);
|
|
1237
|
+
const log = await fx.consumer.admin.auditLog(staff, { action: 'bindScopeVersion' });
|
|
1238
|
+
expect(log.some((e) => e.scopeId === pt && JSON.stringify(e.after).includes('"exportBreak":true'))).toBe(true);
|
|
1239
|
+
});
|
|
1240
|
+
it('a bind is judged on what the scope RUNS: re-pointing a scope on its serving script changes nothing it exports (#1756)', async () => {
|
|
1241
|
+
const tag = ulid().slice(-8).toLowerCase();
|
|
1242
|
+
const producer = `acme/sx-${tag}`;
|
|
1243
|
+
const consumer = `acme/si-${tag}`;
|
|
1244
|
+
for (const slug of [producer, consumer]) {
|
|
1245
|
+
await fx.consumer.admin.registerVertical(staff, { slug, name: slug, source: 'cli' });
|
|
1246
|
+
}
|
|
1247
|
+
const v1 = await publishManifest(producer, exportingJson(1));
|
|
1248
|
+
const dropped = await publishManifest(producer, exportingJson(null));
|
|
1249
|
+
const consumerVersion = await publishManifest(consumer, importingJson(producer));
|
|
1250
|
+
const t = await newTenant();
|
|
1251
|
+
const pt = await bindAt(t, producer, v1);
|
|
1252
|
+
const ct = await bindAt(t, consumer, consumerVersion);
|
|
1253
|
+
// The vertical serves v1 in place, and the producer's scope runs that script. What it runs
|
|
1254
|
+
// is the serving version whatever its pointer says, so moving the pointer breaks nothing.
|
|
1255
|
+
// This is the shape of a tenant's Update of an install on the serving script, and of the
|
|
1256
|
+
// promote's own rebind of a private vertical's scopes.
|
|
1257
|
+
const ref = `serving-${tag}`;
|
|
1258
|
+
await fx.consumer.admin.setVerticalServing(staff, producer, { ref, versionId: v1, doClasses: [], migrationTag: 'g' });
|
|
1259
|
+
await fx.consumer.admin.setScopeServingRef(staff, t, pt, ref);
|
|
1260
|
+
await expect(fx.consumer.admin.bindingImpact(staff, t, pt, dropped)).resolves.toEqual([]);
|
|
1261
|
+
await fx.consumer.admin.bindScopeVersion(staff, t, pt, dropped);
|
|
1262
|
+
// Its twin: the same scope off the serving script runs its own pointer, so the same move is
|
|
1263
|
+
// a break — here from `dropped` back to v1's export and away again.
|
|
1264
|
+
await fx.consumer.admin.bindScopeVersion(staff, t, pt, v1);
|
|
1265
|
+
await fx.consumer.admin.setScopeServingRef(staff, t, pt, null);
|
|
1266
|
+
const refused = await refusal(fx.consumer.admin.bindScopeVersion(staff, t, pt, dropped));
|
|
1267
|
+
expect(String(refused)).toMatch(/this bind drops or re-versions/);
|
|
1268
|
+
expect(await boundTo(t, pt)).toBe(v1);
|
|
1269
|
+
expect((await fx.consumer.admin.bindingImpact(staff, t, pt, dropped)).map((b) => b.scopeId)).toEqual([ct]);
|
|
1270
|
+
});
|
|
1271
|
+
it('moving a scope onto its serving script is judged like a bind: it runs the served version from that moment (#1756)', async () => {
|
|
1272
|
+
const tag = ulid().slice(-8).toLowerCase();
|
|
1273
|
+
const producer = `acme/ax-${tag}`;
|
|
1274
|
+
const consumer = `acme/ai-${tag}`;
|
|
1275
|
+
for (const slug of [producer, consumer]) {
|
|
1276
|
+
await fx.consumer.admin.registerVertical(staff, { slug, name: slug, source: 'cli' });
|
|
1277
|
+
}
|
|
1278
|
+
const v1 = await publishManifest(producer, exportingJson(1));
|
|
1279
|
+
const kept = await publishManifest(producer, exportingJson(1));
|
|
1280
|
+
const dropped = await publishManifest(producer, exportingJson(null));
|
|
1281
|
+
const consumerVersion = await publishManifest(consumer, importingJson(producer));
|
|
1282
|
+
const t = await newTenant();
|
|
1283
|
+
const pt = await bindAt(t, producer, v1);
|
|
1284
|
+
const ct = await bindAt(t, consumer, consumerVersion);
|
|
1285
|
+
const routeOf = async (sc) => (await fx.consumer.admin.getScopeRecord(staff, t, sc))?.servingRef ?? null;
|
|
1286
|
+
// The vertical serves a version that drops the export. Routing the scope there is the
|
|
1287
|
+
// break, before its pointer moves at all: this is what an adopt does first.
|
|
1288
|
+
const ref = `serving-${tag}`;
|
|
1289
|
+
await fx.consumer.admin.setVerticalServing(staff, producer, { ref, versionId: dropped, doClasses: [], migrationTag: 'g' });
|
|
1290
|
+
const refused = await refusal(fx.consumer.admin.setScopeServingRef(staff, t, pt, ref));
|
|
1291
|
+
expect(String(refused)).toMatch(/this bind drops or re-versions 1 exported event type/);
|
|
1292
|
+
expect(await routeOf(pt)).toBeNull();
|
|
1293
|
+
// The listing asks the same move: bound where it is, routed onto the serving script.
|
|
1294
|
+
expect(await fx.consumer.admin.bindingImpact(staff, t, pt, v1, { servingRef: ref })).toMatchObject([
|
|
1295
|
+
{ scopeId: ct, incoming: null },
|
|
1296
|
+
]);
|
|
1297
|
+
// A scope that has never been active has never delivered: provisioning, it is not judged.
|
|
1298
|
+
const provisioning = scopeId.parse(ulid());
|
|
1299
|
+
await fx.consumer.provisionScope(staff, { tenantId: t, scopeId: provisioning, vertical: producer });
|
|
1300
|
+
await fx.consumer.admin.bindScopeVersion(staff, t, provisioning, v1);
|
|
1301
|
+
// Born on the serving script (a scope provisioned while its vertical serves in place
|
|
1302
|
+
// inherits the ref), so the route is cleared first: the move below is a real one.
|
|
1303
|
+
await fx.consumer.admin.setScopeServingRef(staff, t, provisioning, null);
|
|
1304
|
+
expect((await fx.consumer.admin.getScopeRecord(staff, t, provisioning))?.status).toBe('provisioning');
|
|
1305
|
+
await fx.consumer.admin.setScopeServingRef(staff, t, provisioning, ref);
|
|
1306
|
+
expect(await routeOf(provisioning)).toBe(ref);
|
|
1307
|
+
// Acknowledged, it moves, and the admin log says so. Binding it to the served version
|
|
1308
|
+
// after that changes nothing it runs.
|
|
1309
|
+
await fx.consumer.admin.setScopeServingRef(staff, t, pt, ref, { acknowledge: { exportBreak: true } });
|
|
1310
|
+
expect(await routeOf(pt)).toBe(ref);
|
|
1311
|
+
await fx.consumer.admin.bindScopeVersion(staff, t, pt, dropped);
|
|
1312
|
+
const log = await fx.consumer.admin.auditLog(staff, { action: 'setScopeServingRef' });
|
|
1313
|
+
expect(log.some((e) => e.scopeId === pt && JSON.stringify(e.after).includes('"exportBreak":true'))).toBe(true);
|
|
1314
|
+
// The twin: a vertical serving a version that keeps the export takes the scope unasked.
|
|
1315
|
+
const other = await newTenant();
|
|
1316
|
+
const po = await bindAt(other, producer, v1);
|
|
1317
|
+
await bindAt(other, consumer, consumerVersion);
|
|
1318
|
+
await fx.consumer.admin.setVerticalServing(staff, producer, { ref, versionId: kept, doClasses: [], migrationTag: 'g' });
|
|
1319
|
+
await expect(fx.consumer.admin.bindingImpact(staff, other, po, v1, { servingRef: ref })).resolves.toEqual([]);
|
|
1320
|
+
await fx.consumer.admin.setScopeServingRef(staff, other, po, ref);
|
|
1321
|
+
expect((await fx.consumer.admin.getScopeRecord(staff, other, po))?.servingRef).toBe(ref);
|
|
1322
|
+
// A route onto a script that is NOT this vertical's serving script is a crossing: another
|
|
1323
|
+
// vertical's code, which this gate does not judge (a known gap). It is never measured as a
|
|
1324
|
+
// move of this vertical's — which, for a scope on its serving script whose pointer says
|
|
1325
|
+
// something else, would read a phantom break between the served version and the pointer.
|
|
1326
|
+
await fx.consumer.admin.bindScopeVersion(staff, other, po, dropped); // on the script: runs `kept` still
|
|
1327
|
+
await fx.consumer.admin.setScopeServingRef(staff, other, po, `elsewhere-${tag}`);
|
|
1328
|
+
expect((await fx.consumer.admin.getScopeRecord(staff, other, po))?.servingRef).toBe(`elsewhere-${tag}`);
|
|
1329
|
+
});
|
|
1330
|
+
it("a private vertical's promote rebinds its owned scopes past the bind gate: the promote already judged the break (#1756)", async () => {
|
|
1331
|
+
const tag = ulid().slice(-8).toLowerCase();
|
|
1332
|
+
const producer = `acme/px-${tag}`;
|
|
1333
|
+
const consumer = `acme/pi-${tag}`;
|
|
1334
|
+
const t = await newTenant();
|
|
1335
|
+
// PRIVATE: owned by t and unlisted, so a prod promote re-points t's scopes in the same act.
|
|
1336
|
+
await fx.consumer.admin.registerVertical(staff, { slug: producer, name: producer, source: 'cli', ownerTenant: t });
|
|
1337
|
+
await fx.consumer.admin.registerVertical(staff, { slug: consumer, name: consumer, source: 'cli' });
|
|
1338
|
+
const v1 = await publishManifest(producer, exportingJson(1));
|
|
1339
|
+
const dropped = await publishManifest(producer, exportingJson(null));
|
|
1340
|
+
const consumerVersion = await publishManifest(consumer, importingJson(producer));
|
|
1341
|
+
await fx.consumer.admin.promoteVersion(staff, producer, 'prod', v1);
|
|
1342
|
+
const pt = await bindAt(t, producer, v1);
|
|
1343
|
+
await bindAt(t, consumer, consumerVersion);
|
|
1344
|
+
// The same move by bind alone is refused, which is what makes the next line mean something.
|
|
1345
|
+
expect(String(await refusal(fx.consumer.admin.bindScopeVersion(staff, t, pt, dropped)))).toMatch(/this bind drops/);
|
|
1346
|
+
// The promote is refused on the same break, and acknowledged it moves the owned scope too.
|
|
1347
|
+
expect(String(await refusal(fx.consumer.admin.promoteVersion(staff, producer, 'prod', dropped)))).toMatch(/promotion drops/);
|
|
1348
|
+
await fx.consumer.admin.promoteVersion(staff, producer, 'prod', dropped, { exportBreak: true });
|
|
1349
|
+
expect(await boundTo(t, pt)).toBe(dropped);
|
|
1350
|
+
});
|
|
562
1351
|
it('a fork is neither read nor fed, and two primary installs are refused rather than guessed between', async () => {
|
|
563
1352
|
const t = await newTenant();
|
|
564
1353
|
const p = await install(t, CRM_VERTICAL);
|