@substrat-run/kernel 0.138.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 (164) 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 +21 -2
  6. package/dist/attachment-extractor.d.ts.map +1 -1
  7. package/dist/attachment-extractor.js +31 -6
  8. package/dist/attachment-extractor.js.map +1 -1
  9. package/dist/attachment-text.d.ts +64 -0
  10. package/dist/attachment-text.d.ts.map +1 -1
  11. package/dist/attachment-text.js +87 -16
  12. package/dist/attachment-text.js.map +1 -1
  13. package/dist/attribution.d.ts +36 -3
  14. package/dist/attribution.d.ts.map +1 -1
  15. package/dist/attribution.js +21 -8
  16. package/dist/attribution.js.map +1 -1
  17. package/dist/audit-outcome.d.ts +149 -0
  18. package/dist/audit-outcome.d.ts.map +1 -0
  19. package/dist/audit-outcome.js +233 -0
  20. package/dist/audit-outcome.js.map +1 -0
  21. package/dist/connect-links.d.ts +91 -0
  22. package/dist/connect-links.d.ts.map +1 -0
  23. package/dist/connect-links.js +189 -0
  24. package/dist/connect-links.js.map +1 -0
  25. package/dist/connect-state.d.ts +12 -3
  26. package/dist/connect-state.d.ts.map +1 -1
  27. package/dist/connect-state.js +8 -3
  28. package/dist/connect-state.js.map +1 -1
  29. package/dist/derived-object.d.ts +14 -0
  30. package/dist/derived-object.d.ts.map +1 -0
  31. package/dist/derived-object.js +2 -0
  32. package/dist/derived-object.js.map +1 -0
  33. package/dist/derived-objects.d.ts +108 -0
  34. package/dist/derived-objects.d.ts.map +1 -0
  35. package/dist/derived-objects.js +199 -0
  36. package/dist/derived-objects.js.map +1 -0
  37. package/dist/email-intent.d.ts +56 -0
  38. package/dist/email-intent.d.ts.map +1 -0
  39. package/dist/email-intent.js +71 -0
  40. package/dist/email-intent.js.map +1 -0
  41. package/dist/entity-edges.d.ts +6 -1
  42. package/dist/entity-edges.d.ts.map +1 -1
  43. package/dist/entity-edges.js +13 -0
  44. package/dist/entity-edges.js.map +1 -1
  45. package/dist/entity-grant-shape.d.ts +94 -0
  46. package/dist/entity-grant-shape.d.ts.map +1 -0
  47. package/dist/entity-grant-shape.js +329 -0
  48. package/dist/entity-grant-shape.js.map +1 -0
  49. package/dist/entity-grant.d.ts +45 -0
  50. package/dist/entity-grant.d.ts.map +1 -0
  51. package/dist/entity-grant.js +71 -0
  52. package/dist/entity-grant.js.map +1 -0
  53. package/dist/entity-state-reads.d.ts +40 -0
  54. package/dist/entity-state-reads.d.ts.map +1 -0
  55. package/dist/entity-state-reads.js +91 -0
  56. package/dist/entity-state-reads.js.map +1 -0
  57. package/dist/entity-state.d.ts +204 -0
  58. package/dist/entity-state.d.ts.map +1 -0
  59. package/dist/entity-state.js +356 -0
  60. package/dist/entity-state.js.map +1 -0
  61. package/dist/entity-trash.d.ts +245 -0
  62. package/dist/entity-trash.d.ts.map +1 -0
  63. package/dist/entity-trash.js +380 -0
  64. package/dist/entity-trash.js.map +1 -0
  65. package/dist/findings.d.ts +143 -0
  66. package/dist/findings.d.ts.map +1 -0
  67. package/dist/findings.js +463 -0
  68. package/dist/findings.js.map +1 -0
  69. package/dist/grant-scoped-read.d.ts +41 -0
  70. package/dist/grant-scoped-read.d.ts.map +1 -0
  71. package/dist/grant-scoped-read.js +100 -0
  72. package/dist/grant-scoped-read.js.map +1 -0
  73. package/dist/index.d.ts +40 -8
  74. package/dist/index.d.ts.map +1 -1
  75. package/dist/index.js +26 -6
  76. package/dist/index.js.map +1 -1
  77. package/dist/kernel-outbox.d.ts +14 -0
  78. package/dist/kernel-outbox.d.ts.map +1 -0
  79. package/dist/kernel-outbox.js +37 -0
  80. package/dist/kernel-outbox.js.map +1 -0
  81. package/dist/list-index.d.ts +34 -3
  82. package/dist/list-index.d.ts.map +1 -1
  83. package/dist/list-index.js +83 -31
  84. package/dist/list-index.js.map +1 -1
  85. package/dist/mail-sender.d.ts +78 -0
  86. package/dist/mail-sender.d.ts.map +1 -0
  87. package/dist/mail-sender.js +2 -0
  88. package/dist/mail-sender.js.map +1 -0
  89. package/dist/membership-executor.d.ts +7 -47
  90. package/dist/membership-executor.d.ts.map +1 -1
  91. package/dist/membership-executor.js +28 -53
  92. package/dist/membership-executor.js.map +1 -1
  93. package/dist/migration-digest.d.ts +161 -0
  94. package/dist/migration-digest.d.ts.map +1 -0
  95. package/dist/migration-digest.js +237 -0
  96. package/dist/migration-digest.js.map +1 -0
  97. package/dist/module-erasure.d.ts +156 -0
  98. package/dist/module-erasure.d.ts.map +1 -0
  99. package/dist/module-erasure.js +440 -0
  100. package/dist/module-erasure.js.map +1 -0
  101. package/dist/module-migrations.d.ts +6 -3
  102. package/dist/module-migrations.d.ts.map +1 -1
  103. package/dist/module-migrations.js +12 -5
  104. package/dist/module-migrations.js.map +1 -1
  105. package/dist/peer.d.ts +9 -1
  106. package/dist/peer.d.ts.map +1 -1
  107. package/dist/peer.js +29 -0
  108. package/dist/peer.js.map +1 -1
  109. package/dist/permission-checker.d.ts +6 -0
  110. package/dist/permission-checker.d.ts.map +1 -1
  111. package/dist/permission-checker.js.map +1 -1
  112. package/dist/permission-eval.d.ts +51 -1
  113. package/dist/permission-eval.d.ts.map +1 -1
  114. package/dist/permission-eval.js +104 -2
  115. package/dist/permission-eval.js.map +1 -1
  116. package/dist/platform-sweep.d.ts +8 -2
  117. package/dist/platform-sweep.d.ts.map +1 -1
  118. package/dist/platform-sweep.js +17 -0
  119. package/dist/platform-sweep.js.map +1 -1
  120. package/dist/scope-host.d.ts +479 -24
  121. package/dist/scope-host.d.ts.map +1 -1
  122. package/dist/scope-host.js +82 -7
  123. package/dist/scope-host.js.map +1 -1
  124. package/dist/scope-role-admin.d.ts +68 -0
  125. package/dist/scope-role-admin.d.ts.map +1 -0
  126. package/dist/scope-role-admin.js +80 -0
  127. package/dist/scope-role-admin.js.map +1 -0
  128. package/dist/search-index.d.ts +21 -1
  129. package/dist/search-index.d.ts.map +1 -1
  130. package/dist/search-index.js +60 -37
  131. package/dist/search-index.js.map +1 -1
  132. package/dist/spine-guard.d.ts +71 -2
  133. package/dist/spine-guard.d.ts.map +1 -1
  134. package/dist/spine-guard.js +393 -10
  135. package/dist/spine-guard.js.map +1 -1
  136. package/dist/spine-restore.d.ts.map +1 -1
  137. package/dist/spine-restore.js +5 -0
  138. package/dist/spine-restore.js.map +1 -1
  139. package/dist/sql-identifier.d.ts +12 -0
  140. package/dist/sql-identifier.d.ts.map +1 -0
  141. package/dist/sql-identifier.js +17 -0
  142. package/dist/sql-identifier.js.map +1 -0
  143. package/dist/sql-statements.d.ts +71 -0
  144. package/dist/sql-statements.d.ts.map +1 -0
  145. package/dist/sql-statements.js +191 -0
  146. package/dist/sql-statements.js.map +1 -0
  147. package/dist/subject-redaction.d.ts +9 -1
  148. package/dist/subject-redaction.d.ts.map +1 -1
  149. package/dist/subject-redaction.js.map +1 -1
  150. package/dist/system-switch-record.d.ts +3 -0
  151. package/dist/system-switch-record.d.ts.map +1 -1
  152. package/dist/system-switch-record.js +2 -0
  153. package/dist/system-switch-record.js.map +1 -1
  154. package/dist/table-ownership.d.ts +103 -0
  155. package/dist/table-ownership.d.ts.map +1 -0
  156. package/dist/table-ownership.js +285 -0
  157. package/dist/table-ownership.js.map +1 -0
  158. package/dist/timeline.d.ts +4 -4
  159. package/dist/timeline.js +4 -4
  160. package/dist/visible-continuation.d.ts +38 -0
  161. package/dist/visible-continuation.d.ts.map +1 -0
  162. package/dist/visible-continuation.js +162 -0
  163. package/dist/visible-continuation.js.map +1 -0
  164. package/package.json +2 -2
@@ -0,0 +1,245 @@
1
+ /**
2
+ * The trash, held by the host (#119 PR 2) — the half of K-45 a handler used to have to remember.
3
+ *
4
+ * Three things, written once for both adapters, the way `entity-state.ts` is:
5
+ *
6
+ * 1. **The refusal.** An operation that addresses an entity by id (`permission: { entity,
7
+ * idFrom }`) is refused on a TRASHED one before its guards and handler run, unless it
8
+ * declares `trashed: 'admits'` or `'purges'`. PR 1's review found handlers forgetting the
9
+ * per-handler check (`list-shares`, `revoke-share`); this makes it a mechanism.
10
+ * 2. **The purge horizon.** An entity declaring `trash.purgeAfterDays` is permanently deleted
11
+ * once it has been in the bin that long, by the sweep running the module's own
12
+ * `trashed: 'purges'` operation as a derived schedule (`purgeSchedulesOf` in contracts), one
13
+ * entity per transaction.
14
+ * 3. **Registration.** The checks that make both of those true of a module rather than of the
15
+ * modules that remembered a line.
16
+ *
17
+ * ## What the refusal does not reach, stated
18
+ *
19
+ * An operation whose check is `resolved` in the handler, narrowed by `refFrom`, or a `narrows`
20
+ * walk has no entity the host can read off its input, so it is not refused here and keeps its
21
+ * own `ctx.entityState` check. `trashRefusalGapsOf` names them and `lint:model` prints them.
22
+ */
23
+ import { type EntityRef, type EntityStateDeclaration, type OperationTarget, type ScheduleSpec } from '@substrat-run/contracts';
24
+ import { type EntityStatePlan, type StateCheck } from './entity-state.js';
25
+ import type { ScopedSql } from './scope-host.js';
26
+ import type { SystemScheduleState } from './system-switch.js';
27
+ /** How many entities one purge pass deletes per entity type. A full batch leaves the schedule due. */
28
+ export declare const PURGE_BATCH = 50;
29
+ /**
30
+ * Is `entity` out of reach as a parent (#119): its type declares trash, and it is in the trash or
31
+ * does not exist. The two answer as ONE, so `ctx.link` cannot be used to tell a binned entity from
32
+ * a missing one. `false` for a type that declares no trash — the kernel never read those rows
33
+ * before, and linking to one is unchanged.
34
+ */
35
+ export declare function isUnreachableParent(sql: ScopedSql, plans: ReadonlyMap<string, EntityStatePlan>, entity: EntityRef): boolean;
36
+ /** What the refusal needs from the adapter, inside the operation's own transaction. */
37
+ export interface TrashRefusalDeps {
38
+ /** RAW access — the state columns are read, never written, but the guarded seam is module code's. */
39
+ readonly sql: ScopedSql;
40
+ readonly plans: ReadonlyMap<string, EntityStatePlan>;
41
+ /** The operation's own check, so a pass is one of its authorizations and a denial is recorded as its own. */
42
+ readonly check: StateCheck;
43
+ }
44
+ /**
45
+ * Refuse an operation reaching a trashed entity it did not declare it reaches (#119).
46
+ *
47
+ * Runs after `BEGIN` and before the guards and the handler, for every caller and every door.
48
+ *
49
+ * **The order is the security property.** On a trashed entity the operation's DECLARED key is
50
+ * checked first, exactly as the handler's first line would have: a caller without it gets the
51
+ * `forbidden` an active entity would have given them, so the bin is not something they can
52
+ * probe. Only a caller who holds the key learns it is gone — `not_found`, the answer the
53
+ * per-handler check gave. An active entity is never checked here; the handler does it, as
54
+ * always, so a pass is never recorded twice.
55
+ *
56
+ * `purge` is set only by the host's own purge sweep (`InvokeOptions` carries no such field, and an
57
+ * adapter refuses a call that tries to supply one). The CUTOFF is computed here, from `now` — the
58
+ * host's clock — and the entity's declared horizon, never taken from a caller: the entity must
59
+ * still be trashed at or before it, or the purge is `conflict` (`purge_not_due`). So a restore
60
+ * that landed between the sweep's selection and this turn wins, and nothing can purge early.
61
+ */
62
+ export declare function refuseTrashedTarget(deps: TrashRefusalDeps, operation: string, target: OperationTarget | undefined, input: unknown, purge?: {
63
+ readonly now: string;
64
+ readonly gate: PurgeGateFacts;
65
+ }): Promise<void>;
66
+ /**
67
+ * The keys a system principal's checks refuse on this call (#119): its module's purge-only keys,
68
+ * unless the call is the host's own purge sweep invoking the purge operation. `undefined` —
69
+ * nothing withheld — for any other subject, and for a module with no purge-only key.
70
+ */
71
+ export declare function withheldKeysFor(purgeOnlyKeys: ReadonlySet<string> | undefined, target: OperationTarget | undefined, purging: boolean): ReadonlySet<string> | undefined;
72
+ /**
73
+ * Refuse an invoke that tries to supply a purge from outside (#119). Purge authority comes only from
74
+ * the host's own sweep, through a path no caller can construct; an options object carrying the
75
+ * field is somebody trying, and is told so rather than silently ignored.
76
+ */
77
+ export declare function assertNoCallerPurge(options: object | undefined): void;
78
+ /** The latest trash instant still due for purge at `now` — `now` minus the horizon. */
79
+ export declare function purgeCutoffOf(now: string, days: number): string;
80
+ /** A position in the purge walk's order: the trash instant and the id of the last entity tried. */
81
+ export interface PurgeKey {
82
+ readonly at: string;
83
+ readonly id: string;
84
+ }
85
+ /**
86
+ * The ids due for purge: trashed at or before `cutoff`, oldest trash first, at most `limit` —
87
+ * and, given `after`, only those strictly after it in that order (#2096). The predicate leads with
88
+ * the purge index's own `WHERE`, so the walk reads only the bin; `after` is compared as a VALUE,
89
+ * so a key whose entity has since been purged or restored still places the walk correctly.
90
+ */
91
+ export declare function purgeCandidates(sql: ScopedSql, plan: EntityStatePlan, cutoff: string, limit?: number, after?: PurgeKey | null): PurgeKey[];
92
+ /**
93
+ * A purge LAP in progress (#2096) — what a schedule's cadence row holds in `purge_cursor` between
94
+ * passes. A lap walks the whole due bin, oldest first, `PURGE_BATCH` at a time; each pass resumes
95
+ * after the last entity the one before it tried, whatever became of that entity. So entities that
96
+ * keep failing cannot hold the head of the walk: the walk moves past them, reaches everything due
97
+ * behind them in the same lap, and tries them again in the next.
98
+ *
99
+ * `failed` counts the purges that failed in the lap's earlier passes, so the pass that closes the
100
+ * lap — the one that writes the cadence row — records the lap `failed` even when its own batch
101
+ * succeeded.
102
+ */
103
+ export interface PurgeLap {
104
+ /** Where the next pass resumes, or null at the start of a lap. */
105
+ readonly after: PurgeKey | null;
106
+ readonly failed: number;
107
+ }
108
+ /**
109
+ * The lap a stored `purge_cursor` holds. NULL — no lap in progress, a row written before the
110
+ * column, or no row at all — is the start of a lap, and so is a value this code cannot read: the
111
+ * worst a lost cursor costs is walking from the oldest again.
112
+ */
113
+ export declare function purgeLapOf(stored: string | null | undefined): PurgeLap;
114
+ /** Read the purge schedule's lap from its cadence row (`kind = 'schedule'`, keyed by the operation). */
115
+ export declare function readPurgeLap(sql: ScopedSql, operation: string): PurgeLap;
116
+ /** One purge horizon's due work on a scope, for one pass of its lap. */
117
+ export interface PurgeDue {
118
+ readonly cutoff: string;
119
+ /** The purge operation's input field carrying the id. */
120
+ readonly idFrom: string;
121
+ /** The ids this pass tries, in the walk's order. */
122
+ readonly ids: string[];
123
+ /** The lap as this pass found it. */
124
+ readonly lap: PurgeLap;
125
+ /** Where the next pass resumes when more is due after these ids — or null: this pass closes the lap. */
126
+ readonly next: PurgeKey | null;
127
+ }
128
+ /**
129
+ * One purge horizon's due work on a scope (#119, #2096): the cutoff, the input field carrying the
130
+ * id, and the next `limit` due ids of the lap the cadence row records — read one past the batch, so
131
+ * `more` is known rather than guessed from a full batch. Registration has tied the schedule to a
132
+ * declared horizon and the entity's purge operation, so a miss here is a wiring fault.
133
+ */
134
+ export declare function purgeDueOf(sql: ScopedSql, plans: ReadonlyMap<string, EntityStatePlan>, targets: ReadonlyMap<string, OperationTarget>, operation: string, entityType: string, now: string, limit?: number): PurgeDue;
135
+ /**
136
+ * What the purge sweep's gate reads (#119), each fact from the scope's OWN state as the adapter
137
+ * holds it — never from the caller. The adapter reads them before the sweep selects anything and
138
+ * again inside each purge's transaction, and both go through `purgeHeldBy`.
139
+ */
140
+ export interface PurgeGateFacts {
141
+ /** The module's kill switch on this scope (`systemScheduleState`). */
142
+ readonly switched: SystemScheduleState;
143
+ /** The scope's lifecycle refusal (`lifecycleRefusal`), or null when it may run or none is held. */
144
+ readonly lifecycle: string | null;
145
+ /** Whether the scope is a copy — a fork, a snapshot or a preview — rather than the primary install. */
146
+ readonly copy: boolean;
147
+ /** Whether the tenant the sweep names is foreign to the scope's own record (`tenantVerdict`). */
148
+ readonly foreignTenant: boolean;
149
+ }
150
+ /**
151
+ * THE purge sweep's gate (#119): why this scope may purge nothing now, or null when it may. The
152
+ * same holds the coordinator applies before any schedule fires, applied by the scope itself from
153
+ * whatever of them it records (below: on a directory-backed host, the kill switch). A tenant foreign to the scope is a REFUSAL (`not_found`,
154
+ * the doors' answer for a pair that does not hold), not a hold: nothing about it is "not yet".
155
+ *
156
+ * On a directory-backed host the scope DO enforces the kill switch; tenant, lifecycle, copy and
157
+ * rewind holds are enforced by the coordinator. The scope holds no tenant receipt, lifecycle or copy
158
+ * classification of its own there (the directory is their authority), so those facts read clear —
159
+ * an unrecorded tenant is `unknown`, which passes as at every door.
160
+ */
161
+ export declare function purgeHeldBy(facts: PurgeGateFacts): string | null;
162
+ /** What one purge schedule did on one scope in one pass. */
163
+ export interface PurgePass {
164
+ /** Entities the operation deleted. */
165
+ purged: number;
166
+ /** Entities restored or already gone by their turn — not failures. */
167
+ skipped: number;
168
+ /** One entry per entity whose purge threw for any other reason; it stays in the bin and is retried. */
169
+ errors: {
170
+ entityId: string;
171
+ error: string;
172
+ }[];
173
+ /** More is due after this batch: the lap continues, and the schedule stays due (`purgeStillDue`). */
174
+ more: boolean;
175
+ /**
176
+ * Purges that failed in the EARLIER passes of the lap this pass closed (#2096) — so the cadence
177
+ * row this pass writes says the lap failed, even when this batch did not. 0 while the lap goes on.
178
+ * Absent, like `more`, from a scope running code older than the lap, which ran no lap.
179
+ */
180
+ lapFailed?: number;
181
+ /** Why the scope ran no purge at all this pass (`purgeHeldBy`), when its gate held it. */
182
+ held?: string;
183
+ }
184
+ /** A pass the scope's gate held: nothing selected, nothing tried, the lap where it was. */
185
+ export declare function heldPurgePass(held: string): PurgePass;
186
+ /**
187
+ * Run one pass of a purge schedule over the ids the adapter selected (`purgeDueOf`), then record
188
+ * what it did to its lap (#2096). One invoke per entity, each its own transaction, so a crash or a
189
+ * failure loses nothing already committed and the next pass simply selects what is left. An entity
190
+ * that was restored (`purge_not_due`) or is already gone (`not_found`) is skipped, never a failure —
191
+ * the purge is idempotent by state.
192
+ *
193
+ * Then the lap moves on WHATEVER its purges did — that is the whole of #2096: resume after this
194
+ * batch with its failures added while more is due, or close the lap. After the pass, so a pass cut
195
+ * short repeats its batch rather than skipping it. `inScope` runs the write against the scope's
196
+ * spine, in the turn the adapter's storage needs.
197
+ */
198
+ export declare function runPurgePass(operation: string, due: PurgeDue, purgeOne: (entityId: string) => Promise<void>, inScope: (write: (sql: ScopedSql) => void) => unknown): Promise<PurgePass>;
199
+ /**
200
+ * Whether a purge pass leaves its schedule due, so the next sweep pass continues its lap rather
201
+ * than waiting the cadence: more is due after its batch (#2096). It cannot spin (#2087): every pass
202
+ * moves the lap strictly forward, whatever its purges did, and the pass that runs out of due
203
+ * entities closes the lap and records the run — so a cadence window holds one lap, and an entity
204
+ * that keeps failing is tried once per window, not once per sweep tick. A scope running code older
205
+ * than the lap answers no `more`, and its pass waits its cadence.
206
+ */
207
+ export declare function purgeStillDue(pass: PurgePass): boolean;
208
+ /**
209
+ * The keys a module's system principal holds ONLY for its purge schedules (#119): declared by a
210
+ * purge schedule and by no other. Withheld from every system-principal check except the purge
211
+ * sweep's own invoke of the purge operation, so the scope-wide grant seated for a purge cannot
212
+ * run anything else — another operation checking the same key, a job step, a host-level call.
213
+ */
214
+ export declare function purgeOnlyKeysOf(schedules: readonly ScheduleSpec[]): ReadonlySet<string> | undefined;
215
+ /**
216
+ * A purge pass, as the schedule run reports it: one error row per entity that failed (labelled
217
+ * with its id), and the error that marks the schedule's run `failed` when any did — in this pass,
218
+ * or in an earlier pass of the lap this one closed (#2096), so the cadence row keeps the failure.
219
+ */
220
+ export declare function purgeReportOf(operation: string, entityType: string, pass: PurgePass): {
221
+ errors: {
222
+ operation: string;
223
+ error: string;
224
+ }[];
225
+ failure?: Error;
226
+ };
227
+ /**
228
+ * Hold one module's registration to the trash rules (#119), and answer the targets the host
229
+ * keeps. Refuses, never skips — each of these is a binned entity reachable while the module
230
+ * believes it is not:
231
+ *
232
+ * The targets are DERIVED, never handed over: `operationInputsOf(ops)` records the declared
233
+ * surface of the frozen map it returns (`declaredSurfaceOf`, which nothing else can write), and the
234
+ * host reads each operation's target off the same declaration it parses with. A module with a trashable entity must therefore
235
+ * - pass `operationInputs` built by `operationInputsOf`, so there is a declared surface to read;
236
+ * - declare every operation it binds there — an undeclared one could address a binned entity and
237
+ * the host could not see which;
238
+ * - declare a `trashed: 'purges'` operation whose input is a strict object holding the id and nothing else.
239
+ *
240
+ * Also refused: an opt-in on an entity that declares no trash here, a purge horizon without
241
+ * exactly the schedule `purgeSchedulesOf` derives for it, and a purge schedule that does not run
242
+ * the entity's `trashed: 'purges'` operation.
243
+ */
244
+ export declare function registerTrashTargets(moduleId: string, ownOps: ReadonlySet<string>, operationInputs: Readonly<Record<string, unknown>> | undefined, entityStates: readonly EntityStateDeclaration[] | undefined, schedules: readonly ScheduleSpec[] | undefined): Map<string, OperationTarget>;
245
+ //# sourceMappingURL=entity-trash.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"entity-trash.d.ts","sourceRoot":"","sources":["../src/entity-trash.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,380 @@
1
+ /**
2
+ * The trash, held by the host (#119 PR 2) — the half of K-45 a handler used to have to remember.
3
+ *
4
+ * Three things, written once for both adapters, the way `entity-state.ts` is:
5
+ *
6
+ * 1. **The refusal.** An operation that addresses an entity by id (`permission: { entity,
7
+ * idFrom }`) is refused on a TRASHED one before its guards and handler run, unless it
8
+ * declares `trashed: 'admits'` or `'purges'`. PR 1's review found handlers forgetting the
9
+ * per-handler check (`list-shares`, `revoke-share`); this makes it a mechanism.
10
+ * 2. **The purge horizon.** An entity declaring `trash.purgeAfterDays` is permanently deleted
11
+ * once it has been in the bin that long, by the sweep running the module's own
12
+ * `trashed: 'purges'` operation as a derived schedule (`purgeSchedulesOf` in contracts), one
13
+ * entity per transaction.
14
+ * 3. **Registration.** The checks that make both of those true of a module rather than of the
15
+ * modules that remembered a line.
16
+ *
17
+ * ## What the refusal does not reach, stated
18
+ *
19
+ * An operation whose check is `resolved` in the handler, narrowed by `refFrom`, or a `narrows`
20
+ * walk has no entity the host can read off its input, so it is not refused here and keeps its
21
+ * own `ctx.entityState` check. `trashRefusalGapsOf` names them and `lint:model` prints them.
22
+ */
23
+ import { TRASHED_AT_COLUMN, declaredSurfaceOf, errorCodeOf, isStrictObjectSchema, substratError, } from '@substrat-run/contracts';
24
+ import { assertAllowed } from './permission-checker.js';
25
+ import { readStateRow } from './entity-state.js';
26
+ /** How many entities one purge pass deletes per entity type. A full batch leaves the schedule due. */
27
+ export const PURGE_BATCH = 50;
28
+ /**
29
+ * Is `entity` out of reach as a parent (#119): its type declares trash, and it is in the trash or
30
+ * does not exist. The two answer as ONE, so `ctx.link` cannot be used to tell a binned entity from
31
+ * a missing one. `false` for a type that declares no trash — the kernel never read those rows
32
+ * before, and linking to one is unchanged.
33
+ */
34
+ export function isUnreachableParent(sql, plans, entity) {
35
+ const plan = plans.get(entity.entityType);
36
+ if (!plan?.trashPermission)
37
+ return false;
38
+ const row = readStateRow(sql, plan, entity.entityId);
39
+ return !row || row.trashed_at !== null;
40
+ }
41
+ /**
42
+ * Refuse an operation reaching a trashed entity it did not declare it reaches (#119).
43
+ *
44
+ * Runs after `BEGIN` and before the guards and the handler, for every caller and every door.
45
+ *
46
+ * **The order is the security property.** On a trashed entity the operation's DECLARED key is
47
+ * checked first, exactly as the handler's first line would have: a caller without it gets the
48
+ * `forbidden` an active entity would have given them, so the bin is not something they can
49
+ * probe. Only a caller who holds the key learns it is gone — `not_found`, the answer the
50
+ * per-handler check gave. An active entity is never checked here; the handler does it, as
51
+ * always, so a pass is never recorded twice.
52
+ *
53
+ * `purge` is set only by the host's own purge sweep (`InvokeOptions` carries no such field, and an
54
+ * adapter refuses a call that tries to supply one). The CUTOFF is computed here, from `now` — the
55
+ * host's clock — and the entity's declared horizon, never taken from a caller: the entity must
56
+ * still be trashed at or before it, or the purge is `conflict` (`purge_not_due`). So a restore
57
+ * that landed between the sweep's selection and this turn wins, and nothing can purge early.
58
+ */
59
+ export async function refuseTrashedTarget(deps, operation, target, input, purge) {
60
+ if (purge && target?.trashed !== 'purges') {
61
+ throw substratError('internal', `${operation} is not a purge — only a \`trashed: 'purges'\` operation is run by the purge sweep`);
62
+ }
63
+ // A purge reaches one entity, whoever runs it: its PARSED input is the id and nothing else.
64
+ // Registration requires a strict schema; this holds the parse result to it as well.
65
+ if (target?.trashed === 'purges')
66
+ assertPurgeInput(operation, target, input);
67
+ // An operation that opted in reaches the bin as it is; only a purge re-checks its cutoff.
68
+ if (!target || (target.trashed && !purge))
69
+ return;
70
+ const plan = deps.plans.get(target.entity);
71
+ if (!plan?.trashPermission)
72
+ return;
73
+ const id = input?.[target.idFrom];
74
+ if (typeof id !== 'string')
75
+ return;
76
+ const row = readStateRow(deps.sql, plan, id);
77
+ if (purge) {
78
+ // The sweep's gate again, inside this purge's own transaction: a switch pulled, a lifecycle
79
+ // hold or a copy classification that landed after the sweep began stops this purge here.
80
+ const held = purgeHeldBy(purge.gate);
81
+ if (held !== null)
82
+ throw substratError('conflict', `${operation}: ${held}`, { reason: 'purge_held' });
83
+ if (plan.purgeAfterDays === undefined) {
84
+ throw substratError('internal', `${operation}: '${target.entity}' declares no purge horizon`);
85
+ }
86
+ const purgeCutoff = purgeCutoffOf(purge.now, plan.purgeAfterDays);
87
+ if (!row)
88
+ throw substratError('not_found', `${target.entity} not found: ${id}`);
89
+ if (row.trashed_at === null || row.trashed_at > purgeCutoff) {
90
+ throw substratError('conflict', `${operation}: ${target.entity}:${id} is no longer due for purge`, {
91
+ reason: 'purge_not_due',
92
+ });
93
+ }
94
+ return;
95
+ }
96
+ if (!row || row.trashed_at === null)
97
+ return;
98
+ assertAllowed(await deps.check(target.key, { entityType: target.entity, entityId: id }));
99
+ throw substratError('not_found', `${target.entity} not found: ${id}`);
100
+ }
101
+ /** The parsed input of a `trashed: 'purges'` operation: a plain object holding exactly its id. */
102
+ function assertPurgeInput(operation, target, input) {
103
+ const keys = typeof input === 'object' && input !== null && !Array.isArray(input) ? Object.keys(input) : undefined;
104
+ if (keys?.length === 1 && keys[0] === target.idFrom)
105
+ return;
106
+ throw substratError('validation_failed', `${operation} purges one ${target.entity}: its input is '${target.idFrom}' and nothing else`, {
107
+ errors: [{ path: '', message: `expected exactly { ${target.idFrom} }` }],
108
+ });
109
+ }
110
+ /**
111
+ * The keys a system principal's checks refuse on this call (#119): its module's purge-only keys,
112
+ * unless the call is the host's own purge sweep invoking the purge operation. `undefined` —
113
+ * nothing withheld — for any other subject, and for a module with no purge-only key.
114
+ */
115
+ export function withheldKeysFor(purgeOnlyKeys, target, purging) {
116
+ return purgeOnlyKeys && !(purging && target?.trashed === 'purges') ? purgeOnlyKeys : undefined;
117
+ }
118
+ /**
119
+ * Refuse an invoke that tries to supply a purge from outside (#119). Purge authority comes only from
120
+ * the host's own sweep, through a path no caller can construct; an options object carrying the
121
+ * field is somebody trying, and is told so rather than silently ignored.
122
+ */
123
+ export function assertNoCallerPurge(options) {
124
+ if (options && 'purgeCutoff' in options) {
125
+ throw substratError('validation_failed', 'purgeCutoff is not an invoke option — only the platform\'s purge sweep purges', {
126
+ errors: [{ path: 'purgeCutoff', message: 'not an invoke option' }],
127
+ });
128
+ }
129
+ }
130
+ /** The latest trash instant still due for purge at `now` — `now` minus the horizon. */
131
+ export function purgeCutoffOf(now, days) {
132
+ return new Date(Date.parse(now) - days * 86_400_000).toISOString();
133
+ }
134
+ /**
135
+ * The ids due for purge: trashed at or before `cutoff`, oldest trash first, at most `limit` —
136
+ * and, given `after`, only those strictly after it in that order (#2096). The predicate leads with
137
+ * the purge index's own `WHERE`, so the walk reads only the bin; `after` is compared as a VALUE,
138
+ * so a key whose entity has since been purged or restored still places the walk correctly.
139
+ */
140
+ export function purgeCandidates(sql, plan, cutoff, limit = PURGE_BATCH, after = null) {
141
+ return sql
142
+ .query(`SELECT ${plan.idColumn} AS id, ${TRASHED_AT_COLUMN} AS at FROM ${plan.table} ` +
143
+ `WHERE ${TRASHED_AT_COLUMN} IS NOT NULL AND ${TRASHED_AT_COLUMN} <= ? ` +
144
+ (after ? `AND (${TRASHED_AT_COLUMN}, ${plan.idColumn}) > (?, ?) ` : '') +
145
+ `ORDER BY ${TRASHED_AT_COLUMN}, ${plan.idColumn} LIMIT ?`, after ? [cutoff, after.at, after.id, limit] : [cutoff, limit])
146
+ .map((r) => ({ id: String(r.id), at: String(r.at) }));
147
+ }
148
+ const LAP_START = { after: null, failed: 0 };
149
+ /**
150
+ * The lap a stored `purge_cursor` holds. NULL — no lap in progress, a row written before the
151
+ * column, or no row at all — is the start of a lap, and so is a value this code cannot read: the
152
+ * worst a lost cursor costs is walking from the oldest again.
153
+ */
154
+ export function purgeLapOf(stored) {
155
+ if (stored === null || stored === undefined)
156
+ return LAP_START;
157
+ try {
158
+ const v = JSON.parse(stored);
159
+ if (typeof v.at !== 'string' || typeof v.id !== 'string')
160
+ return LAP_START;
161
+ const failed = typeof v.failed === 'number' && Number.isSafeInteger(v.failed) && v.failed > 0 ? v.failed : 0;
162
+ return { after: { at: v.at, id: v.id }, failed };
163
+ }
164
+ catch {
165
+ return LAP_START;
166
+ }
167
+ }
168
+ /** Read the purge schedule's lap from its cadence row (`kind = 'schedule'`, keyed by the operation). */
169
+ export function readPurgeLap(sql, operation) {
170
+ const [row] = sql.query(`SELECT purge_cursor FROM _substrat_schedule_state WHERE kind = 'schedule' AND schedule_op = ?`, [operation]);
171
+ return purgeLapOf(row?.purge_cursor);
172
+ }
173
+ /** Store a lap's next start (NULL when none), touching `purge_cursor` alone — see `runPurgePass`. */
174
+ function writePurgeLap(sql, operation, lap) {
175
+ sql.exec(`INSERT INTO _substrat_schedule_state (kind, schedule_op, purge_cursor) VALUES ('schedule', ?, ?)
176
+ ON CONFLICT(kind, schedule_op) DO UPDATE SET purge_cursor = excluded.purge_cursor`, [operation, lap?.after ? JSON.stringify({ at: lap.after.at, id: lap.after.id, failed: lap.failed }) : null]);
177
+ }
178
+ /**
179
+ * One purge horizon's due work on a scope (#119, #2096): the cutoff, the input field carrying the
180
+ * id, and the next `limit` due ids of the lap the cadence row records — read one past the batch, so
181
+ * `more` is known rather than guessed from a full batch. Registration has tied the schedule to a
182
+ * declared horizon and the entity's purge operation, so a miss here is a wiring fault.
183
+ */
184
+ export function purgeDueOf(sql, plans, targets, operation, entityType, now, limit = PURGE_BATCH) {
185
+ const plan = plans.get(entityType);
186
+ const target = targets.get(operation);
187
+ if (plan?.purgeAfterDays === undefined || !target) {
188
+ throw new Error(`purge: '${operation}' is not the purge operation of a horizon on '${entityType}'`);
189
+ }
190
+ const cutoff = purgeCutoffOf(now, plan.purgeAfterDays);
191
+ const lap = readPurgeLap(sql, operation);
192
+ const keys = purgeCandidates(sql, plan, cutoff, limit + 1, lap.after);
193
+ const batch = keys.slice(0, limit);
194
+ return {
195
+ cutoff,
196
+ idFrom: target.idFrom,
197
+ ids: batch.map((k) => k.id),
198
+ lap,
199
+ next: keys.length > limit ? batch.at(-1) : null,
200
+ };
201
+ }
202
+ /**
203
+ * THE purge sweep's gate (#119): why this scope may purge nothing now, or null when it may. The
204
+ * same holds the coordinator applies before any schedule fires, applied by the scope itself from
205
+ * whatever of them it records (below: on a directory-backed host, the kill switch). A tenant foreign to the scope is a REFUSAL (`not_found`,
206
+ * the doors' answer for a pair that does not hold), not a hold: nothing about it is "not yet".
207
+ *
208
+ * On a directory-backed host the scope DO enforces the kill switch; tenant, lifecycle, copy and
209
+ * rewind holds are enforced by the coordinator. The scope holds no tenant receipt, lifecycle or copy
210
+ * classification of its own there (the directory is their authority), so those facts read clear —
211
+ * an unrecorded tenant is `unknown`, which passes as at every door.
212
+ */
213
+ export function purgeHeldBy(facts) {
214
+ if (facts.foreignTenant)
215
+ throw substratError('not_found', 'purge refused: the tenant named is not this scope\'s');
216
+ if (facts.switched !== 'on')
217
+ return `the module's schedules are ${facts.switched === 'off' ? 'switched off' : 'not granted'} on this scope`;
218
+ if (facts.lifecycle !== null)
219
+ return facts.lifecycle;
220
+ if (facts.copy)
221
+ return 'this scope is a copy, and a copy never purges';
222
+ return null;
223
+ }
224
+ /** A pass the scope's gate held: nothing selected, nothing tried, the lap where it was. */
225
+ export function heldPurgePass(held) {
226
+ return { purged: 0, skipped: 0, errors: [], more: false, lapFailed: 0, held };
227
+ }
228
+ /**
229
+ * Run one pass of a purge schedule over the ids the adapter selected (`purgeDueOf`), then record
230
+ * what it did to its lap (#2096). One invoke per entity, each its own transaction, so a crash or a
231
+ * failure loses nothing already committed and the next pass simply selects what is left. An entity
232
+ * that was restored (`purge_not_due`) or is already gone (`not_found`) is skipped, never a failure —
233
+ * the purge is idempotent by state.
234
+ *
235
+ * Then the lap moves on WHATEVER its purges did — that is the whole of #2096: resume after this
236
+ * batch with its failures added while more is due, or close the lap. After the pass, so a pass cut
237
+ * short repeats its batch rather than skipping it. `inScope` runs the write against the scope's
238
+ * spine, in the turn the adapter's storage needs.
239
+ */
240
+ export async function runPurgePass(operation, due, purgeOne, inScope) {
241
+ const more = due.next !== null;
242
+ const pass = { purged: 0, skipped: 0, errors: [], more, lapFailed: more ? 0 : due.lap.failed };
243
+ for (const entityId of due.ids) {
244
+ try {
245
+ await purgeOne(entityId);
246
+ pass.purged += 1;
247
+ }
248
+ catch (err) {
249
+ const code = errorCodeOf(err);
250
+ const reason = err.extensions?.reason;
251
+ if (code === 'not_found' || (code === 'conflict' && (reason === 'purge_not_due' || reason === 'purge_held'))) {
252
+ pass.skipped += 1;
253
+ continue;
254
+ }
255
+ pass.errors.push({ entityId, error: err instanceof Error ? err.message : String(err) });
256
+ }
257
+ }
258
+ // A lap that began at the oldest and closes in this pass leaves the cursor NULL, as it found it.
259
+ if (more || due.lap.after !== null) {
260
+ const next = due.next ? { after: due.next, failed: due.lap.failed + pass.errors.length } : null;
261
+ await inScope((sql) => writePurgeLap(sql, operation, next));
262
+ }
263
+ return pass;
264
+ }
265
+ /**
266
+ * Whether a purge pass leaves its schedule due, so the next sweep pass continues its lap rather
267
+ * than waiting the cadence: more is due after its batch (#2096). It cannot spin (#2087): every pass
268
+ * moves the lap strictly forward, whatever its purges did, and the pass that runs out of due
269
+ * entities closes the lap and records the run — so a cadence window holds one lap, and an entity
270
+ * that keeps failing is tried once per window, not once per sweep tick. A scope running code older
271
+ * than the lap answers no `more`, and its pass waits its cadence.
272
+ */
273
+ export function purgeStillDue(pass) {
274
+ return pass.more === true;
275
+ }
276
+ /**
277
+ * The keys a module's system principal holds ONLY for its purge schedules (#119): declared by a
278
+ * purge schedule and by no other. Withheld from every system-principal check except the purge
279
+ * sweep's own invoke of the purge operation, so the scope-wide grant seated for a purge cannot
280
+ * run anything else — another operation checking the same key, a job step, a host-level call.
281
+ */
282
+ export function purgeOnlyKeysOf(schedules) {
283
+ const purge = new Set(schedules.filter((s) => s.purge).flatMap((s) => s.permissions));
284
+ for (const s of schedules)
285
+ if (!s.purge)
286
+ for (const p of s.permissions)
287
+ purge.delete(p);
288
+ return purge.size > 0 ? purge : undefined;
289
+ }
290
+ /**
291
+ * A purge pass, as the schedule run reports it: one error row per entity that failed (labelled
292
+ * with its id), and the error that marks the schedule's run `failed` when any did — in this pass,
293
+ * or in an earlier pass of the lap this one closed (#2096), so the cadence row keeps the failure.
294
+ */
295
+ export function purgeReportOf(operation, entityType, pass) {
296
+ const errors = pass.errors.map((e) => ({ operation: `${operation} (${entityType}:${e.entityId})`, error: e.error }));
297
+ const earlier = pass.lapFailed ?? 0;
298
+ if (errors.length === 0 && earlier === 0)
299
+ return { errors };
300
+ const counts = [errors.length > 0 && `${errors.length} purge(s) of ${entityType} failed`, earlier > 0 && `${earlier} failed earlier in this lap`];
301
+ return { errors, failure: new Error(`${counts.filter(Boolean).join('; ')}; they stay in the bin and are retried`) };
302
+ }
303
+ /**
304
+ * Hold one module's registration to the trash rules (#119), and answer the targets the host
305
+ * keeps. Refuses, never skips — each of these is a binned entity reachable while the module
306
+ * believes it is not:
307
+ *
308
+ * The targets are DERIVED, never handed over: `operationInputsOf(ops)` records the declared
309
+ * surface of the frozen map it returns (`declaredSurfaceOf`, which nothing else can write), and the
310
+ * host reads each operation's target off the same declaration it parses with. A module with a trashable entity must therefore
311
+ * - pass `operationInputs` built by `operationInputsOf`, so there is a declared surface to read;
312
+ * - declare every operation it binds there — an undeclared one could address a binned entity and
313
+ * the host could not see which;
314
+ * - declare a `trashed: 'purges'` operation whose input is a strict object holding the id and nothing else.
315
+ *
316
+ * Also refused: an opt-in on an entity that declares no trash here, a purge horizon without
317
+ * exactly the schedule `purgeSchedulesOf` derives for it, and a purge schedule that does not run
318
+ * the entity's `trashed: 'purges'` operation.
319
+ */
320
+ export function registerTrashTargets(moduleId, ownOps, operationInputs, entityStates, schedules) {
321
+ const trashable = new Map((entityStates ?? []).filter((d) => d.trashPermission).map((d) => [d.entityType, d]));
322
+ const surface = declaredSurfaceOf(operationInputs);
323
+ if (trashable.size > 0) {
324
+ const where = `${moduleId} declares trashable entities (${[...trashable.keys()].sort().join(', ')})`;
325
+ if (!surface) {
326
+ throw new Error(`${where} but its \`operationInputs\` is not a map \`operationInputsOf\` returned — the host could not see which ` +
327
+ 'operations address a binned one. A copy, a spread or an edited map is not one, and nor is a map built by a ' +
328
+ 'second copy of @substrat-run/contracts.\n Remedy: `operationInputs: operationInputsOf(ops)`, as returned.');
329
+ }
330
+ const undeclared = [...ownOps].filter((name) => !surface.operations.includes(name)).sort();
331
+ if (undeclared.length > 0) {
332
+ throw new Error(`${where} and binds operation(s) its declarations do not name: ${undeclared.join(', ')} — the host could not ` +
333
+ 'tell which entity they reach, so it could not refuse them on a binned one. Declare them.');
334
+ }
335
+ }
336
+ const out = new Map();
337
+ for (const [name, target] of Object.entries(surface?.targets ?? {})) {
338
+ if (!ownOps.has(name))
339
+ continue; // declared and not bound here: nothing to refuse
340
+ if (target.trashed === 'purges') {
341
+ const schema = operationInputs?.[name];
342
+ const fields = Object.keys(schema?.shape ?? {});
343
+ if (fields.length !== 1 || fields[0] !== target.idFrom) {
344
+ throw new Error(`${moduleId}: '${name}' purges '${target.entity}', and its parsed input takes ${fields.map((f) => `'${f}'`).join(', ') || 'nothing'} — ` +
345
+ `a purge's input is '${target.idFrom}' and nothing else, so it reaches only the entity it is run for`);
346
+ }
347
+ // The shape alone is not the parsed result: a passthrough object keeps whatever else the
348
+ // call carried. Strict, so an extra field is refused rather than kept or dropped.
349
+ if (!isStrictObjectSchema(schema)) {
350
+ throw new Error(`${moduleId}: '${name}' purges '${target.entity}', and its input is not a strict object — a passthrough one ` +
351
+ `keeps fields beside '${target.idFrom}'.\n Remedy: \`input: z.strictObject({ ${target.idFrom}: … })\`.`);
352
+ }
353
+ }
354
+ if (target.trashed && !trashable.has(target.entity)) {
355
+ throw new Error(`${moduleId}: '${name}' declares \`trashed: '${target.trashed}'\` over '${target.entity}', which declares no trash here`);
356
+ }
357
+ out.set(name, target);
358
+ }
359
+ for (const schedule of schedules ?? []) {
360
+ if (!schedule.purge)
361
+ continue;
362
+ const decl = trashable.get(schedule.purge.entityType);
363
+ const target = out.get(schedule.operation);
364
+ if (decl?.purgeAfterDays === undefined || target?.trashed !== 'purges' || target.entity !== schedule.purge.entityType) {
365
+ throw new Error(`${moduleId}: the purge schedule for '${schedule.purge.entityType}' runs '${schedule.operation}', which is not ` +
366
+ "that entity's `trashed: 'purges'` operation over a declared horizon — derive it with `purgeSchedulesOf`");
367
+ }
368
+ }
369
+ for (const [entityType, decl] of trashable) {
370
+ if (decl.purgeAfterDays === undefined)
371
+ continue;
372
+ const count = (schedules ?? []).filter((s) => s.purge?.entityType === entityType).length;
373
+ if (count !== 1) {
374
+ throw new Error(`${moduleId}: '${entityType}' declares a purge horizon and ${count} purge schedules — ` +
375
+ 'spread `purgeSchedulesOf(ops, entities)` into the manifest\'s `schedules` once');
376
+ }
377
+ }
378
+ return out;
379
+ }
380
+ //# sourceMappingURL=entity-trash.js.map