@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.
Files changed (73) hide show
  1. package/dist/attachment-refusal-suite.d.ts +3 -0
  2. package/dist/attachment-refusal-suite.d.ts.map +1 -0
  3. package/dist/attachment-refusal-suite.js +128 -0
  4. package/dist/attachment-refusal-suite.js.map +1 -0
  5. package/dist/bind-module.d.ts +199 -0
  6. package/dist/bind-module.d.ts.map +1 -0
  7. package/dist/bind-module.js +104 -0
  8. package/dist/bind-module.js.map +1 -0
  9. package/dist/binding-invite-suite.d.ts +12 -0
  10. package/dist/binding-invite-suite.d.ts.map +1 -0
  11. package/dist/binding-invite-suite.js +507 -0
  12. package/dist/binding-invite-suite.js.map +1 -0
  13. package/dist/caused-by-module.d.ts.map +1 -1
  14. package/dist/caused-by-module.js +3 -2
  15. package/dist/caused-by-module.js.map +1 -1
  16. package/dist/derived-module.d.ts +690 -0
  17. package/dist/derived-module.d.ts.map +1 -0
  18. package/dist/derived-module.js +320 -0
  19. package/dist/derived-module.js.map +1 -0
  20. package/dist/derived-suite.d.ts +3 -0
  21. package/dist/derived-suite.d.ts.map +1 -0
  22. package/dist/derived-suite.js +481 -0
  23. package/dist/derived-suite.js.map +1 -0
  24. package/dist/entity-state-module.d.ts.map +1 -1
  25. package/dist/entity-state-module.js +12 -6
  26. package/dist/entity-state-module.js.map +1 -1
  27. package/dist/entity-trash-module.d.ts.map +1 -1
  28. package/dist/entity-trash-module.js +4 -4
  29. package/dist/entity-trash-module.js.map +1 -1
  30. package/dist/entity-trash-suite.d.ts.map +1 -1
  31. package/dist/entity-trash-suite.js +17 -8
  32. package/dist/entity-trash-suite.js.map +1 -1
  33. package/dist/erasure-module.d.ts.map +1 -1
  34. package/dist/erasure-module.js +3 -2
  35. package/dist/erasure-module.js.map +1 -1
  36. package/dist/index.d.ts +8 -0
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +8 -0
  39. package/dist/index.js.map +1 -1
  40. package/dist/input-parse-suite.d.ts.map +1 -1
  41. package/dist/input-parse-suite.js +33 -2
  42. package/dist/input-parse-suite.js.map +1 -1
  43. package/dist/membership-module.d.ts.map +1 -1
  44. package/dist/membership-module.js +3 -2
  45. package/dist/membership-module.js.map +1 -1
  46. package/dist/migration-comments.d.ts.map +1 -1
  47. package/dist/migration-comments.js +3 -2
  48. package/dist/migration-comments.js.map +1 -1
  49. package/dist/modules.d.ts.map +1 -1
  50. package/dist/modules.js +78 -68
  51. package/dist/modules.js.map +1 -1
  52. package/dist/permission-suite.d.ts.map +1 -1
  53. package/dist/permission-suite.js +28 -0
  54. package/dist/permission-suite.js.map +1 -1
  55. package/dist/scope-host-suite.d.ts.map +1 -1
  56. package/dist/scope-host-suite.js +151 -31
  57. package/dist/scope-host-suite.js.map +1 -1
  58. package/dist/shape-backfill-ties.d.ts +29 -0
  59. package/dist/shape-backfill-ties.d.ts.map +1 -0
  60. package/dist/shape-backfill-ties.js +113 -0
  61. package/dist/shape-backfill-ties.js.map +1 -0
  62. package/dist/shape-reconcile-plan.d.ts +37 -0
  63. package/dist/shape-reconcile-plan.d.ts.map +1 -0
  64. package/dist/shape-reconcile-plan.js +143 -0
  65. package/dist/shape-reconcile-plan.js.map +1 -0
  66. package/dist/test-operations.d.ts +25 -0
  67. package/dist/test-operations.d.ts.map +1 -0
  68. package/dist/test-operations.js +22 -0
  69. package/dist/test-operations.js.map +1 -0
  70. package/dist/vertical-events-suite.d.ts.map +1 -1
  71. package/dist/vertical-events-suite.js +72 -7
  72. package/dist/vertical-events-suite.js.map +1 -1
  73. 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,EAML,KAAK,kBAAkB,EAEvB,KAAK,kBAAkB,EAGvB,KAAK,SAAS,EAEf,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EAA4D,KAAK,aAAa,EAAE,MAAM,yBAAyB,CAAC;AAcvH,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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA2BjC,CAAC;AAkCH,eAAO,MAAM,cAAc,EAAE,kBAgD5B,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,CA+1CN"}
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
- operations: {
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('SELECT replay_id, kind, event_id, consumer_module FROM _substrat_import_replays ORDER BY kind, event_id'),
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
- operations: { 'board/read': boardRead },
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
- if (c.name === 'poison')
226
- throw new Error('board refuses the poison customer');
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);