@substrat-run/kernel 0.139.0 → 0.140.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 (140) hide show
  1. package/dist/admin-log-ddl.d.ts +21 -0
  2. package/dist/admin-log-ddl.d.ts.map +1 -0
  3. package/dist/admin-log-ddl.js +32 -0
  4. package/dist/admin-log-ddl.js.map +1 -0
  5. package/dist/attachment-extractor.d.ts +12 -1
  6. package/dist/attachment-extractor.d.ts.map +1 -1
  7. package/dist/attachment-extractor.js +10 -5
  8. package/dist/attachment-extractor.js.map +1 -1
  9. package/dist/audit-outcome.d.ts +149 -0
  10. package/dist/audit-outcome.d.ts.map +1 -0
  11. package/dist/audit-outcome.js +233 -0
  12. package/dist/audit-outcome.js.map +1 -0
  13. package/dist/connect-links.d.ts +91 -0
  14. package/dist/connect-links.d.ts.map +1 -0
  15. package/dist/connect-links.js +189 -0
  16. package/dist/connect-links.js.map +1 -0
  17. package/dist/connect-state.d.ts +12 -3
  18. package/dist/connect-state.d.ts.map +1 -1
  19. package/dist/connect-state.js +8 -3
  20. package/dist/connect-state.js.map +1 -1
  21. package/dist/derived-object.d.ts +14 -0
  22. package/dist/derived-object.d.ts.map +1 -0
  23. package/dist/derived-object.js +2 -0
  24. package/dist/derived-object.js.map +1 -0
  25. package/dist/derived-objects.d.ts +108 -0
  26. package/dist/derived-objects.d.ts.map +1 -0
  27. package/dist/derived-objects.js +199 -0
  28. package/dist/derived-objects.js.map +1 -0
  29. package/dist/email-intent.d.ts +56 -0
  30. package/dist/email-intent.d.ts.map +1 -0
  31. package/dist/email-intent.js +71 -0
  32. package/dist/email-intent.js.map +1 -0
  33. package/dist/entity-edges.d.ts +6 -1
  34. package/dist/entity-edges.d.ts.map +1 -1
  35. package/dist/entity-edges.js +13 -0
  36. package/dist/entity-edges.js.map +1 -1
  37. package/dist/entity-grant-shape.d.ts +94 -0
  38. package/dist/entity-grant-shape.d.ts.map +1 -0
  39. package/dist/entity-grant-shape.js +329 -0
  40. package/dist/entity-grant-shape.js.map +1 -0
  41. package/dist/entity-grant.d.ts +45 -0
  42. package/dist/entity-grant.d.ts.map +1 -0
  43. package/dist/entity-grant.js +71 -0
  44. package/dist/entity-grant.js.map +1 -0
  45. package/dist/entity-state-reads.d.ts +4 -7
  46. package/dist/entity-state-reads.d.ts.map +1 -1
  47. package/dist/entity-state-reads.js +13 -57
  48. package/dist/entity-state-reads.js.map +1 -1
  49. package/dist/entity-state.d.ts +37 -10
  50. package/dist/entity-state.d.ts.map +1 -1
  51. package/dist/entity-state.js +80 -69
  52. package/dist/entity-state.js.map +1 -1
  53. package/dist/entity-trash.d.ts +245 -0
  54. package/dist/entity-trash.d.ts.map +1 -0
  55. package/dist/entity-trash.js +380 -0
  56. package/dist/entity-trash.js.map +1 -0
  57. package/dist/grant-scoped-read.d.ts +41 -0
  58. package/dist/grant-scoped-read.d.ts.map +1 -0
  59. package/dist/grant-scoped-read.js +100 -0
  60. package/dist/grant-scoped-read.js.map +1 -0
  61. package/dist/index.d.ts +31 -5
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +20 -4
  64. package/dist/index.js.map +1 -1
  65. package/dist/kernel-outbox.d.ts +14 -0
  66. package/dist/kernel-outbox.d.ts.map +1 -0
  67. package/dist/kernel-outbox.js +37 -0
  68. package/dist/kernel-outbox.js.map +1 -0
  69. package/dist/list-index.d.ts +11 -2
  70. package/dist/list-index.d.ts.map +1 -1
  71. package/dist/list-index.js +29 -21
  72. package/dist/list-index.js.map +1 -1
  73. package/dist/mail-sender.d.ts +78 -0
  74. package/dist/mail-sender.d.ts.map +1 -0
  75. package/dist/mail-sender.js +2 -0
  76. package/dist/mail-sender.js.map +1 -0
  77. package/dist/membership-executor.d.ts +0 -52
  78. package/dist/membership-executor.d.ts.map +1 -1
  79. package/dist/membership-executor.js +19 -59
  80. package/dist/membership-executor.js.map +1 -1
  81. package/dist/migration-digest.d.ts +161 -0
  82. package/dist/migration-digest.d.ts.map +1 -0
  83. package/dist/migration-digest.js +237 -0
  84. package/dist/migration-digest.js.map +1 -0
  85. package/dist/module-erasure.d.ts +156 -0
  86. package/dist/module-erasure.d.ts.map +1 -0
  87. package/dist/module-erasure.js +440 -0
  88. package/dist/module-erasure.js.map +1 -0
  89. package/dist/peer.d.ts +9 -1
  90. package/dist/peer.d.ts.map +1 -1
  91. package/dist/peer.js +29 -0
  92. package/dist/peer.js.map +1 -1
  93. package/dist/permission-checker.d.ts +6 -0
  94. package/dist/permission-checker.d.ts.map +1 -1
  95. package/dist/permission-checker.js.map +1 -1
  96. package/dist/permission-eval.d.ts +10 -1
  97. package/dist/permission-eval.d.ts.map +1 -1
  98. package/dist/permission-eval.js +51 -2
  99. package/dist/permission-eval.js.map +1 -1
  100. package/dist/platform-sweep.d.ts +1 -1
  101. package/dist/platform-sweep.d.ts.map +1 -1
  102. package/dist/platform-sweep.js +5 -0
  103. package/dist/platform-sweep.js.map +1 -1
  104. package/dist/scope-host.d.ts +301 -22
  105. package/dist/scope-host.d.ts.map +1 -1
  106. package/dist/scope-host.js +72 -5
  107. package/dist/scope-host.js.map +1 -1
  108. package/dist/search-index.d.ts +8 -0
  109. package/dist/search-index.d.ts.map +1 -1
  110. package/dist/search-index.js +51 -21
  111. package/dist/search-index.js.map +1 -1
  112. package/dist/spine-guard.d.ts +7 -0
  113. package/dist/spine-guard.d.ts.map +1 -1
  114. package/dist/spine-guard.js +54 -6
  115. package/dist/spine-guard.js.map +1 -1
  116. package/dist/spine-restore.d.ts.map +1 -1
  117. package/dist/spine-restore.js +5 -0
  118. package/dist/spine-restore.js.map +1 -1
  119. package/dist/sql-statements.d.ts +71 -0
  120. package/dist/sql-statements.d.ts.map +1 -0
  121. package/dist/sql-statements.js +191 -0
  122. package/dist/sql-statements.js.map +1 -0
  123. package/dist/subject-redaction.d.ts +9 -1
  124. package/dist/subject-redaction.d.ts.map +1 -1
  125. package/dist/subject-redaction.js.map +1 -1
  126. package/dist/system-switch-record.d.ts +3 -0
  127. package/dist/system-switch-record.d.ts.map +1 -1
  128. package/dist/system-switch-record.js +2 -0
  129. package/dist/system-switch-record.js.map +1 -1
  130. package/dist/table-ownership.d.ts +103 -0
  131. package/dist/table-ownership.d.ts.map +1 -0
  132. package/dist/table-ownership.js +285 -0
  133. package/dist/table-ownership.js.map +1 -0
  134. package/dist/timeline.d.ts +4 -4
  135. package/dist/timeline.js +4 -4
  136. package/dist/visible-continuation.d.ts +38 -0
  137. package/dist/visible-continuation.d.ts.map +1 -0
  138. package/dist/visible-continuation.js +162 -0
  139. package/dist/visible-continuation.js.map +1 -0
  140. package/package.json +2 -2
@@ -0,0 +1,237 @@
1
+ import { substratError, tokenizeSql } from '@substrat-run/contracts';
2
+ import { moduleMigrations } from './module-migrations.js';
3
+ import { assertNoSpineReference, assertNoSpineWrite } from './spine-guard.js';
4
+ import { attachmentSha256 } from './scope-host.js';
5
+ /**
6
+ * What a scope's migration journal records beside each version, and the check that reads it
7
+ * (#2066). Shared by both adapters, so the rule and the sentence an operator reads are one.
8
+ *
9
+ * A scope used to record an applied migration by `(module_id, version)` alone, so a later
10
+ * `0025` whose SQL differed from the `0025` it ran was skipped as already applied: a dev
11
+ * database that ran one branch's `0025` and then `main`'s, or a preview that ran an unmerged
12
+ * branch, kept whichever schema it got first, and the first sign was a query failing against
13
+ * a column that was not there. `_substrat_migrations.sql_digest` closes that.
14
+ *
15
+ * - **The digest is SHA-256 over the exact SQL text**, lowercase hex, with no normalisation:
16
+ * not whitespace, not comments, not line endings. Any change is a different migration. A
17
+ * shipped migration's text never changes on `main` (`lint:migrations --check` refuses the
18
+ * edit upstream), so a difference at a scope means the scope ran something else.
19
+ * - **Only a module's AUTHORED migrations are held to it.** The kernel-derived DDL
20
+ * (`search/…`, `state/…`, `list/…`, see `moduleMigrations`) is versioned by its
21
+ * declaration on purpose: the version names what decides the DDL, so the kernel may change
22
+ * how it spells the same declaration between releases. Holding those rows to a digest would
23
+ * fail every scope with a searchable entity closed on the kernel upgrade that respelled a
24
+ * trigger. Their digest is still recorded, as the fact of what ran.
25
+ * - **A row applied before the column carries `'legacy'`, and is accepted.** Nobody measured
26
+ * what it ran, so it gets no digest: backfilling the registered one would write a value
27
+ * nobody measured, and bless the one row this exists to catch if that scope did run
28
+ * something else. The mark is explicit rather than a NULL, because a NULL cannot say where it
29
+ * came from: the rows present when the column arrives are marked (`MIGRATION_DIGEST_MARK_LEGACY`),
30
+ * a dump taken before the column restores as `'legacy'` (the restore derives it), and from
31
+ * then on the fence below refuses a NULL however it is written. So `'legacy'` is provenance,
32
+ * and a NULL — say a dump edited to clear a digest — is refused rather than trusted. Such a
33
+ * row is unprotected for good, which is the price of not lying in the journal.
34
+ */
35
+ export function migrationDigest(sql) {
36
+ return attachmentSha256(new TextEncoder().encode(sql));
37
+ }
38
+ /** What a journal row applied before digests were recorded carries in `sql_digest` (#2066). */
39
+ export const MIGRATION_DIGEST_LEGACY = 'legacy';
40
+ /**
41
+ * Marks the rows present when the column arrived. Run right after the column's ALTER on every
42
+ * wake: once the fence is in place it can only ever find those rows, since nothing else can
43
+ * leave a NULL. Literal text, like the fence, so `lint:spine-ddl` and a reviewer read one spelling.
44
+ */
45
+ export const MIGRATION_DIGEST_MARK_LEGACY = "UPDATE _substrat_migrations SET sql_digest = 'legacy' WHERE sql_digest IS NULL";
46
+ /**
47
+ * The fence: no journal row is written, or rewritten, without a digest or the legacy mark — in
48
+ * both adapters' KERNEL_DDL, after the journal table, so `lint:spine-ddl` holds it like any
49
+ * spine trigger.
50
+ *
51
+ * Without it an older writer (an instance still on the previous release during a rollout or a
52
+ * rollback, or a second process over the same SQLite file) inserts a row that omits the
53
+ * column, and SQLite records NULL. With it that INSERT aborts, so the older writer's migration
54
+ * fails and its scope fails closed until code that records the digest serves it, which then
55
+ * applies the migration properly. KERNEL_DDL runs before the column is ALTERed onto a legacy
56
+ * journal, and on a table without the column the INSERT trigger makes any INSERT fail with
57
+ * `no such column`: there is no window in which a NULL can be written. A restore loads its
58
+ * rows under the fence too, so a dump that carries a NULL is refused (`spineRowsInsert` gives a
59
+ * dump from before the column the legacy mark instead).
60
+ *
61
+ * Migration SQL cannot drop it: `assertNoJournalSql` refuses any migration naming the journal.
62
+ * Literal text, no interpolation: `lint:spine-ddl` inlines a kernel fragment one level deep.
63
+ */
64
+ export const MIGRATION_DIGEST_FENCE_DDL = `
65
+ CREATE TRIGGER IF NOT EXISTS _substrat_migrations_digest_required
66
+ BEFORE INSERT ON _substrat_migrations WHEN NEW.sql_digest IS NULL
67
+ BEGIN
68
+ SELECT RAISE(ABORT, 'a migration journal row must carry its sql_digest (#2066)');
69
+ END;
70
+ CREATE TRIGGER IF NOT EXISTS _substrat_migrations_digest_kept
71
+ BEFORE UPDATE OF sql_digest ON _substrat_migrations WHEN NEW.sql_digest IS NULL
72
+ BEGIN
73
+ SELECT RAISE(ABORT, 'a migration journal row must carry its sql_digest (#2066)');
74
+ END;
75
+ `;
76
+ /**
77
+ * Refuse migration SQL that names the migration journal (`_substrat_migration…`: the table, its
78
+ * fence, the DO's bookmarks), in any spelling the grammar allows — quoted, any case, schema-
79
+ * qualified — before any of it runs.
80
+ *
81
+ * A migration runs on the kernel's own handle, not `ctx.sql`, so the spine write guard never
82
+ * sees it, and an authored migration that repairs `_substrat_tuples` is a reviewed, linted path
83
+ * (`boundary-lint-allow R4 migration`). The journal is different: a migration that drops the
84
+ * fence, or writes its own journal rows, makes the digest check say whatever it wrote. Nothing
85
+ * legitimate in a module's migration names it, reads included.
86
+ */
87
+ export function assertNoJournalSql(sql, what) {
88
+ for (const token of tokenizeSql(sql)) {
89
+ if (!token.text.split('.').some((part) => part.toLowerCase().startsWith('_substrat_migration')))
90
+ continue;
91
+ throw substratError('forbidden', `${what} cannot name the migration journal ('${token.text}'): the journal and its digest fence are the kernel's (#2066)`, { reason: 'spine_write' });
92
+ }
93
+ }
94
+ /**
95
+ * The authored migrations allowed to write the spine: four shipped ticket0 repairs of its own
96
+ * `_substrat_tuples` edges and its hand-recreated derived list indexes, each reviewed under
97
+ * `boundary-lint-allow R4 migration`. Keyed by module, version AND the digest of the exact text,
98
+ * so only those texts pass: a new migration, or any edit to one of these, gets the full guard.
99
+ * Shipped migrations are append-only, so this list only ever shrinks — a new spine repair goes
100
+ * through the platform, not a migration.
101
+ */
102
+ const REVIEWED_SPINE_MIGRATIONS = new Set([
103
+ '@substrat-run/demo-ticket0@0020#d29122b68f3cc5d07a70fb87874085e0e6805e2e19612984160488c59789d75c',
104
+ '@substrat-run/demo-ticket0@0022#9de9088207fa914e9eb8c8344924708655cbecb11e5b64f76e1d2ec7d0bd0ea2',
105
+ '@substrat-run/demo-ticket0@0024#ba60103ae91108bfce21fd5b24818cec18aefe4ee3b03b49ca8013fd2634aae3',
106
+ '@substrat-run/demo-ticket0@0026#da0bb4e3c8b13f5febd8b2202e7267c30e9ee91a19d202181b99ccdeb71ec3a4',
107
+ ]);
108
+ /**
109
+ * Refuse a migration's SQL before any of it runs, if it reaches where a migration must not.
110
+ *
111
+ * A migration runs on the kernel's own handle, not `ctx.sql`, so `ctx.sql`'s guard never sees it.
112
+ * Every migration is held to: no foreign key to the spine (#1898), and nothing that names the
113
+ * journal (`assertNoJournalSql`). An AUTHORED one is also held to the full spine write guard —
114
+ * no write, DDL or same-named TEMP object on any `_substrat_*` table — unless it is one of the
115
+ * exact reviewed texts above. Kernel-derived DDL is exempt from that last rule: creating
116
+ * `_substrat_*` indexes and triggers is what it is for.
117
+ */
118
+ export function assertMigrationSql(sql, step) {
119
+ const what = `migration ${step.key}`;
120
+ assertNoSpineReference(sql, what);
121
+ assertNoJournalSql(sql, what);
122
+ if (!step.authored || REVIEWED_SPINE_MIGRATIONS.has(`${step.key}#${step.digest}`))
123
+ return;
124
+ try {
125
+ assertNoSpineWrite(sql);
126
+ }
127
+ catch (err) {
128
+ throw substratError('forbidden', `${what} cannot write the platform spine: ${err.message.replace(/^ctx\.sql cannot write the platform spine: /, '')}`, {
129
+ reason: 'spine_write',
130
+ });
131
+ }
132
+ }
133
+ /**
134
+ * Refuse a dump whose journal is not consistent with its own shape (#2066 r3): a table whose
135
+ * recorded DDL declares `sql_digest` while its columns omit it, or the other way round.
136
+ *
137
+ * A dump exported before digests were recorded restores as `'legacy'` (`spineRowsInsert`), so
138
+ * the shape of the journal is what says which kind of dump this is — and a current dump with
139
+ * the column stripped would otherwise pass as legacy and unprotect every row. The dump's DDL is
140
+ * `sqlite_master`'s text, which an ALTER rewrites, so a real pre-#2066 journal carries neither.
141
+ * Nothing else a dump carries changed with #2066 to cross-check against (the fence is a trigger,
142
+ * and a dump holds tables). A dump is freely editable data: a consistent edit of both is not
143
+ * detectable, and restore is privileged, audited staff work that could forge any digest anyway.
144
+ * This catches corruption and partial edits, not an author set on lying.
145
+ */
146
+ export function assertJournalDumpCoherent(tables) {
147
+ for (const t of tables) {
148
+ if (t.name.toLowerCase() !== '_substrat_migrations')
149
+ continue;
150
+ const declared = tokenizeSql(t.ddl).some((tok) => tok.text.toLowerCase() === 'sql_digest');
151
+ const carried = t.columns.some((c) => c.toLowerCase() === 'sql_digest');
152
+ if (declared === carried)
153
+ continue;
154
+ throw substratError('validation_failed', `restore refused: the dump's _substrat_migrations ${declared ? 'declares sql_digest in its DDL but carries no such column' : 'carries sql_digest but its DDL does not declare it'} — ` +
155
+ `it is neither a dump from before digests were recorded nor a whole one from after. Nothing was changed (#2066).`);
156
+ }
157
+ }
158
+ /**
159
+ * Digests by SQL text. Keyed on the content, not on the registration object: a registration
160
+ * mutated or rebuilt with other SQL under the same version gets that SQL's digest, never a
161
+ * stale one. Bounded by the distinct migration texts the isolate has registered.
162
+ */
163
+ const digestOf = new Map();
164
+ const cachedDigest = (sql) => {
165
+ let digest = digestOf.get(sql);
166
+ if (!digest)
167
+ digestOf.set(sql, (digest = migrationDigest(sql)));
168
+ return digest;
169
+ };
170
+ /**
171
+ * A module's migrations in the order the host applies them (`moduleMigrations`), each with its
172
+ * digest and whether it is authored — read from the registration as it is now. The digest of a
173
+ * text is computed once per isolate, so a Durable Object that wakes with the same code hashes
174
+ * nothing again.
175
+ */
176
+ export function migrationSteps(registration) {
177
+ const authored = new Set((registration.migrations ?? []).map((m) => m.version));
178
+ return Promise.all(moduleMigrations(registration).map(async (migration) => ({
179
+ migration,
180
+ digest: await cachedDigest(migration.sql),
181
+ authored: authored.has(migration.version),
182
+ })));
183
+ }
184
+ /**
185
+ * Plan a scope's migration pass against its journal (`module@version` → recorded digest): the
186
+ * registered steps it has not applied, or the first applied one whose SQL differs, which fails
187
+ * the scope closed before anything else runs. Both adapters plan through this.
188
+ */
189
+ export async function planMigrations(modules, applied) {
190
+ const pending = [];
191
+ for (const mod of modules) {
192
+ for (const step of await mod.steps) {
193
+ const key = `${mod.id}@${step.migration.version}`;
194
+ if (!applied.has(key)) {
195
+ pending.push({ moduleId: mod.id, ...step });
196
+ continue;
197
+ }
198
+ const error = migrationDivergence(applied.get(key), step.digest, step.authored);
199
+ if (error)
200
+ return { pending: [], diverged: { version: key, error } };
201
+ }
202
+ }
203
+ return { pending };
204
+ }
205
+ /** The error a scope fails closed with when `module@version` cannot be applied or trusted. */
206
+ export function migrationFailedError(key, cause) {
207
+ return new Error(`migration failed for ${key} — scope fails closed: ${cause}`);
208
+ }
209
+ /**
210
+ * Why a registered migration the scope has already applied cannot be trusted, or null when it
211
+ * can: on an authored migration, a recorded digest that differs from the registered one, or
212
+ * no digest and no legacy mark at all.
213
+ *
214
+ * The caller fails the scope closed with this as the cause, the way a migration that threw
215
+ * does — the same `migration failed for <module>@<version> — scope fails closed:` record, so
216
+ * the directory, the sweep and the dashboard read it with nothing new. The scope recovers
217
+ * when its journal and its schema agree with the deployment again: a scope that ran another
218
+ * branch's migration is rebuilt (a dev database, a preview) or restored to a dump taken before
219
+ * it ran; a deployment carrying the wrong SQL is redeployed with the SQL the scope ran. A
220
+ * migration is never edited or renumbered once a scope has it, so re-numbering is not a
221
+ * recovery: the scope would still hold the other SQL under this version.
222
+ */
223
+ export function migrationDivergence(recorded, registered, authored) {
224
+ if (!authored || recorded === undefined || recorded === registered || recorded === MIGRATION_DIGEST_LEGACY)
225
+ return null;
226
+ if (recorded === null) {
227
+ // The fence makes this unreachable through the kernel; a row that has it anyway was not
228
+ // written by the kernel, and says nothing about what ran.
229
+ return (`this scope's journal records neither a digest nor the legacy mark for this version, so ` +
230
+ `what it ran is unknown (registered sha256 ${registered}). Rebuild the scope, or restore it ` +
231
+ `to a dump the kernel wrote (#2066)`);
232
+ }
233
+ return (`this scope applied different SQL under this version (applied sha256 ${recorded}, ` +
234
+ `registered sha256 ${registered}). Rebuild the scope, or restore it to a dump taken before ` +
235
+ `it ran the other SQL; a migration is never edited or renumbered once a scope has applied it (#2066)`);
236
+ }
237
+ //# sourceMappingURL=migration-digest.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"migration-digest.js","sourceRoot":"","sources":["../src/migration-digest.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Subject erasure inside a module's OWN tables (#2068) — the half of open question 17 that
3
+ * K-37 left named and unbuilt.
4
+ *
5
+ * `shredSubject` redacts the spine and destroys the key that seals the platform's copies. A
6
+ * module's rows were outside that, and §13.1 said so (limit 2). This file brings them in, two
7
+ * ways, both run by the kernel in the SAME transaction as the spine redaction and before the
8
+ * key is destroyed:
9
+ *
10
+ * - **Declared.** An entity names its subject columns beside its `erasable` fields
11
+ * (`erasure: { subjects }`, `subject-erasure.ts` in contracts), and the kernel blanks those
12
+ * fields — or deletes the row — wherever a subject column equals the id. No module code
13
+ * runs, so a vertical gets the right behaviour by declaring, and a declaration cannot forget
14
+ * a column the way a hand-written UPDATE can.
15
+ * - **A hook.** `onSubjectErased(ctx, { subjectId })` on the registration, for a link the row
16
+ * does not hold itself (a rating reached through its conversation). It is narrow on purpose:
17
+ * a `sql` that reaches the module's own tables and nothing else, and `now()`. No emit — an
18
+ * event about the erasure would be a new immutable copy of what is being erased — no
19
+ * check, grant, link or platform request, and no await: it is synchronous, so it runs inside
20
+ * the erasure's one transaction and nothing interleaves with it.
21
+ *
22
+ * **Failure is all or nothing.** A hook that throws rolls the whole scope-side unit back, the
23
+ * spine redaction with it, and the erasure throws before the key is touched or a receipt is
24
+ * written. Nothing claims an erasure that did not happen, and a re-run converges: a declared
25
+ * blank skips rows already blank, and a hook is idempotent by contract.
26
+ *
27
+ * **Derived stores.** A search index over an erased column is kept in step by its own triggers,
28
+ * but FTS5 keeps a deleted row's terms in older index segments until they merge — the words
29
+ * are still in the database file, findable by anyone reading the shadow tables. So the erasure
30
+ * runs with FTS5's `secure-delete` on for the indexes over the erasing modules' tables, which
31
+ * removes each entry where it sits rather than recording its deletion. A list index is an ordinary b-tree and loses the old value with the
32
+ * UPDATE. What neither reaches — free pages SQLite has not reused, and attachment text — is
33
+ * written down in kernel-design.md §13.1 rather than implied.
34
+ */
35
+ import { type ErasedEntityCount, type ErasureHookCount, type SubjectErasureDeclaration, type UnreachedEntity } from '@substrat-run/contracts';
36
+ import type { ModuleRegistration, ScopedSql } from './scope-host.js';
37
+ import type { SearchIndexPlan } from './search-index.js';
38
+ /** What a module's `onSubjectErased` hook is handed — and all it is handed. */
39
+ export interface SubjectErasureContext {
40
+ /**
41
+ * The module's own tables, and only those — the ones its migrations create: a statement
42
+ * naming any other table in the scope (another module's, a view, a TEMP object, the spine)
43
+ * is refused before it runs, reads included. Only `SELECT`, `UPDATE`, `DELETE`, `INSERT` and
44
+ * `REPLACE`; no `WITH`, no DDL, no `PRAGMA`. Revoked when the hook returns: a call after that
45
+ * throws and writes nothing.
46
+ */
47
+ readonly sql: ScopedSql;
48
+ /** The erasure's instant, the same one the spine's tombstones carry. Revoked with `sql`. */
49
+ now(): string;
50
+ }
51
+ /**
52
+ * A module's own erasure step (#2068). Synchronous and returning nothing — a promise, an
53
+ * iterator or any other returned value refuses the whole erasure — and
54
+ * idempotent: an erasure is re-run after any failure, so a second call for the same subject
55
+ * must find nothing left to do.
56
+ */
57
+ export type OnSubjectErased = (ctx: SubjectErasureContext, subject: {
58
+ readonly subjectId: string;
59
+ }) => void;
60
+ /** One registered module's erasure, validated once at registration. */
61
+ export interface ModuleErasurePlan {
62
+ readonly moduleId: string;
63
+ readonly declaration: SubjectErasureDeclaration;
64
+ readonly hook?: OnSubjectErased;
65
+ /**
66
+ * The tables this module's erasure may touch, lowercased: the declaration's, each one VERIFIED
67
+ * at registration to be created by the module's own migrations. The hook's whole reach.
68
+ */
69
+ readonly ownTables: ReadonlySet<string>;
70
+ }
71
+ /** What the module half of one erasure did — the receipt's three new lines. */
72
+ export interface ModuleErasureCounts {
73
+ verticalRows: ErasedEntityCount[];
74
+ hookRows: ErasureHookCount[];
75
+ unreachedEntities: UnreachedEntity[];
76
+ }
77
+ /**
78
+ * The tables a module's own migration TEXT creates, lowercased — read at registration, where no
79
+ * scope exists yet, as early feedback only: it can refuse a module whose erasure names a table it
80
+ * plainly never creates, and it never grants anything. `CREATE TABLE IF NOT EXISTS` on another
81
+ * module's table reads as a creation here and creates nothing in a scope, which is why the
82
+ * authority is the ownership each scope RECORDS as its migrations run (`table-ownership.ts`),
83
+ * checked by the erasure itself before it writes.
84
+ */
85
+ export declare function tablesCreatedBy(migrations: readonly {
86
+ readonly sql: string;
87
+ }[]): Set<string>;
88
+ /**
89
+ * The erasure plan for one registration, or undefined when it has nothing to erase. Throws,
90
+ * at registration, for every way a module can claim an erasure it cannot deliver, or one that
91
+ * would reach past itself:
92
+ *
93
+ * - a hook with no `manifest.erasure` — the hook's reach IS that block's `tables`, and a hook
94
+ * with no reach declared is refused rather than handed the whole scope;
95
+ * - an entity declared `custom` with no hook to reach it;
96
+ * - an entity the erasure would write (`blank`, `delete`, `custom`) on a table the module's own
97
+ * migrations do not create, and — when there is a hook — any table in its reach that they do
98
+ * not create. The declaration is a claim; the migrations are what the kernel ran. A misdeclared
99
+ * entity naming another module's table would otherwise have that table erased through the
100
+ * kernel's own handle. An `unreached` entity writes nothing and is not held to it.
101
+ */
102
+ export declare function moduleErasurePlan(registration: ModuleRegistration): ModuleErasurePlan | undefined;
103
+ /**
104
+ * Refuse a hook statement that reaches past the module's own tables (#2068).
105
+ *
106
+ * An ALLOWLIST, judged where a table can stand: every name in a table position — after `FROM`,
107
+ * `JOIN`, `INTO` and `UPDATE`, and each further name in a comma-joined `FROM` list, at any
108
+ * nesting depth — must be one of `own`, unqualified. Whatever else the scope holds — another
109
+ * module's table, a view, a TEMP object, the spine — is refused without having to be listed,
110
+ * so nothing the schema grows later widens the reach. Also refused: a qualified name
111
+ * (`main.x`, `temp.x`), a table-valued function other than `json_each`/`json_tree`, `WITH`
112
+ * (a CTE could shadow an own table's name), and any spine- or `sqlite_`-prefixed name
113
+ * anywhere. Every statement chained after a `;` starts with an allowed verb.
114
+ *
115
+ * It errs toward refusing: a name in a table position that is not a table at all
116
+ * (`a IS DISTINCT FROM b`) is held to the allowlist too, and fails closed with a message.
117
+ */
118
+ export declare function assertWithinErasureReach(moduleId: string, sql: string, own: ReadonlySet<string>): void;
119
+ /**
120
+ * The oldest SQLite that may run an erasure over a search index (#2068): FTS5 `secure-delete`
121
+ * arrived in 3.42.0, and an index deleted from under it is recorded at FTS5 format version 5,
122
+ * which no older SQLite can read. A dump never carries a search index (it is rebuilt on load),
123
+ * so the reader that matters is the runtime holding the scope's own file.
124
+ */
125
+ export declare const SECURE_DELETE_MIN_SQLITE = "3.42.0";
126
+ /**
127
+ * Run the module half of one erasure: every declared entity, then every hook, with FTS5's
128
+ * `secure-delete` on for the search indexes over those modules' tables. `sql` is the kernel's
129
+ * own handle on the scope — never module code's — and the caller holds it inside the
130
+ * transaction the spine redaction runs in, so a throw from here rolls both back.
131
+ *
132
+ * Order between the two halves: declared first, hooks after, so a hook reading a declared
133
+ * entity sees it already blank and a hook that deletes cannot strand a declared blank. Order
134
+ * between modules is registration order, which nothing here depends on: each touches its own
135
+ * tables only.
136
+ */
137
+ export declare function eraseSubjectFromModules(input: {
138
+ readonly sql: ScopedSql;
139
+ readonly plans: readonly ModuleErasurePlan[];
140
+ readonly searchPlans: Iterable<SearchIndexPlan>;
141
+ /** The module tables carrying archive/trash columns (#119), lowercased — `guardSpine`'s. */
142
+ readonly statefulTables?: ReadonlySet<string>;
143
+ readonly subjectId: string;
144
+ readonly at: string;
145
+ /** A registered module's migration text, for backfilling a scope migrated before ownership was recorded. */
146
+ readonly migrationSqlOf: (moduleId: string, version: string) => string | undefined;
147
+ }): ModuleErasureCounts;
148
+ /** Every row the module half changed — what the access log adds to "how much evidence this destroyed". */
149
+ export declare function moduleRowsErased(counts: ModuleErasureCounts): number;
150
+ /**
151
+ * Whether a host's reply carries the module half (#2068) in the shape this kernel wrote — what a
152
+ * coordinator checks before destroying the key, so a host built before the half existed is
153
+ * refused rather than read as having reached nothing.
154
+ */
155
+ export declare function isModuleErasureCounts(value: unknown): value is ModuleErasureCounts;
156
+ //# sourceMappingURL=module-erasure.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"module-erasure.d.ts","sourceRoot":"","sources":["../src/module-erasure.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,OAAO,EAKL,KAAK,iBAAiB,EACtB,KAAK,gBAAgB,EACrB,KAAK,yBAAyB,EAC9B,KAAK,eAAe,EACrB,MAAM,yBAAyB,CAAC;AACjC,OAAO,KAAK,EAAE,kBAAkB,EAAE,SAAS,EAAY,MAAM,iBAAiB,CAAC;AAC/E,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAKzD,+EAA+E;AAC/E,MAAM,WAAW,qBAAqB;IACpC;;;;;;OAMG;IACH,QAAQ,CAAC,GAAG,EAAE,SAAS,CAAC;IACxB,4FAA4F;IAC5F,GAAG,IAAI,MAAM,CAAC;CACf;AAED;;;;;GAKG;AACH,MAAM,MAAM,eAAe,GAAG,CAAC,GAAG,EAAE,qBAAqB,EAAE,OAAO,EAAE;IAAE,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;CAAE,KAAK,IAAI,CAAC;AAE5G,uEAAuE;AACvE,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,WAAW,EAAE,yBAAyB,CAAC;IAChD,QAAQ,CAAC,IAAI,CAAC,EAAE,eAAe,CAAC;IAChC;;;OAGG;IACH,QAAQ,CAAC,SAAS,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;CACzC;AAED,+EAA+E;AAC/E,MAAM,WAAW,mBAAmB;IAClC,YAAY,EAAE,iBAAiB,EAAE,CAAC;IAClC,QAAQ,EAAE,gBAAgB,EAAE,CAAC;IAC7B,iBAAiB,EAAE,eAAe,EAAE,CAAC;CACtC;AAED;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAAC,UAAU,EAAE,SAAS;IAAE,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;CAAE,EAAE,GAAG,GAAG,CAAC,MAAM,CAAC,CAW5F;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,iBAAiB,CAAC,YAAY,EAAE,kBAAkB,GAAG,iBAAiB,GAAG,SAAS,CAyCjG;AAkBD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,wBAAwB,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,WAAW,CAAC,MAAM,CAAC,GAAG,IAAI,CAqEtG;AAoCD;;;;;GAKG;AACH,eAAO,MAAM,wBAAwB,WAAW,CAAC;AA0BjD;;;;;;;;;;GAUG;AACH,wBAAgB,uBAAuB,CAAC,KAAK,EAAE;IAC7C,QAAQ,CAAC,GAAG,EAAE,SAAS,CAAC;IACxB,QAAQ,CAAC,KAAK,EAAE,SAAS,iBAAiB,EAAE,CAAC;IAC7C,QAAQ,CAAC,WAAW,EAAE,QAAQ,CAAC,eAAe,CAAC,CAAC;IAChD,4FAA4F;IAC5F,QAAQ,CAAC,cAAc,CAAC,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IAC9C,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,4GAA4G;IAC5G,QAAQ,CAAC,cAAc,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,CAAC;CACpF,GAAG,mBAAmB,CAgJtB;AAED,0GAA0G;AAC1G,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,mBAAmB,GAAG,MAAM,CAEpE;AAED;;;;GAIG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,mBAAmB,CAIlF"}