@substrat-run/contract-tests 0.141.0 → 0.143.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/become-mint-suite.d.ts +12 -0
- package/dist/become-mint-suite.d.ts.map +1 -0
- package/dist/become-mint-suite.js +457 -0
- package/dist/become-mint-suite.js.map +1 -0
- package/dist/caused-by-module.d.ts.map +1 -1
- package/dist/caused-by-module.js +3 -2
- package/dist/caused-by-module.js.map +1 -1
- package/dist/derived-module.d.ts +350 -0
- package/dist/derived-module.d.ts.map +1 -0
- package/dist/derived-module.js +153 -0
- package/dist/derived-module.js.map +1 -0
- package/dist/derived-suite.d.ts +3 -0
- package/dist/derived-suite.d.ts.map +1 -0
- package/dist/derived-suite.js +225 -0
- package/dist/derived-suite.js.map +1 -0
- package/dist/directory-restore-suite.js +1 -1
- package/dist/directory-restore-suite.js.map +1 -1
- package/dist/entity-state-module.d.ts.map +1 -1
- package/dist/entity-state-module.js +12 -6
- package/dist/entity-state-module.js.map +1 -1
- package/dist/entity-trash-module.d.ts.map +1 -1
- package/dist/entity-trash-module.js +4 -4
- package/dist/entity-trash-module.js.map +1 -1
- package/dist/entity-trash-suite.d.ts.map +1 -1
- package/dist/entity-trash-suite.js +17 -8
- package/dist/entity-trash-suite.js.map +1 -1
- package/dist/erasure-module.d.ts.map +1 -1
- package/dist/erasure-module.js +3 -2
- package/dist/erasure-module.js.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/input-parse-suite.d.ts.map +1 -1
- package/dist/input-parse-suite.js +33 -2
- package/dist/input-parse-suite.js.map +1 -1
- package/dist/membership-module.d.ts.map +1 -1
- package/dist/membership-module.js +3 -2
- package/dist/membership-module.js.map +1 -1
- package/dist/migration-comments.d.ts.map +1 -1
- package/dist/migration-comments.js +3 -2
- package/dist/migration-comments.js.map +1 -1
- package/dist/modules.d.ts.map +1 -1
- package/dist/modules.js +74 -69
- package/dist/modules.js.map +1 -1
- package/dist/permission-suite.d.ts.map +1 -1
- package/dist/permission-suite.js +30 -2
- package/dist/permission-suite.js.map +1 -1
- package/dist/scope-host-suite.d.ts.map +1 -1
- package/dist/scope-host-suite.js +485 -32
- package/dist/scope-host-suite.js.map +1 -1
- package/dist/shape-reconcile-plan.d.ts +23 -0
- package/dist/shape-reconcile-plan.d.ts.map +1 -0
- package/dist/shape-reconcile-plan.js +87 -0
- package/dist/shape-reconcile-plan.js.map +1 -0
- package/dist/test-operations.d.ts +25 -0
- package/dist/test-operations.d.ts.map +1 -0
- package/dist/test-operations.js +22 -0
- package/dist/test-operations.js.map +1 -0
- package/dist/vertical-events-suite.d.ts.map +1 -1
- package/dist/vertical-events-suite.js +72 -7
- package/dist/vertical-events-suite.js.map +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type SwitchSql } from '@substrat-run/kernel';
|
|
2
|
+
interface ShapeReconcilePlanReport {
|
|
3
|
+
/** The plan of each marker walk the passes sent (one per retire and top-up walk). */
|
|
4
|
+
walks: string[];
|
|
5
|
+
/** The plan of each grantee backfill read. */
|
|
6
|
+
backfills: string[];
|
|
7
|
+
/** Every planned table scan of `_substrat_tuples`, with the statement that planned it. */
|
|
8
|
+
scans: string[];
|
|
9
|
+
/** Passes the reconcile took, and whether every holder ended up with the whole shape. */
|
|
10
|
+
passes: number;
|
|
11
|
+
complete: boolean;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Seed one shape's world on `raw` — holders of the old shape, legacy grantees with no marker, a
|
|
15
|
+
* retired key, and another type's markers beside them — then reconcile it in small passes, each
|
|
16
|
+
* resuming from the last, planning every statement. Run it on a scope nothing else is writing.
|
|
17
|
+
*/
|
|
18
|
+
export declare function shapeReconcilePlans(raw: SwitchSql, ids: {
|
|
19
|
+
tenantId: string;
|
|
20
|
+
scopeId: string;
|
|
21
|
+
}): ShapeReconcilePlanReport;
|
|
22
|
+
export {};
|
|
23
|
+
//# sourceMappingURL=shape-reconcile-plan.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"shape-reconcile-plan.d.ts","sourceRoot":"","sources":["../src/shape-reconcile-plan.ts"],"names":[],"mappings":"AAeA,OAAO,EAAkD,KAAK,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAItG,UAAU,wBAAwB;IAChC,qFAAqF;IACrF,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,8CAA8C;IAC9C,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,0FAA0F;IAC1F,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,yFAAyF;IACzF,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,OAAO,CAAC;CACnB;AAOD;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,SAAS,EAAE,GAAG,EAAE;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GAAG,wBAAwB,CA2DxH"}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shape reconcile's reads, planned by the SQLite each adapter actually runs (#2083).
|
|
3
|
+
*
|
|
4
|
+
* A pass walks a shape's markers on `_substrat_tuples_shape_marker`, a partial index SQLite uses
|
|
5
|
+
* only where it can prove the index's `relation = 'bootstrap'` from the query. A relation term it
|
|
6
|
+
* cannot prove that from, or an object prefix written as `substr`, plans a scan of the whole tuple
|
|
7
|
+
* table, which no functional test notices, because a test scope has a dozen rows. So the probe here drives the kernel's own
|
|
8
|
+
* pass over a scope's real schema and asks the engine to plan every statement the pass sends,
|
|
9
|
+
* exactly as sent. Pure, with no runner: each adapter hands it its scope's own SQLite as the
|
|
10
|
+
* kernel's `SwitchSql` (over a `better-sqlite3` file, a Durable Object's `storage.sql`) and
|
|
11
|
+
* asserts on the report.
|
|
12
|
+
*
|
|
13
|
+
* Harness code: it writes `_substrat_*` rows directly, which is exactly what `ctx.sql` refuses.
|
|
14
|
+
*/
|
|
15
|
+
import { entityGrantShape } from '@substrat-run/contracts';
|
|
16
|
+
import { topUpEntityGrantShapes, ulid } from '@substrat-run/kernel';
|
|
17
|
+
const MARKERS = 30;
|
|
18
|
+
const LEGACY = 5;
|
|
19
|
+
const KEYS = ['shape-plan:read', 'shape-plan:use'];
|
|
20
|
+
const RETIRED = 'shape-plan:old';
|
|
21
|
+
/**
|
|
22
|
+
* Seed one shape's world on `raw` — holders of the old shape, legacy grantees with no marker, a
|
|
23
|
+
* retired key, and another type's markers beside them — then reconcile it in small passes, each
|
|
24
|
+
* resuming from the last, planning every statement. Run it on a scope nothing else is writing.
|
|
25
|
+
*/
|
|
26
|
+
export function shapeReconcilePlans(raw, ids) {
|
|
27
|
+
const tuple = (subject, relation, object) => raw.run('INSERT OR REPLACE INTO _substrat_tuples (subject, relation, object) VALUES (?, ?, ?)', subject, relation, object);
|
|
28
|
+
const pad = (i) => String(i).padStart(3, '0');
|
|
29
|
+
for (let i = 0; i < MARKERS; i++) {
|
|
30
|
+
const [who, desk] = [`principal:${ulid()}`, `desk:d${pad(i)}`];
|
|
31
|
+
tuple(who, 'bootstrap', desk);
|
|
32
|
+
tuple(who, `granted:${KEYS[0]}`, desk);
|
|
33
|
+
if (i % 3 === 0)
|
|
34
|
+
tuple(who, `granted:${RETIRED}`, desk);
|
|
35
|
+
tuple(who, 'bootstrap', `room:d${pad(i)}`); // another type's marker: never read by this shape
|
|
36
|
+
}
|
|
37
|
+
for (let i = 0; i < LEGACY; i++)
|
|
38
|
+
tuple(`principal:${ulid()}`, `granted:${KEYS[0]}`, `desk:l${pad(i)}`);
|
|
39
|
+
const walks = [];
|
|
40
|
+
const backfills = [];
|
|
41
|
+
const scans = [];
|
|
42
|
+
const plan = (sql, params) => {
|
|
43
|
+
if (!/^\s*(SELECT|UPDATE|DELETE)/i.test(sql))
|
|
44
|
+
return;
|
|
45
|
+
const detail = raw.all(`EXPLAIN QUERY PLAN ${sql}`, ...params).map((r) => String(r['detail']));
|
|
46
|
+
const joined = detail.join(' | ');
|
|
47
|
+
if (sql.includes('(m.object, m.subject) > (?, ?)'))
|
|
48
|
+
walks.push(joined);
|
|
49
|
+
if (sql.includes('SELECT DISTINCT t.subject, t.object'))
|
|
50
|
+
backfills.push(joined);
|
|
51
|
+
// A virtual table (`json_each`) is scanned by design; a scan of the tuple table never is.
|
|
52
|
+
for (const d of detail)
|
|
53
|
+
if (/^SCAN /.test(d) && !/VIRTUAL TABLE/.test(d))
|
|
54
|
+
scans.push(`${d} — ${sql.replace(/\s+/g, ' ').slice(0, 160)}`);
|
|
55
|
+
};
|
|
56
|
+
const sql = {
|
|
57
|
+
all: (q, ...p) => {
|
|
58
|
+
plan(q, p);
|
|
59
|
+
return raw.all(q, ...p);
|
|
60
|
+
},
|
|
61
|
+
run: (q, ...p) => {
|
|
62
|
+
plan(q, p);
|
|
63
|
+
raw.run(q, ...p);
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
const shapes = [entityGrantShape.parse({ entityType: 'desk', permissions: KEYS, retired: [RETIRED], bootstrap: true, holder: 'grantee' })];
|
|
67
|
+
let after = null;
|
|
68
|
+
let passes = 0;
|
|
69
|
+
do {
|
|
70
|
+
after = topUpEntityGrantShapes(sql, {
|
|
71
|
+
...ids,
|
|
72
|
+
shapes,
|
|
73
|
+
now: new Date().toISOString(),
|
|
74
|
+
limit: 4,
|
|
75
|
+
after,
|
|
76
|
+
mintEventId: () => ulid(),
|
|
77
|
+
version: null,
|
|
78
|
+
}).next;
|
|
79
|
+
passes++;
|
|
80
|
+
} while (after && passes < 100);
|
|
81
|
+
const short = raw.all(`SELECT count(*) AS n FROM _substrat_tuples m
|
|
82
|
+
WHERE m.relation = 'bootstrap' AND m.object >= 'desk:' AND m.object < 'desk;'
|
|
83
|
+
AND NOT EXISTS (SELECT 1 FROM _substrat_tuples t WHERE t.subject = m.subject AND t.object = m.object
|
|
84
|
+
AND t.relation = 'granted:${KEYS[1]}' AND t.revoked_at IS NULL)`)[0]['n'];
|
|
85
|
+
return { walks, backfills, scans, passes, complete: after === null && Number(short) === 0 };
|
|
86
|
+
}
|
|
87
|
+
//# sourceMappingURL=shape-reconcile-plan.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"shape-reconcile-plan.js","sourceRoot":"","sources":["../src/shape-reconcile-plan.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type BoundOperations, type HandlerMap } from '@substrat-run/kernel';
|
|
2
|
+
/** The maps a registration derives from a declaration, as a fixture may hand them over raw. */
|
|
3
|
+
export interface RawDerivedMaps {
|
|
4
|
+
inputs?: BoundOperations['inputs'];
|
|
5
|
+
concurrency?: BoundOperations['concurrency'];
|
|
6
|
+
idempotencyOptOuts?: BoundOperations['idempotencyOptOuts'];
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* A fixture's operations, registered without binding them to a declaration (#1835) — the TEST
|
|
10
|
+
* SEAM around `operationsFor`, and the only one.
|
|
11
|
+
*
|
|
12
|
+
* The suites drive the host with modules built for one assertion each, so most have no declared
|
|
13
|
+
* surface: `testOperations(handlers)` is `undeclaredOperations` with the reason stated once.
|
|
14
|
+
*
|
|
15
|
+
* A few prove what the HOST refuses when the derived maps are wrong — a schema or a precondition
|
|
16
|
+
* naming an unbound operation, an `inputs` map that is not the one `operationInputsOf`
|
|
17
|
+
* returned, a forged trash surface — or need maps over handlers whose types they erase on
|
|
18
|
+
* purpose. Those pass `derived`, and get a `BoundOperations` no binder made, through the kernel's
|
|
19
|
+
* `./testing` subpath — the value a module cannot build, and boundary-lint R2 refuses that import,
|
|
20
|
+
* and this package's, from module code.
|
|
21
|
+
*/
|
|
22
|
+
export declare function testOperations(handlers: HandlerMap, derived?: RawDerivedMaps): {
|
|
23
|
+
operations: BoundOperations;
|
|
24
|
+
};
|
|
25
|
+
//# sourceMappingURL=test-operations.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"test-operations.d.ts","sourceRoot":"","sources":["../src/test-operations.ts"],"names":[],"mappings":"AAAA,OAAO,EAAwB,KAAK,eAAe,EAAE,KAAK,UAAU,EAAE,MAAM,sBAAsB,CAAC;AAGnG,+FAA+F;AAC/F,MAAM,WAAW,cAAc;IAC7B,MAAM,CAAC,EAAE,eAAe,CAAC,QAAQ,CAAC,CAAC;IACnC,WAAW,CAAC,EAAE,eAAe,CAAC,aAAa,CAAC,CAAC;IAC7C,kBAAkB,CAAC,EAAE,eAAe,CAAC,oBAAoB,CAAC,CAAC;CAC5D;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,cAAc,CAAC,QAAQ,EAAE,UAAU,EAAE,OAAO,CAAC,EAAE,cAAc,GAAG;IAAE,UAAU,EAAE,eAAe,CAAA;CAAE,CAG9G"}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { undeclaredOperations } from '@substrat-run/kernel';
|
|
2
|
+
import { forgeBoundOperationsForTest } from '@substrat-run/kernel/testing';
|
|
3
|
+
/**
|
|
4
|
+
* A fixture's operations, registered without binding them to a declaration (#1835) — the TEST
|
|
5
|
+
* SEAM around `operationsFor`, and the only one.
|
|
6
|
+
*
|
|
7
|
+
* The suites drive the host with modules built for one assertion each, so most have no declared
|
|
8
|
+
* surface: `testOperations(handlers)` is `undeclaredOperations` with the reason stated once.
|
|
9
|
+
*
|
|
10
|
+
* A few prove what the HOST refuses when the derived maps are wrong — a schema or a precondition
|
|
11
|
+
* naming an unbound operation, an `inputs` map that is not the one `operationInputsOf`
|
|
12
|
+
* returned, a forged trash surface — or need maps over handlers whose types they erase on
|
|
13
|
+
* purpose. Those pass `derived`, and get a `BoundOperations` no binder made, through the kernel's
|
|
14
|
+
* `./testing` subpath — the value a module cannot build, and boundary-lint R2 refuses that import,
|
|
15
|
+
* and this package's, from module code.
|
|
16
|
+
*/
|
|
17
|
+
export function testOperations(handlers, derived) {
|
|
18
|
+
if (!derived)
|
|
19
|
+
return undeclaredOperations('a contract-test fixture, built to drive the host rather than declared', handlers);
|
|
20
|
+
return { operations: forgeBoundOperationsForTest(handlers, derived) };
|
|
21
|
+
}
|
|
22
|
+
//# sourceMappingURL=test-operations.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"test-operations.js","sourceRoot":"","sources":["../src/test-operations.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,oBAAoB,EAAyC,MAAM,sBAAsB,CAAC;AACnG,OAAO,EAAE,2BAA2B,EAAE,MAAM,8BAA8B,CAAC;AAS3E;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,cAAc,CAAC,QAAoB,EAAE,OAAwB;IAC3E,IAAI,CAAC,OAAO;QAAE,OAAO,oBAAoB,CAAC,uEAAuE,EAAE,QAAQ,CAAC,CAAC;IAC7H,OAAO,EAAE,UAAU,EAAE,2BAA2B,CAAC,QAAQ,EAAE,OAAO,CAAC,EAAE,CAAC;AACxE,CAAC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"vertical-events-suite.d.ts","sourceRoot":"","sources":["../src/vertical-events-suite.ts"],"names":[],"mappings":"AACA,OAAO,EASL,CAAC,EAED,KAAK,gBAAgB,EACrB,KAAK,iBAAiB,EAGtB,KAAK,OAAO,EACZ,KAAK,QAAQ,EACd,MAAM,yBAAyB,CAAC;AACjC,OAAO,
|
|
1
|
+
{"version":3,"file":"vertical-events-suite.d.ts","sourceRoot":"","sources":["../src/vertical-events-suite.ts"],"names":[],"mappings":"AACA,OAAO,EASL,CAAC,EAED,KAAK,gBAAgB,EACrB,KAAK,iBAAiB,EAGtB,KAAK,OAAO,EACZ,KAAK,QAAQ,EACd,MAAM,yBAAyB,CAAC;AACjC,OAAO,EAOL,KAAK,kBAAkB,EAEvB,KAAK,kBAAkB,EAGvB,KAAK,SAAS,EAEf,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EAA4D,KAAK,aAAa,EAAE,MAAM,yBAAyB,CAAC;AAevH,eAAO,MAAM,YAAY,aAAa,CAAC;AACvC,eAAO,MAAM,cAAc,eAAe,CAAC;AAI3C,eAAO,MAAM,oBAAoB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAiC/B,CAAC;AAiFH,eAAO,MAAM,YAAY,EAAE,kBAuC1B,CAAC;AAEF,eAAO,MAAM,sBAAsB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA8BjC,CAAC;AAgDH,eAAO,MAAM,cAAc,EAAE,kBA0D5B,CAAC;AAIF;;;;;GAKG;AACH,MAAM,WAAW,qBAAqB;IACpC,QAAQ,EAAE,SAAS,CAAC;IACpB,QAAQ,EAAE,SAAS,CAAC;IACpB,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7B;;;;;;OAMG;IACH,SAAS,CAAC,EAAE,IAAI,CAAC,kBAAkB,EAAE,YAAY,CAAC,CAAC;IACnD;;;;OAIG;IACH,YAAY,CAAC,EAAE,CAAC,QAAQ,EAAE,QAAQ,EAAE,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACzF;;;;OAIG;IACH,KAAK,CAAC,EAAE,CAAC,QAAQ,EAAE,QAAQ,EAAE,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,gBAAgB,KAAK,OAAO,CAAC,iBAAiB,CAAC,CAAC;IACrG;;;OAGG;IACH,IAAI,CAAC,EAAE,CACL,QAAQ,EAAE,QAAQ,EAClB,OAAO,EAAE,OAAO,KACb,OAAO,CAAC,SAAS;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE;YAAE,MAAM,EAAE,MAAM,CAAA;SAAE,GAAG,IAAI,CAAA;KAAE,EAAE,CAAC,CAAC;IACtG;;;;OAIG;IACH,eAAe,CAAC,EAAE,aAAa,CAAC,iBAAiB,CAAC,CAAC;CACpD;AAID;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,2BAA2B,CACzC,WAAW,EAAE,MAAM,EACnB,WAAW,EAAE,MAAM,OAAO,CAAC,qBAAqB,CAAC,GAChD,IAAI,CA24CN"}
|
|
@@ -1,7 +1,8 @@
|
|
|
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, crossVerticalHealth, INERT_SCOPE_REASON, runPlatformSweep, ulid, } from '@substrat-run/kernel';
|
|
3
|
+
import { assertAllowed, crossVerticalHealth, INERT_SCOPE_REASON, REDACTED_DELIVERY_NOTE, runPlatformSweep, ulid, } from '@substrat-run/kernel';
|
|
4
4
|
import { expectAnswered, expectSettledUnknown, withRefusedOutcome } from './switch-audit-fault.js';
|
|
5
|
+
import { testOperations } from './test-operations.js';
|
|
5
6
|
// -- the two fixture verticals ------------------------------------------------
|
|
6
7
|
//
|
|
7
8
|
// Named as the verticals of one tenant would be: a CRM that owns customers, and a board-room
|
|
@@ -127,13 +128,13 @@ const crmFlag = async (ctx, input) => {
|
|
|
127
128
|
export const crmExportMod = {
|
|
128
129
|
manifest: crmExportModManifest,
|
|
129
130
|
migrations: [{ version: '0001-init', sql: 'CREATE TABLE crm_customers (id TEXT PRIMARY KEY, name TEXT NOT NULL)' }],
|
|
130
|
-
|
|
131
|
+
...testOperations({
|
|
131
132
|
'crm/create': crmCreate,
|
|
132
133
|
'crm/note': crmNote,
|
|
133
134
|
'crm/touch': crmTouch,
|
|
134
135
|
'crm/create-then-fail': crmCreateThenFail,
|
|
135
136
|
'crm/flag': crmFlag,
|
|
136
|
-
},
|
|
137
|
+
}),
|
|
137
138
|
consumers: {
|
|
138
139
|
// In the invoke's post-commit tail: an exported type committed by a consumer, not the operation.
|
|
139
140
|
'crm.customer-flagged': async (ctx, event) => {
|
|
@@ -176,8 +177,11 @@ export const boardImportModManifest = moduleManifest.parse({
|
|
|
176
177
|
emits: [
|
|
177
178
|
{ type: 'board.association-created', schemaVersion: 1 },
|
|
178
179
|
{ type: 'board.association-linked', schemaVersion: 1 },
|
|
180
|
+
// #1757: board's OWN classified event, whose local consumer fails quoting the subject.
|
|
181
|
+
{ type: 'board.member-noted', schemaVersion: 1 },
|
|
179
182
|
],
|
|
180
183
|
consumes: [
|
|
184
|
+
{ type: 'board.member-noted', schemaVersion: 1 },
|
|
181
185
|
{ from: CRM_VERTICAL, type: 'crm.customer-created', schemaVersion: 1 },
|
|
182
186
|
// crm emits this and does not export it. Declaring it must not be enough to receive it.
|
|
183
187
|
{ from: CRM_VERTICAL, type: 'crm.customer-noted', schemaVersion: 1 },
|
|
@@ -202,9 +206,22 @@ const boardRead = async (ctx) => {
|
|
|
202
206
|
imports: ctx.sql.query('SELECT event_id, source_vertical, source_scope_id, type, hops, withheld FROM _substrat_imports ORDER BY event_id'),
|
|
203
207
|
deliveries: ctx.sql.query('SELECT event_id, consumer_module, error FROM _substrat_deliveries ORDER BY event_id, consumer_module'),
|
|
204
208
|
outbox: ctx.sql.query('SELECT id, type, caused_by, actor FROM _substrat_outbox ORDER BY id'),
|
|
205
|
-
replays: ctx.sql.query(
|
|
209
|
+
replays: ctx.sql.query(`SELECT replay_id, kind, event_id, consumer_module, json_extract(row, '$.error') AS error
|
|
210
|
+
FROM _substrat_import_replays ORDER BY kind, event_id`),
|
|
206
211
|
};
|
|
207
212
|
};
|
|
213
|
+
/** #1757: emits board's own classified event about one subject. */
|
|
214
|
+
const boardNoteMember = async (ctx, input) => {
|
|
215
|
+
assertAllowed(await ctx.check(key('association:read')));
|
|
216
|
+
ctx.emit({
|
|
217
|
+
type: 'board.member-noted',
|
|
218
|
+
schemaVersion: 1,
|
|
219
|
+
entity: { entityType: 'member', entityId: input.subjectId },
|
|
220
|
+
piiClass: 'direct',
|
|
221
|
+
subjectId: dataSubjectId.parse(input.subjectId),
|
|
222
|
+
payload: { subjectId: input.subjectId },
|
|
223
|
+
});
|
|
224
|
+
};
|
|
208
225
|
export const boardImportMod = {
|
|
209
226
|
manifest: boardImportModManifest,
|
|
210
227
|
migrations: [
|
|
@@ -216,14 +233,24 @@ export const boardImportMod = {
|
|
|
216
233
|
`,
|
|
217
234
|
},
|
|
218
235
|
],
|
|
219
|
-
|
|
236
|
+
...testOperations({
|
|
237
|
+
'board/read': boardRead,
|
|
238
|
+
'board/note-member': boardNoteMember,
|
|
239
|
+
}),
|
|
240
|
+
consumers: {
|
|
241
|
+
// #1757: fails quoting the subject, so its delivery row holds the subject's id as text.
|
|
242
|
+
'board.member-noted': async (_ctx, event) => {
|
|
243
|
+
throw new Error(`board cannot note member ${event.subjectId}`);
|
|
244
|
+
},
|
|
245
|
+
},
|
|
220
246
|
imports: {
|
|
221
247
|
[CRM_VERTICAL]: {
|
|
222
248
|
'crm.customer-created': async (ctx, event) => {
|
|
223
249
|
assertAllowed(await ctx.check(key('association:sync')));
|
|
224
250
|
const c = customerCreated.parse(event.payload);
|
|
225
|
-
|
|
226
|
-
|
|
251
|
+
// The name is quoted, so a handler's own sentence can carry a subject's id (#1757).
|
|
252
|
+
if (c.name.startsWith('poison'))
|
|
253
|
+
throw new Error(`board refuses the poison customer ${c.name}`);
|
|
227
254
|
ctx.sql.exec(`INSERT INTO board_associations (crm_id, name) VALUES (?, ?)
|
|
228
255
|
ON CONFLICT (crm_id) DO UPDATE SET name = excluded.name`, [c.id, c.name]);
|
|
229
256
|
ctx.emit({
|
|
@@ -742,6 +769,44 @@ export function verticalEventsContractSuite(adapterName, makeFixture) {
|
|
|
742
769
|
expect(createdFor(first.event_id)).toBe(1);
|
|
743
770
|
expect(createdFor(second.event_id)).toBe(2);
|
|
744
771
|
});
|
|
772
|
+
// #1757: a replay keeps an import's delivery row in `_substrat_import_replays`, `error`
|
|
773
|
+
// included. On a released import that text is a handler's own sentence and can name a
|
|
774
|
+
// subject, but no erasure reaches it, live or moved aside: a released event is piiClass
|
|
775
|
+
// 'none', so no subject link names its delivery. (A withheld one, a classified one included,
|
|
776
|
+
// gets a dead letter whose error is the platform's own note, which names no one.) The control is
|
|
777
|
+
// the scope's OWN classified event, whose failed delivery the same erasure does rewrite.
|
|
778
|
+
// What is held is that the two copies of an import's delivery agree after an erasure, so a
|
|
779
|
+
// later erasure that reaches the live row cannot leave the replayed one behind.
|
|
780
|
+
it('an erasure treats an import\'s moved-aside delivery exactly as its live one (#1757)', async () => {
|
|
781
|
+
const t = await newTenant();
|
|
782
|
+
const p = await install(t, CRM_VERTICAL);
|
|
783
|
+
const c = await install(t, BOARD_VERTICAL);
|
|
784
|
+
const subject = dataSubjectId.parse(ulid());
|
|
785
|
+
await create(t, p, `poison ${subject}`);
|
|
786
|
+
await sweep();
|
|
787
|
+
await lever(t, c, replay(null));
|
|
788
|
+
// The redelivery fails again, so the live table and the replay history each hold the text.
|
|
789
|
+
await sweep();
|
|
790
|
+
await (await fx.consumer.getScope(reader, t, c)).invoke('board/note-member', { subjectId: subject });
|
|
791
|
+
const receipt = await fx.consumer.admin.shredSubject(staff, t, c, subject);
|
|
792
|
+
expect(receipt).toMatchObject({ subjectId: subject, eventsRedacted: 1 });
|
|
793
|
+
const view = await board(t, c);
|
|
794
|
+
const [own] = view.outbox.filter((o) => o.type === 'board.member-noted');
|
|
795
|
+
expect(view.deliveries.find((d) => d.event_id === own.id)?.error).toBe(REDACTED_DELIVERY_NOTE);
|
|
796
|
+
const [imported] = view.imports;
|
|
797
|
+
const live = view.deliveries.find((d) => d.event_id === imported.event_id)?.error;
|
|
798
|
+
const archived = view.replays.find((r) => r.kind === 'delivery' && r.event_id === imported.event_id)?.error;
|
|
799
|
+
// Both copies exist, so the comparison below cannot pass on two absences.
|
|
800
|
+
expect(typeof live).toBe('string');
|
|
801
|
+
expect(typeof archived).toBe('string');
|
|
802
|
+
expect(archived, '#1757: an erasure changed an imported delivery\'s live error but not its copy in ' +
|
|
803
|
+
'_substrat_import_replays (or the reverse). Whatever reaches _substrat_deliveries.error for an ' +
|
|
804
|
+
'imported event must rewrite json_extract(row, \'$.error\') of its kind=\'delivery\' rows too, in ' +
|
|
805
|
+
'the same transaction, or the replay history keeps what the live table gave up.').toBe(live);
|
|
806
|
+
// Today no erasure reaches either copy. When one does, this line changes, and the one above
|
|
807
|
+
// is what holds the replay history to it.
|
|
808
|
+
expect(live).toContain(subject);
|
|
809
|
+
});
|
|
745
810
|
it('skip to now: nothing before the skip is delivered, what comes after is, and a replay reaches back', async () => {
|
|
746
811
|
const t = await newTenant();
|
|
747
812
|
const p = await install(t, CRM_VERTICAL);
|