@substrat-run/contract-tests 0.142.0 → 0.144.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/attachment-refusal-suite.d.ts +3 -0
- package/dist/attachment-refusal-suite.d.ts.map +1 -0
- package/dist/attachment-refusal-suite.js +128 -0
- package/dist/attachment-refusal-suite.js.map +1 -0
- package/dist/bind-module.d.ts +199 -0
- package/dist/bind-module.d.ts.map +1 -0
- package/dist/bind-module.js +104 -0
- package/dist/bind-module.js.map +1 -0
- package/dist/binding-invite-suite.d.ts +12 -0
- package/dist/binding-invite-suite.d.ts.map +1 -0
- package/dist/binding-invite-suite.js +507 -0
- package/dist/binding-invite-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 +690 -0
- package/dist/derived-module.d.ts.map +1 -0
- package/dist/derived-module.js +320 -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 +481 -0
- package/dist/derived-suite.js.map +1 -0
- 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 +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -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 +78 -68
- package/dist/modules.js.map +1 -1
- package/dist/permission-suite.d.ts.map +1 -1
- package/dist/permission-suite.js +28 -0
- package/dist/permission-suite.js.map +1 -1
- package/dist/scope-host-suite.d.ts.map +1 -1
- package/dist/scope-host-suite.js +151 -31
- package/dist/scope-host-suite.js.map +1 -1
- package/dist/shape-backfill-ties.d.ts +29 -0
- package/dist/shape-backfill-ties.d.ts.map +1 -0
- package/dist/shape-backfill-ties.js +113 -0
- package/dist/shape-backfill-ties.js.map +1 -0
- package/dist/shape-reconcile-plan.d.ts +37 -0
- package/dist/shape-reconcile-plan.d.ts.map +1 -0
- package/dist/shape-reconcile-plan.js +143 -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,29 @@
|
|
|
1
|
+
import { type SwitchSql } from '@substrat-run/kernel';
|
|
2
|
+
interface TieCase {
|
|
3
|
+
name: string;
|
|
4
|
+
/** The pairs the backfill marked, and the ones it should have. */
|
|
5
|
+
marked: string[];
|
|
6
|
+
expected: string[];
|
|
7
|
+
/** Whether the run-once record was written once the reconcile finished. */
|
|
8
|
+
recorded: boolean;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Three cases, each its own table and entity type, each reconciled at `limit` 1 (one mark, a window
|
|
12
|
+
* of ten rows) until the reconcile finishes:
|
|
13
|
+
*
|
|
14
|
+
* - `duplicate id`: `(e1, A)`, `(e1, B)`, `(e2, C)` on an index of the id alone. The write budget
|
|
15
|
+
* ends the first pass on A, mid-id.
|
|
16
|
+
* - `null principal at the edge`: nine rows with no principal, then `(e1, NULL)` closing the first
|
|
17
|
+
* window, then `(e1, A)`. A principal-keyed seek past `(e1, NULL)` compares NULL for every e1 row.
|
|
18
|
+
* - `without rowid`: `(e1, A)`, `(e1, B)`, `(e2, C)` in a `WITHOUT ROWID` table keyed on another
|
|
19
|
+
* column, with an index on the id. Then the same table declared `WITHOUT ROWID, STRICT`, and
|
|
20
|
+
* `WITHOUT ROWID` followed by a comment: DDL text that does not end in the clause.
|
|
21
|
+
* - `a column named rowid`: the duplicate-id case in a table whose own `rowid` column, the same on
|
|
22
|
+
* every row, would stand where the row's identity should.
|
|
23
|
+
*/
|
|
24
|
+
export declare function shapeBackfillTies(raw: SwitchSql, ids: {
|
|
25
|
+
tenantId: string;
|
|
26
|
+
scopeId: string;
|
|
27
|
+
}): TieCase[];
|
|
28
|
+
export {};
|
|
29
|
+
//# sourceMappingURL=shape-backfill-ties.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"shape-backfill-ties.d.ts","sourceRoot":"","sources":["../src/shape-backfill-ties.ts"],"names":[],"mappings":"AAWA,OAAO,EAAkD,KAAK,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAEtG,UAAU,OAAO;IACf,IAAI,EAAE,MAAM,CAAC;IACb,kEAAkE;IAClE,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,2EAA2E;IAC3E,QAAQ,EAAE,OAAO,CAAC;CACnB;AAID;;;;;;;;;;;;;GAaG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,SAAS,EAAE,GAAG,EAAE;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GAAG,OAAO,EAAE,CA+EvG"}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A table holder's rows that share an `idColumn` are all read, whichever row a window ends on
|
|
3
|
+
* (#2154, #2159 review). Nothing makes `idColumn` unique or the principal non-NULL, so a read keyed
|
|
4
|
+
* on the id alone, or on the id and the principal, skipped the rest of an id's rows at a window's
|
|
5
|
+
* edge, and the run-once record then closed the backfill over them for good. Pure, with no runner:
|
|
6
|
+
* each adapter hands it its scope's own SQLite as the kernel's `SwitchSql`, so the cases run on the
|
|
7
|
+
* engine it actually ships, and asserts every case marked what it expected.
|
|
8
|
+
*
|
|
9
|
+
* Harness code: it writes `_substrat_*` rows directly, which is exactly what `ctx.sql` refuses.
|
|
10
|
+
*/
|
|
11
|
+
import { entityGrantShape } from '@substrat-run/contracts';
|
|
12
|
+
import { topUpEntityGrantShapes, ulid } from '@substrat-run/kernel';
|
|
13
|
+
const KEY = 'shape-tie:use';
|
|
14
|
+
/**
|
|
15
|
+
* Three cases, each its own table and entity type, each reconciled at `limit` 1 (one mark, a window
|
|
16
|
+
* of ten rows) until the reconcile finishes:
|
|
17
|
+
*
|
|
18
|
+
* - `duplicate id`: `(e1, A)`, `(e1, B)`, `(e2, C)` on an index of the id alone. The write budget
|
|
19
|
+
* ends the first pass on A, mid-id.
|
|
20
|
+
* - `null principal at the edge`: nine rows with no principal, then `(e1, NULL)` closing the first
|
|
21
|
+
* window, then `(e1, A)`. A principal-keyed seek past `(e1, NULL)` compares NULL for every e1 row.
|
|
22
|
+
* - `without rowid`: `(e1, A)`, `(e1, B)`, `(e2, C)` in a `WITHOUT ROWID` table keyed on another
|
|
23
|
+
* column, with an index on the id. Then the same table declared `WITHOUT ROWID, STRICT`, and
|
|
24
|
+
* `WITHOUT ROWID` followed by a comment: DDL text that does not end in the clause.
|
|
25
|
+
* - `a column named rowid`: the duplicate-id case in a table whose own `rowid` column, the same on
|
|
26
|
+
* every row, would stand where the row's identity should.
|
|
27
|
+
*/
|
|
28
|
+
export function shapeBackfillTies(raw, ids) {
|
|
29
|
+
const cases = [
|
|
30
|
+
{
|
|
31
|
+
name: 'duplicate id',
|
|
32
|
+
type: 'tieseat',
|
|
33
|
+
ddl: ['CREATE TABLE shape_tie_seat (entity_id TEXT, holder TEXT)', 'CREATE INDEX shape_tie_seat_id ON shape_tie_seat (entity_id)'],
|
|
34
|
+
rows: [['e1', 'A'], ['e1', 'B'], ['e2', 'C']],
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
name: 'null principal at the edge',
|
|
38
|
+
type: 'tiedesk',
|
|
39
|
+
ddl: ['CREATE TABLE shape_tie_desk (entity_id TEXT, holder TEXT)', 'CREATE INDEX shape_tie_desk_id ON shape_tie_desk (entity_id)'],
|
|
40
|
+
rows: [...Array.from({ length: 9 }, (_, i) => [`a${i}`, null]), ['e1', null], ['e1', 'A']],
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
name: 'without rowid',
|
|
44
|
+
type: 'tiebadge',
|
|
45
|
+
ddl: [
|
|
46
|
+
'CREATE TABLE shape_tie_badge (k TEXT PRIMARY KEY, entity_id TEXT, holder TEXT) WITHOUT ROWID',
|
|
47
|
+
'CREATE INDEX shape_tie_badge_id ON shape_tie_badge (entity_id)',
|
|
48
|
+
],
|
|
49
|
+
rows: [['e1', 'A'], ['e1', 'B'], ['e2', 'C']],
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: 'without rowid, strict',
|
|
53
|
+
type: 'tiestrict',
|
|
54
|
+
ddl: [
|
|
55
|
+
'CREATE TABLE shape_tie_strict (k TEXT PRIMARY KEY, entity_id TEXT, holder TEXT) WITHOUT ROWID, STRICT',
|
|
56
|
+
'CREATE INDEX shape_tie_strict_id ON shape_tie_strict (entity_id)',
|
|
57
|
+
],
|
|
58
|
+
rows: [['e1', 'A'], ['e1', 'B'], ['e2', 'C']],
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
name: 'without rowid, then a comment',
|
|
62
|
+
type: 'tienote',
|
|
63
|
+
ddl: [
|
|
64
|
+
'CREATE TABLE shape_tie_note (k TEXT PRIMARY KEY, entity_id TEXT, holder TEXT) WITHOUT ROWID -- keyed on k',
|
|
65
|
+
'CREATE INDEX shape_tie_note_id ON shape_tie_note (entity_id)',
|
|
66
|
+
],
|
|
67
|
+
rows: [['e1', 'A'], ['e1', 'B'], ['e2', 'C']],
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
name: 'a column named rowid',
|
|
71
|
+
type: 'tieshadow',
|
|
72
|
+
ddl: [
|
|
73
|
+
"CREATE TABLE shape_tie_shadow (rowid TEXT DEFAULT 'same', entity_id TEXT, holder TEXT)",
|
|
74
|
+
'CREATE INDEX shape_tie_shadow_id ON shape_tie_shadow (entity_id)',
|
|
75
|
+
],
|
|
76
|
+
rows: [['e1', 'A'], ['e1', 'B'], ['e2', 'C']],
|
|
77
|
+
},
|
|
78
|
+
];
|
|
79
|
+
return cases.map(({ name, type, ddl, rows }) => {
|
|
80
|
+
const table = ddl[0].split(' ')[2];
|
|
81
|
+
for (const st of ddl)
|
|
82
|
+
raw.run(st);
|
|
83
|
+
const expected = [];
|
|
84
|
+
const keyed = ddl[0].includes('WITHOUT ROWID'); // its own key column, `k`
|
|
85
|
+
rows.forEach(([id, who], i) => {
|
|
86
|
+
const holder = who && `${who}-${ulid()}`;
|
|
87
|
+
if (keyed)
|
|
88
|
+
raw.run(`INSERT INTO ${table} (k, entity_id, holder) VALUES (?, ?, ?)`, `k${i}`, id, holder);
|
|
89
|
+
else
|
|
90
|
+
raw.run(`INSERT INTO ${table} (entity_id, holder) VALUES (?, ?)`, id, holder);
|
|
91
|
+
if (!holder)
|
|
92
|
+
return;
|
|
93
|
+
raw.run('INSERT INTO _substrat_tuples (subject, relation, object) VALUES (?, ?, ?)', `principal:${holder}`, `granted:${KEY}`, `${type}:${id}`);
|
|
94
|
+
expected.push(`principal:${holder} ${type}:${id}`);
|
|
95
|
+
});
|
|
96
|
+
const shapes = [entityGrantShape.parse({ entityType: type, permissions: [KEY], bootstrap: true, holder: { table, idColumn: 'entity_id', principalColumn: 'holder' } })];
|
|
97
|
+
let after = null;
|
|
98
|
+
let passes = 0;
|
|
99
|
+
do {
|
|
100
|
+
after = topUpEntityGrantShapes(raw, { ...ids, shapes, now: new Date().toISOString(), limit: 1, after, mintEventId: () => ulid(), version: null }).next;
|
|
101
|
+
passes++;
|
|
102
|
+
} while (after && passes < 100);
|
|
103
|
+
// A cursor that never ends is a failure of its own, whatever happened to be marked by the cap.
|
|
104
|
+
if (after)
|
|
105
|
+
throw new Error(`${name}: the reconcile did not finish in ${passes} passes`);
|
|
106
|
+
const marked = raw
|
|
107
|
+
.all("SELECT subject || ' ' || object AS pair FROM _substrat_tuples WHERE relation = 'bootstrap' AND object >= ? AND object < ? ORDER BY 1", `${type}:`, `${type};`)
|
|
108
|
+
.map((r) => String(r['pair']));
|
|
109
|
+
const recorded = raw.all("SELECT 1 FROM _substrat_tuples WHERE subject = ? AND relation = 'shape-backfilled'", `shape:${type}`).length === 1;
|
|
110
|
+
return { name, marked, expected: expected.sort(), recorded };
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
//# sourceMappingURL=shape-backfill-ties.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"shape-backfill-ties.js","sourceRoot":"","sources":["../src/shape-backfill-ties.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type SwitchSql } from '@substrat-run/kernel';
|
|
2
|
+
type HolderKind = 'self' | 'grantee' | 'table';
|
|
3
|
+
interface ShapeReconcilePlanReport {
|
|
4
|
+
/** The plan of each marker walk the passes sent (one per retire and top-up walk). */
|
|
5
|
+
walks: string[];
|
|
6
|
+
/** The plan of each backfill read, by the holder kind it read for. */
|
|
7
|
+
backfills: Record<HolderKind, string[]>;
|
|
8
|
+
/** Every planned table scan of `_substrat_tuples`, with the statement that planned it. */
|
|
9
|
+
scans: string[];
|
|
10
|
+
/** Passes the reconcile took, and whether every holder ended up with the whole shape. */
|
|
11
|
+
passes: number;
|
|
12
|
+
complete: boolean;
|
|
13
|
+
/** Markers the backfill wrote, and how many legacy holders there were to mark. */
|
|
14
|
+
marks: number;
|
|
15
|
+
holders: number;
|
|
16
|
+
/** The most rows any one pass's backfill reads fetched, and the read budget of a pass. */
|
|
17
|
+
maxFetched: number;
|
|
18
|
+
window: number;
|
|
19
|
+
/** The most rows the engine read in one pass, every statement included: given `rowsRead` only. */
|
|
20
|
+
maxRowsRead: number | null;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Seed three shapes' worlds on `raw` — holders of the old shape, legacy holders with no marker for
|
|
24
|
+
* each holder kind, a retired key, and another type's markers beside them — then reconcile them in
|
|
25
|
+
* small passes, each resuming from the last, planning every statement. `legacy` legacy holders per
|
|
26
|
+
* kind (default 5); `rowsRead`, when the engine counts them, the running total of rows it has read.
|
|
27
|
+
* Run it on a scope nothing else is writing.
|
|
28
|
+
*/
|
|
29
|
+
export declare function shapeReconcilePlans(raw: SwitchSql, ids: {
|
|
30
|
+
tenantId: string;
|
|
31
|
+
scopeId: string;
|
|
32
|
+
}, opts?: {
|
|
33
|
+
legacy?: number;
|
|
34
|
+
rowsRead?: () => number;
|
|
35
|
+
}): ShapeReconcilePlanReport;
|
|
36
|
+
export {};
|
|
37
|
+
//# 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":"AAsBA,OAAO,EAA8E,KAAK,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAIlI,KAAK,UAAU,GAAG,MAAM,GAAG,SAAS,GAAG,OAAO,CAAC;AAE/C,UAAU,wBAAwB;IAChC,qFAAqF;IACrF,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,sEAAsE;IACtE,SAAS,EAAE,MAAM,CAAC,UAAU,EAAE,MAAM,EAAE,CAAC,CAAC;IACxC,0FAA0F;IAC1F,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,yFAAyF;IACzF,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,OAAO,CAAC;IAClB,kFAAkF;IAClF,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,0FAA0F;IAC1F,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,kGAAkG;IAClG,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;CAC5B;AAQD;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CACjC,GAAG,EAAE,SAAS,EACd,GAAG,EAAE;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,EAC1C,IAAI,GAAE;IAAE,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,MAAM,CAAA;CAAO,GACtD,wBAAwB,CA0G1B"}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shape reconcile's reads, planned by the SQLite each adapter actually runs (#2083, #2154).
|
|
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
|
+
* The backfill reads in windows too (#2154), for each holder kind: the probe seeds legacy holders
|
|
14
|
+
* of a `'self'`, a `'grantee'` and a table-holder shape, and reports each kind's read plans, how
|
|
15
|
+
* many rows each pass fetched, and how many markers the backfill wrote, so an adapter can assert
|
|
16
|
+
* every holder was marked exactly once. Given the engine's own count of rows read (a Durable
|
|
17
|
+
* Object's `cursor.rowsRead`), it reports the most any one pass read, every statement included,
|
|
18
|
+
* which is the bound a scope actually pays.
|
|
19
|
+
*
|
|
20
|
+
* Harness code: it writes `_substrat_*` rows directly, which is exactly what `ctx.sql` refuses.
|
|
21
|
+
*/
|
|
22
|
+
import { entityGrantShape } from '@substrat-run/contracts';
|
|
23
|
+
import { SHAPE_MARKER_READS_PER_ROW, topUpEntityGrantShapes, ulid } from '@substrat-run/kernel';
|
|
24
|
+
const MARKERS = 30;
|
|
25
|
+
const LIMIT = 4;
|
|
26
|
+
const KEYS = ['shape-plan:read', 'shape-plan:use'];
|
|
27
|
+
const RETIRED = 'shape-plan:old';
|
|
28
|
+
const BADGES = 'shape_plan_badge';
|
|
29
|
+
/**
|
|
30
|
+
* Seed three shapes' worlds on `raw` — holders of the old shape, legacy holders with no marker for
|
|
31
|
+
* each holder kind, a retired key, and another type's markers beside them — then reconcile them in
|
|
32
|
+
* small passes, each resuming from the last, planning every statement. `legacy` legacy holders per
|
|
33
|
+
* kind (default 5); `rowsRead`, when the engine counts them, the running total of rows it has read.
|
|
34
|
+
* Run it on a scope nothing else is writing.
|
|
35
|
+
*/
|
|
36
|
+
export function shapeReconcilePlans(raw, ids, opts = {}) {
|
|
37
|
+
const legacy = opts.legacy ?? 5;
|
|
38
|
+
const tuple = (subject, relation, object) => raw.run('INSERT OR REPLACE INTO _substrat_tuples (subject, relation, object) VALUES (?, ?, ?)', subject, relation, object);
|
|
39
|
+
const pad = (i) => String(i).padStart(5, '0');
|
|
40
|
+
for (let i = 0; i < MARKERS; i++) {
|
|
41
|
+
const [who, desk] = [`principal:${ulid()}`, `desk:d${pad(i)}`];
|
|
42
|
+
tuple(who, 'bootstrap', desk);
|
|
43
|
+
tuple(who, `granted:${KEYS[0]}`, desk);
|
|
44
|
+
if (i % 3 === 0)
|
|
45
|
+
tuple(who, `granted:${RETIRED}`, desk);
|
|
46
|
+
tuple(who, 'bootstrap', `room:d${pad(i)}`); // another type's marker: never read by this shape
|
|
47
|
+
}
|
|
48
|
+
raw.run(`CREATE TABLE IF NOT EXISTS ${BADGES} (id TEXT PRIMARY KEY, holder TEXT)`);
|
|
49
|
+
for (let i = 0; i < legacy; i++) {
|
|
50
|
+
const [grantee, owner, badged] = [ulid(), ulid(), ulid()];
|
|
51
|
+
tuple(`principal:${grantee}`, `granted:${KEYS[0]}`, `desk:l${pad(i)}`);
|
|
52
|
+
tuple(`principal:${owner}`, `granted:${KEYS[0]}`, `seat:${owner}`);
|
|
53
|
+
tuple(`principal:${ulid()}`, `granted:${KEYS[0]}`, `seat:${owner}`); // a sharee on it: never marked
|
|
54
|
+
raw.run(`INSERT INTO ${BADGES} (id, holder) VALUES (?, ?)`, `b${pad(i)}`, badged);
|
|
55
|
+
tuple(`principal:${badged}`, `granted:${KEYS[0]}`, `badge:b${pad(i)}`);
|
|
56
|
+
}
|
|
57
|
+
const walks = [];
|
|
58
|
+
const backfills = { self: [], grantee: [], table: [] };
|
|
59
|
+
const scans = [];
|
|
60
|
+
let [marks, fetched] = [0, 0];
|
|
61
|
+
/** Which holder kind a statement is the backfill read of, if it is one. */
|
|
62
|
+
const backfillOf = (sql, params) => sql.includes(`FROM "${BADGES}" e`) ? 'table' : sql.includes('(t.object, t.relation, t.subject) > (?, ?, ?)') ? (params.includes('seat;') ? 'self' : 'grantee') : null;
|
|
63
|
+
const plan = (sql, params) => {
|
|
64
|
+
if (!/^\s*(SELECT|UPDATE|DELETE)/i.test(sql))
|
|
65
|
+
return;
|
|
66
|
+
const detail = raw.all(`EXPLAIN QUERY PLAN ${sql}`, ...params).map((r) => String(r['detail']));
|
|
67
|
+
const joined = detail.join(' | ');
|
|
68
|
+
if (sql.includes('(m.object, m.subject) > (?, ?)'))
|
|
69
|
+
walks.push(joined);
|
|
70
|
+
const kind = backfillOf(sql, params);
|
|
71
|
+
if (kind)
|
|
72
|
+
backfills[kind].push(joined);
|
|
73
|
+
// A virtual table (`json_each`, `pragma_table_list`) is scanned by design; a scan of the tuple
|
|
74
|
+
// table, or of the holder's table, never is.
|
|
75
|
+
for (const d of detail)
|
|
76
|
+
if (/^SCAN /.test(d) && !/VIRTUAL TABLE/.test(d))
|
|
77
|
+
scans.push(`${d} — ${sql.replace(/\s+/g, ' ').slice(0, 160)}`);
|
|
78
|
+
};
|
|
79
|
+
const sql = {
|
|
80
|
+
all: (q, ...p) => {
|
|
81
|
+
if (q.startsWith('EXPLAIN'))
|
|
82
|
+
return raw.all(q, ...p); // the kernel's own plan check: not a read of rows
|
|
83
|
+
plan(q, p);
|
|
84
|
+
const rows = raw.all(q, ...p);
|
|
85
|
+
if (backfillOf(q, p))
|
|
86
|
+
fetched += rows.length;
|
|
87
|
+
return rows;
|
|
88
|
+
},
|
|
89
|
+
run: (q, ...p) => {
|
|
90
|
+
plan(q, p);
|
|
91
|
+
if (q.startsWith('INSERT OR IGNORE') && p[1] === 'bootstrap')
|
|
92
|
+
marks++;
|
|
93
|
+
raw.run(q, ...p);
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
const shapes = [
|
|
97
|
+
entityGrantShape.parse({ entityType: 'desk', permissions: KEYS, retired: [RETIRED], bootstrap: true, holder: 'grantee' }),
|
|
98
|
+
entityGrantShape.parse({ entityType: 'seat', permissions: KEYS, bootstrap: true, holder: 'self' }),
|
|
99
|
+
entityGrantShape.parse({ entityType: 'badge', permissions: KEYS, bootstrap: true, holder: { table: BADGES, idColumn: 'id', principalColumn: 'holder' } }),
|
|
100
|
+
];
|
|
101
|
+
let after = null;
|
|
102
|
+
let passes = 0;
|
|
103
|
+
let maxFetched = 0;
|
|
104
|
+
let maxRowsRead = 0;
|
|
105
|
+
do {
|
|
106
|
+
fetched = 0;
|
|
107
|
+
const before = opts.rowsRead?.() ?? 0;
|
|
108
|
+
after = topUpEntityGrantShapes(sql, {
|
|
109
|
+
...ids,
|
|
110
|
+
shapes,
|
|
111
|
+
now: new Date().toISOString(),
|
|
112
|
+
limit: LIMIT,
|
|
113
|
+
after,
|
|
114
|
+
mintEventId: () => ulid(),
|
|
115
|
+
version: null,
|
|
116
|
+
}).next;
|
|
117
|
+
maxFetched = Math.max(maxFetched, fetched);
|
|
118
|
+
// `EXPLAIN QUERY PLAN` executes nothing, so the probe's own planning adds no rows read.
|
|
119
|
+
maxRowsRead = Math.max(maxRowsRead, (opts.rowsRead?.() ?? 0) - before);
|
|
120
|
+
passes++;
|
|
121
|
+
} while (after && passes < 100_000);
|
|
122
|
+
const short = raw.all(`SELECT count(*) AS n FROM _substrat_tuples m
|
|
123
|
+
WHERE m.relation = 'bootstrap' AND ((m.object >= 'desk:' AND m.object < 'desk;') OR (m.object >= 'seat:' AND m.object < 'seat;')
|
|
124
|
+
OR (m.object >= 'badge:' AND m.object < 'badge;'))
|
|
125
|
+
AND NOT EXISTS (SELECT 1 FROM _substrat_tuples t WHERE t.subject = m.subject AND t.object = m.object
|
|
126
|
+
AND t.relation = 'granted:${KEYS[1]}' AND t.revoked_at IS NULL)`)[0]['n'];
|
|
127
|
+
const markers = raw.all(`SELECT count(*) AS n FROM _substrat_tuples WHERE relation = 'bootstrap'
|
|
128
|
+
AND ((object >= 'desk:l' AND object < 'desk:m') OR (object >= 'seat:' AND object < 'seat;') OR (object >= 'badge:' AND object < 'badge;'))`)[0]['n'];
|
|
129
|
+
const holders = legacy * 3;
|
|
130
|
+
return {
|
|
131
|
+
walks,
|
|
132
|
+
backfills,
|
|
133
|
+
scans,
|
|
134
|
+
passes,
|
|
135
|
+
complete: after === null && Number(short) === 0 && Number(markers) === holders,
|
|
136
|
+
marks,
|
|
137
|
+
holders,
|
|
138
|
+
maxFetched,
|
|
139
|
+
window: LIMIT * SHAPE_MARKER_READS_PER_ROW,
|
|
140
|
+
maxRowsRead: opts.rowsRead ? maxRowsRead : null,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
//# 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);
|