@substrat-run/kernel 0.137.0 → 0.139.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 (121) hide show
  1. package/dist/attachment-extractor.d.ts +9 -1
  2. package/dist/attachment-extractor.d.ts.map +1 -1
  3. package/dist/attachment-extractor.js +21 -1
  4. package/dist/attachment-extractor.js.map +1 -1
  5. package/dist/attachment-text.d.ts +64 -0
  6. package/dist/attachment-text.d.ts.map +1 -1
  7. package/dist/attachment-text.js +87 -16
  8. package/dist/attachment-text.js.map +1 -1
  9. package/dist/attribution.d.ts +36 -3
  10. package/dist/attribution.d.ts.map +1 -1
  11. package/dist/attribution.js +21 -8
  12. package/dist/attribution.js.map +1 -1
  13. package/dist/delivery-refusal.d.ts +21 -0
  14. package/dist/delivery-refusal.d.ts.map +1 -0
  15. package/dist/delivery-refusal.js +21 -0
  16. package/dist/delivery-refusal.js.map +1 -0
  17. package/dist/entity-state-reads.d.ts +43 -0
  18. package/dist/entity-state-reads.d.ts.map +1 -0
  19. package/dist/entity-state-reads.js +135 -0
  20. package/dist/entity-state-reads.js.map +1 -0
  21. package/dist/entity-state.d.ts +177 -0
  22. package/dist/entity-state.d.ts.map +1 -0
  23. package/dist/entity-state.js +345 -0
  24. package/dist/entity-state.js.map +1 -0
  25. package/dist/findings.d.ts +143 -0
  26. package/dist/findings.d.ts.map +1 -0
  27. package/dist/findings.js +463 -0
  28. package/dist/findings.js.map +1 -0
  29. package/dist/index.d.ts +30 -20
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +22 -14
  32. package/dist/index.js.map +1 -1
  33. package/dist/job-run.d.ts +309 -48
  34. package/dist/job-run.d.ts.map +1 -1
  35. package/dist/job-run.js +388 -80
  36. package/dist/job-run.js.map +1 -1
  37. package/dist/list-index.d.ts +25 -3
  38. package/dist/list-index.d.ts.map +1 -1
  39. package/dist/list-index.js +70 -26
  40. package/dist/list-index.js.map +1 -1
  41. package/dist/membership-executor.d.ts +109 -0
  42. package/dist/membership-executor.d.ts.map +1 -0
  43. package/dist/membership-executor.js +261 -0
  44. package/dist/membership-executor.js.map +1 -0
  45. package/dist/membership-fence.d.ts +57 -0
  46. package/dist/membership-fence.d.ts.map +1 -0
  47. package/dist/membership-fence.js +90 -0
  48. package/dist/membership-fence.js.map +1 -0
  49. package/dist/module-migrations.d.ts +6 -3
  50. package/dist/module-migrations.d.ts.map +1 -1
  51. package/dist/module-migrations.js +12 -5
  52. package/dist/module-migrations.js.map +1 -1
  53. package/dist/operation-series.d.ts +58 -0
  54. package/dist/operation-series.d.ts.map +1 -0
  55. package/dist/operation-series.js +63 -0
  56. package/dist/operation-series.js.map +1 -0
  57. package/dist/peer.d.ts +13 -8
  58. package/dist/peer.d.ts.map +1 -1
  59. package/dist/peer.js +13 -14
  60. package/dist/peer.js.map +1 -1
  61. package/dist/permission-eval.d.ts +80 -1
  62. package/dist/permission-eval.d.ts.map +1 -1
  63. package/dist/permission-eval.js +164 -18
  64. package/dist/permission-eval.js.map +1 -1
  65. package/dist/platform-sweep.d.ts +7 -1
  66. package/dist/platform-sweep.d.ts.map +1 -1
  67. package/dist/platform-sweep.js +12 -0
  68. package/dist/platform-sweep.js.map +1 -1
  69. package/dist/read-only-sql.d.ts +2 -26
  70. package/dist/read-only-sql.d.ts.map +1 -1
  71. package/dist/read-only-sql.js +13 -9
  72. package/dist/read-only-sql.js.map +1 -1
  73. package/dist/scope-copy.d.ts +18 -0
  74. package/dist/scope-copy.d.ts.map +1 -1
  75. package/dist/scope-copy.js +18 -0
  76. package/dist/scope-copy.js.map +1 -1
  77. package/dist/scope-host.d.ts +416 -13
  78. package/dist/scope-host.d.ts.map +1 -1
  79. package/dist/scope-host.js +51 -2
  80. package/dist/scope-host.js.map +1 -1
  81. package/dist/scope-role-admin.d.ts +68 -0
  82. package/dist/scope-role-admin.d.ts.map +1 -0
  83. package/dist/scope-role-admin.js +80 -0
  84. package/dist/scope-role-admin.js.map +1 -0
  85. package/dist/search-index.d.ts +13 -1
  86. package/dist/search-index.d.ts.map +1 -1
  87. package/dist/search-index.js +9 -16
  88. package/dist/search-index.js.map +1 -1
  89. package/dist/spine-guard.d.ts +64 -2
  90. package/dist/spine-guard.d.ts.map +1 -1
  91. package/dist/spine-guard.js +345 -10
  92. package/dist/spine-guard.js.map +1 -1
  93. package/dist/spine-restore.d.ts +7 -2
  94. package/dist/spine-restore.d.ts.map +1 -1
  95. package/dist/spine-restore.js +13 -5
  96. package/dist/spine-restore.js.map +1 -1
  97. package/dist/sql-identifier.d.ts +12 -0
  98. package/dist/sql-identifier.d.ts.map +1 -0
  99. package/dist/sql-identifier.js +17 -0
  100. package/dist/sql-identifier.js.map +1 -0
  101. package/dist/system-switch-record.d.ts +277 -77
  102. package/dist/system-switch-record.d.ts.map +1 -1
  103. package/dist/system-switch-record.js +322 -105
  104. package/dist/system-switch-record.js.map +1 -1
  105. package/dist/system-switch.d.ts +110 -14
  106. package/dist/system-switch.d.ts.map +1 -1
  107. package/dist/system-switch.js +92 -17
  108. package/dist/system-switch.js.map +1 -1
  109. package/dist/timeline.d.ts +23 -0
  110. package/dist/timeline.d.ts.map +1 -1
  111. package/dist/timeline.js +30 -0
  112. package/dist/timeline.js.map +1 -1
  113. package/dist/ulid.d.ts +5 -0
  114. package/dist/ulid.d.ts.map +1 -1
  115. package/dist/ulid.js +9 -0
  116. package/dist/ulid.js.map +1 -1
  117. package/dist/vertical-events.d.ts +7 -1
  118. package/dist/vertical-events.d.ts.map +1 -1
  119. package/dist/vertical-events.js +14 -0
  120. package/dist/vertical-events.js.map +1 -1
  121. package/package.json +4 -4
@@ -0,0 +1,261 @@
1
+ import { instant, orgId, principalId, z, } from '@substrat-run/contracts';
2
+ import { isUnknownRoleError } from './permission-checker.js';
3
+ import { refuseDelivery } from './delivery-refusal.js';
4
+ import {} from './scope-host.js';
5
+ /**
6
+ * The membership executor (K-22 §4.2, membership.md §4.2; #1184) — the out-of-band half of
7
+ * joining and leaving, written once so a host mounts it instead of hand-rolling the effect.
8
+ *
9
+ * Module code asks in its own transaction and this effects after commit, through `HostAdmin`:
10
+ * an invite engine's accept emits `member.add-requested`; a vertical's removal emits
11
+ * `member.remove-requested`. A rolled-back operation leaves no event and nothing to effect.
12
+ * Both are driven inline by the emitting call, with the outbox and `_substrat_deliveries` as
13
+ * the retry backstop, so the common case completes inside the request.
14
+ *
15
+ * **What it effects: a tenant-level role, and — opted into — the org.** An add assigns the
16
+ * invited role at the tenant node; a removal unassigns it. By default neither joins nor leaves
17
+ * the org both payloads name (the invites engine keys its invitations by org). A mount with
18
+ * `orgs: 'join'` (#2047) also joins it on an add and takes the person out of it on a removal, in
19
+ * the same directory unit as the role, bounded by MEMBERSHIP: the inviter or remover must be a
20
+ * live member of that org. A member holds everything the org confers, at every node — its
21
+ * grants in each scope's own store included — so that is the exact bound, and no scope store
22
+ * is read. A tenant admin who is not a member of the org cannot invite anyone into it, by
23
+ * design. A join's membership expires no later than the inviter's own: a temporary member
24
+ * cannot confer a permanent one.
25
+ *
26
+ * **Authority.** Every payload field is module-written, so none of them is authority.
27
+ *
28
+ * - An ADD is bounded by the inviter: the kernel-stamped `actor` of the invitation's own
29
+ * `invites.sent` event, asked through the K-21 set comparison at the tenant node. Asked
30
+ * twice: early, to refuse cheaply, and again inside the directory unit that writes the role
31
+ * (`applyMembership`), so a demotion, a grant lost or a role widened in between is not
32
+ * written past. The joiner is the request's own actor, who must have made the invitation's
33
+ * first acceptance, and the request must be the first one naming that invitation: one
34
+ * invitation, one join. The most a module can do is make the principal who actually invoked
35
+ * it look like the sender — the same delegation bound `ctx.grant` has.
36
+ * - A REMOVE is bounded by the remover, the request's own actor (§5.1: removal takes the
37
+ * same bound, or a junior admin could strip a role they could not have granted) — asked the
38
+ * same two ways, so a remover demoted in between removes nobody.
39
+ *
40
+ * **Removal wins.** An add is refused when the joiner was removed after it was requested: by a
41
+ * later `member.remove-requested` on the same membership (ordered by outbox id), or by any
42
+ * tenant-level removal at all — staff's included, and a no-op one — which raises the person's
43
+ * removal fence in the directory unit that revokes. The add's own unit reads the fence, and a
44
+ * fence at or after `occurredAt - MEMBERSHIP_REMOVAL_SKEW_MS` refuses it.
45
+ *
46
+ * A refusal is terminal (`refuseDelivery`): journaled with its reason, never retried, listed
47
+ * by `executorDeadLetters`, and reported to the emitting call through `onExecutorOutcomes`.
48
+ *
49
+ * **The trail.** Admin rows are written by `actor`, the platform identity that executed them,
50
+ * `onBehalfOf` (#977) the person whose authority bounded them, and with `causedBy` = the event
51
+ * id: the correlation id that joins the scope's half of the trail to the directory's. A
52
+ * removal that took nothing writes no row (K-21), but still raises the fence.
53
+ *
54
+ * **Idempotent.** A delivered event never reaches its handler again. A crash between the
55
+ * effect and its journal row re-runs the handler, and every write it makes is idempotent.
56
+ */
57
+ /** What the add path consumes, and the version it reads. */
58
+ export const MEMBER_ADD_REQUESTED = 'member.add-requested';
59
+ /** What the remove path consumes, and the version it reads. */
60
+ export const MEMBER_REMOVE_REQUESTED = 'member.remove-requested';
61
+ /** The delivery-journal id of the add path unless a mount names another (`executor:membership`). */
62
+ export const MEMBERSHIP_EXECUTOR_ID = 'membership';
63
+ /** The remove path's id, derived from the add path's: `<id>-remove`. */
64
+ export const membershipRemoveExecutorId = (id = MEMBERSHIP_EXECUTOR_ID) => `${id}-remove`;
65
+ /** The membership entity both requests are emitted on — what orders a removal after an add. */
66
+ export const membershipEntity = (principal) => ({ entityType: 'membership', entityId: principal });
67
+ /** The executor's own parse of an add (D-19: never the producer's types). */
68
+ const memberAddRequested = z.object({
69
+ principal: principalId,
70
+ orgId,
71
+ tenantId: z.string(),
72
+ roleKey: z.string().min(1),
73
+ invitationId: z.string().min(1),
74
+ });
75
+ /**
76
+ * `member.remove-requested` v1 — what a vertical emits to take someone out: the role it
77
+ * holds them at, and the org its invitation named. Fat (D-19), like the add. The executor
78
+ * takes the role away; the org is the vertical's own vocabulary and is not touched.
79
+ */
80
+ export const memberRemoveRequestedPayload = z.object({
81
+ principal: principalId,
82
+ orgId,
83
+ tenantId: z.string(),
84
+ roleKey: z.string().min(1),
85
+ });
86
+ /** What `invites.sent` and `invites.accepted` must agree with the request on. */
87
+ const invitationFacts = z.object({ orgId: z.string(), roleKey: z.string() });
88
+ const acceptedFacts = z.object({ principal: z.string() });
89
+ /**
90
+ * How far before a request a removal still wins over it (#1184): 5 minutes. A removal's fence
91
+ * is stamped by the directory's clock; the request is a scope event stamped by the scope's.
92
+ * The two stores share no causal order, so a tie goes to the removal and so does anything
93
+ * within this skew. The cost, stated: someone removed less than this before they accept a NEW
94
+ * invite is refused — resend it. Removals through the seam are also ordered by outbox id.
95
+ */
96
+ export const MEMBERSHIP_REMOVAL_SKEW_MS = 5 * 60_000;
97
+ /** Mount the membership executor — both paths — on a host. One call, at host construction. */
98
+ export function registerMembershipExecutor(host, options) {
99
+ if (options.level !== undefined && options.level !== 'tenant') {
100
+ throw new Error(`the membership executor is tenant-level only (got level '${String(options.level)}'): no single operation ` +
101
+ 'spans the directory and a scope store. A scope role has assignScopeRoleBounded.');
102
+ }
103
+ const id = options.id ?? MEMBERSHIP_EXECUTOR_ID;
104
+ // The org a request names rides along only on a mount that joins orgs (#2047).
105
+ const orgOf = (request) => (options.orgs === 'join' ? { orgId: request.orgId } : {});
106
+ // Attributed (#977): the person whose authority bounded the write, beside the platform
107
+ // actor that executed it. `causedBy` is passed, never stamped by the host (#2055): a view of
108
+ // the host is not the `admin` the dispatch handed the handler, so the event goes with it.
109
+ // A host without `attributed` writes through that `admin`, which carries the event already.
110
+ const adminFor = (admin, who, event) => host.attributed?.({ principal: who, tenantId: event.tenantId }, { causedBy: event.id }).admin ?? admin;
111
+ host.registerExecutor(id, MEMBER_ADD_REQUESTED, async (admin, event, scope) => {
112
+ const decided = await authorizeAdd(event, scope);
113
+ if ('refused' in decided)
114
+ return decided.refused;
115
+ const { request, inviter } = decided;
116
+ // One directory unit: the fence, the bound asked again, the role and its audit row. A
117
+ // removal or a change of authority lands wholly before it or wholly after it.
118
+ const applied = await adminFor(admin, inviter, event).applyMembership(options.actor, {
119
+ op: 'add',
120
+ tenantId: event.tenantId,
121
+ principal: request.principal,
122
+ roleKey: request.roleKey,
123
+ boundedBy: inviter,
124
+ ...orgOf(request),
125
+ unlessRemovedSince: instant.parse(new Date(Date.parse(event.occurredAt) - MEMBERSHIP_REMOVAL_SKEW_MS).toISOString()),
126
+ });
127
+ return applied.applied ? undefined : refusalOf(applied, request, 'inviter', inviter);
128
+ }, options.retry);
129
+ host.registerExecutor(membershipRemoveExecutorId(id), MEMBER_REMOVE_REQUESTED, async (admin, event, scope) => {
130
+ const decided = await authorizeRemove(event, scope);
131
+ if ('refused' in decided)
132
+ return decided.refused;
133
+ const { request, remover } = decided;
134
+ // One directory unit: the remover's bound asked again, the revoke, its audit row if it
135
+ // took anything, and the fence — raised even with nothing held, so a pending add sees it.
136
+ const applied = await adminFor(admin, remover, event).applyMembership(options.actor, {
137
+ op: 'remove',
138
+ tenantId: event.tenantId,
139
+ principal: request.principal,
140
+ roleKey: request.roleKey,
141
+ boundedBy: remover,
142
+ ...orgOf(request),
143
+ });
144
+ return applied.applied ? undefined : refusalOf(applied, request, 'remover', remover);
145
+ }, options.retry);
146
+ }
147
+ /** Why a directory unit applied nothing, as the refusal the journal keeps. */
148
+ function refusalOf(result, request, as, who) {
149
+ if ('removedAt' in result)
150
+ return refuseDelivery(`${request.principal} was removed after this request was made`);
151
+ if ('unknownRole' in result)
152
+ return refuseDelivery(`no such role in this tenant: ${result.unknownRole}`);
153
+ if ('unknownOrg' in result)
154
+ return refuseDelivery(`no such org in this tenant: ${result.unknownOrg}`);
155
+ if ('notMember' in result)
156
+ return refuseDelivery(`the ${as} ${who} is not a member of org ${result.notMember}`);
157
+ return refuseDelivery(`the ${as} ${who} no longer holds ${result.missing.join(', ')}, which '${request.roleKey}' confers`);
158
+ }
159
+ const refused = (reason) => ({ refused: refuseDelivery(reason) });
160
+ /** Parse a request and hold it to its own envelope's tenant. Version 1 is the one read. */
161
+ function requestOf(event, schema) {
162
+ if (event.schemaVersion !== 1)
163
+ return refused(`unsupported ${event.type} schemaVersion ${event.schemaVersion}`);
164
+ const parsed = schema.safeParse(event.payload);
165
+ if (!parsed.success)
166
+ return refused(`malformed ${event.type} payload`);
167
+ // The tenant on the envelope is kernel-stamped; the one in the payload is not.
168
+ if (parsed.data.tenantId !== event.tenantId)
169
+ return refused('the request names a tenant other than its own');
170
+ return { request: parsed.data };
171
+ }
172
+ /** §5.1's bound for `who` over `roleKey` at the tenant node, or the refusal saying what is missing. */
173
+ async function bounded(scope, who, roleKey, as) {
174
+ // Read the way the unit's own answer is, so one `refusalOf` words both.
175
+ let refusal;
176
+ try {
177
+ const bound = await scope.covers(who, roleKey, 'tenant');
178
+ refusal = bound.covered ? null : { applied: false, missing: bound.missing };
179
+ }
180
+ catch (err) {
181
+ if (!isUnknownRoleError(err, roleKey))
182
+ throw err;
183
+ refusal = { applied: false, unknownRole: roleKey };
184
+ }
185
+ return refusal && { refused: refusalOf(refusal, { principal: who, roleKey }, as, who) };
186
+ }
187
+ /** Everything that decides whether an add may be effected. Reads only. */
188
+ async function authorizeAdd(event, scope) {
189
+ const parsed = requestOf(event, memberAddRequested);
190
+ if ('refused' in parsed)
191
+ return parsed;
192
+ const { request } = parsed;
193
+ // The joiner is whoever invoked the accept — the envelope's actor, not a payload claim.
194
+ if (event.actor !== request.principal)
195
+ return refused('the request names a principal other than the one who accepted');
196
+ const history = await everything(scope, { entityType: 'invitation', entityId: request.invitationId });
197
+ const sent = history.filter((e) => e.type === 'invites.sent');
198
+ if (sent.length !== 1)
199
+ return refused(`invitation ${request.invitationId} has no single send to take authority from`);
200
+ const send = sent[0];
201
+ const sendFacts = invitationFacts.safeParse(send.payload);
202
+ if (!sendFacts.success || sendFacts.data.orgId !== request.orgId || sendFacts.data.roleKey !== request.roleKey) {
203
+ return refused(`the request does not match invitation ${request.invitationId} as it was sent`);
204
+ }
205
+ // Single use: the FIRST acceptance decides who joins. A later one — by anyone — joins
206
+ // nobody, and cannot unseat the first, whose delivery may still be retrying.
207
+ const first = history.find((e) => e.type === 'invites.accepted');
208
+ const acceptance = first ? acceptedFacts.safeParse(first.payload) : null;
209
+ if (!acceptance?.success || first.actor !== event.actor || acceptance.data.principal !== request.principal) {
210
+ return refused(`invitation ${request.invitationId} was not first accepted by ${request.principal}`);
211
+ }
212
+ const inviter = principalId.safeParse(send.actor);
213
+ if (!inviter.success)
214
+ return refused(`invitation ${request.invitationId} was not sent by a principal`);
215
+ // One invitation, one join. The request must follow the acceptance and be the FIRST
216
+ // request naming this invitation on the joiner's membership — outbox ids are kernel-minted
217
+ // and append-only, so the first request consumes the invitation for good, and a fresh one
218
+ // emitted later (after a removal, say) is refused however its payload reads.
219
+ if (first.id > event.id)
220
+ return refused(`the request precedes invitation ${request.invitationId}'s acceptance`);
221
+ const membership = await everything(scope, membershipEntity(request.principal));
222
+ const earlier = membership.find((e) => e.type === MEMBER_ADD_REQUESTED && e.id < event.id && invitationOf(e) === request.invitationId);
223
+ if (earlier)
224
+ return refused(`invitation ${request.invitationId} was already used, by request ${earlier.id}`);
225
+ // A removal through the seam, ordered after this request by outbox id. Every removal also
226
+ // raises the fence `applyMembership` reads inside the unit that writes.
227
+ if (membership.some((e) => e.type === MEMBER_REMOVE_REQUESTED && e.id > event.id)) {
228
+ return refused(`${request.principal} was removed after this request was made`);
229
+ }
230
+ // The early refusal; `applyMembership` asks the same bound again inside its unit.
231
+ const bound = await bounded(scope, inviter.data, request.roleKey, 'inviter');
232
+ return bound ?? { request, inviter: inviter.data };
233
+ }
234
+ /** Everything that decides whether a removal may be effected. Reads only. */
235
+ async function authorizeRemove(event, scope) {
236
+ const parsed = requestOf(event, memberRemoveRequestedPayload);
237
+ if ('refused' in parsed)
238
+ return parsed;
239
+ const remover = principalId.safeParse(event.actor);
240
+ if (!remover.success)
241
+ return refused('the removal was not requested by a principal');
242
+ const bound = await bounded(scope, remover.data, parsed.request.roleKey, 'remover');
243
+ return bound ?? { request: parsed.request, remover: remover.data };
244
+ }
245
+ /** The invitation an add request names, read the way the executor reads a request. */
246
+ function invitationOf(entry) {
247
+ const parsed = memberAddRequested.safeParse(entry.payload);
248
+ return parsed.success ? parsed.data.invitationId : undefined;
249
+ }
250
+ /** One entity's whole history, oldest first. Short by construction: a handful of events. */
251
+ async function everything(scope, entity) {
252
+ const entries = [];
253
+ let cursor;
254
+ do {
255
+ const page = await scope.history(entity, { cursor });
256
+ entries.push(...page.entries);
257
+ cursor = page.nextCursor ?? undefined;
258
+ } while (cursor);
259
+ return entries;
260
+ }
261
+ //# sourceMappingURL=membership-executor.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"membership-executor.js","sourceRoot":"","sources":["../src/membership-executor.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The removal fence (#1184): one row per (tenant, principal), stamped with the latest moment
3
+ * that person was removed at the tenant level. The membership executor's add reads it inside
4
+ * the directory unit that assigns the role, and applies nothing when it stands at or after
5
+ * the request's `occurredAt - MEMBERSHIP_REMOVAL_SKEW_MS`.
6
+ *
7
+ * A table of its own rather than a read of the admin log, because K-21's audit contract
8
+ * writes no row for a removal that took nothing — and a removal of someone whose add is still
9
+ * on its way takes nothing, yet must still win. Every tenant-level `unassignRole` and every
10
+ * `removeMember` raises it in the SAME unit as its revoke, staff's and the executor's alike,
11
+ * a no-op included. Never lowered: a later removal moves it forward, an earlier one does not.
12
+ *
13
+ * The admin log is still the history. This is only the latest position.
14
+ */
15
+ /**
16
+ * The table. Interpolated into both adapters' directory DDL, so `lint:spine-ddl` sees the one
17
+ * spelling on each side, and a directory dump carries it like every other directory table.
18
+ */
19
+ export declare const MEMBERSHIP_FENCES_DDL = "\n CREATE TABLE IF NOT EXISTS _substrat_membership_fences (\n tenant_id TEXT NOT NULL,\n principal TEXT NOT NULL,\n removed_at TEXT NOT NULL,\n PRIMARY KEY (tenant_id, principal)\n );\n";
20
+ /** Raise `principal`'s fence to `removed_at`, never lower it. Binds tenant, principal, at. */
21
+ export declare const RAISE_MEMBERSHIP_FENCE_SQL = "\n INSERT INTO _substrat_membership_fences (tenant_id, principal, removed_at) VALUES (?, ?, ?)\n ON CONFLICT (tenant_id, principal) DO UPDATE SET removed_at = MAX(removed_at, excluded.removed_at)\n";
22
+ /** The fence, if it stands at or after an instant. Binds tenant, principal, since. */
23
+ export declare const MEMBERSHIP_FENCE_SINCE_SQL = "\n SELECT removed_at FROM _substrat_membership_fences\n WHERE tenant_id = ? AND principal = ? AND removed_at >= ?\n";
24
+ /** The fence's table name, as the DDL above spells it. */
25
+ export declare const MEMBERSHIP_FENCES_TABLE = "_substrat_membership_fences";
26
+ /**
27
+ * The one-time backfill from the admin log: everyone removed at the tenant level before the
28
+ * fence existed. Without it, an add requested back then and never effected — the backlog a host
29
+ * that mounts the executor for the first time drains — would re-admit someone removed by hand
30
+ * since, because the fence would have nothing to say about them.
31
+ *
32
+ * It reads who those removals named: an `unassignRole` row at the tenant node (`scope_id` NULL)
33
+ * carries the assignment as `before.principalId`, a `removeMember` row the membership as
34
+ * `before.principal`. A no-op removal wrote no row and is not recovered — nothing before the
35
+ * fence recorded one.
36
+ *
37
+ * **Every backfilled fence stands at `at`, the adapter's clock at the moment it runs — never the
38
+ * audit row's own time** (Codex round 4). Those rows were stamped by the wall clock, while a
39
+ * request still queued from back then was stamped by the host's clock, which need not agree: a
40
+ * host clock running ahead would put the copied time below the request's cutoff and the backlog
41
+ * would re-admit the person. Stamping at backfill time is conservative instead: every request
42
+ * emitted before it, for anyone ever removed, is refused. That takes nothing legitimate away —
43
+ * anyone re-added before the executor existed got their role inline, by hand, and holds it
44
+ * still — and an invite accepted more than `MEMBERSHIP_REMOVAL_SKEW_MS` after it is unaffected.
45
+ *
46
+ * `INSERT OR IGNORE`, so a fence the live path raised is never lowered by history. The adapters
47
+ * run it on the construction that creates the table, and a directory restore runs it when the
48
+ * dump did not carry the table, so it runs once per directory that never had it.
49
+ */
50
+ export declare function membershipFencesBackfillSql(at: string): string;
51
+ /** "Does the fence table exist yet?" — asked BEFORE the DDL, so the backfill runs once. */
52
+ export declare function membershipFencesTableExists(db: {
53
+ all(sql: string, ...params: string[]): unknown[];
54
+ }): boolean;
55
+ /** Does a dump carry the fence table? Compared without case, as SQLite resolves a name. */
56
+ export declare function dumpCarriesMembershipFences(names: readonly string[]): boolean;
57
+ //# sourceMappingURL=membership-fence.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"membership-fence.d.ts","sourceRoot":"","sources":["../src/membership-fence.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;GAaG;AAEH;;;GAGG;AACH,eAAO,MAAM,qBAAqB,8MAOjC,CAAC;AAEF,8FAA8F;AAC9F,eAAO,MAAM,0BAA0B,4MAGtC,CAAC;AAEF,sFAAsF;AACtF,eAAO,MAAM,0BAA0B,0HAGtC,CAAC;AAEF,0DAA0D;AAC1D,eAAO,MAAM,uBAAuB,gCAAgC,CAAC;AAErE;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,2BAA2B,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,CAiB9D;AAED,2FAA2F;AAC3F,wBAAgB,2BAA2B,CAAC,EAAE,EAAE;IAAE,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,MAAM,EAAE,GAAG,OAAO,EAAE,CAAA;CAAE,GAAG,OAAO,CAE7G;AAED,2FAA2F;AAC3F,wBAAgB,2BAA2B,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAE7E"}
@@ -0,0 +1,90 @@
1
+ import { instant } from '@substrat-run/contracts';
2
+ /**
3
+ * The removal fence (#1184): one row per (tenant, principal), stamped with the latest moment
4
+ * that person was removed at the tenant level. The membership executor's add reads it inside
5
+ * the directory unit that assigns the role, and applies nothing when it stands at or after
6
+ * the request's `occurredAt - MEMBERSHIP_REMOVAL_SKEW_MS`.
7
+ *
8
+ * A table of its own rather than a read of the admin log, because K-21's audit contract
9
+ * writes no row for a removal that took nothing — and a removal of someone whose add is still
10
+ * on its way takes nothing, yet must still win. Every tenant-level `unassignRole` and every
11
+ * `removeMember` raises it in the SAME unit as its revoke, staff's and the executor's alike,
12
+ * a no-op included. Never lowered: a later removal moves it forward, an earlier one does not.
13
+ *
14
+ * The admin log is still the history. This is only the latest position.
15
+ */
16
+ /**
17
+ * The table. Interpolated into both adapters' directory DDL, so `lint:spine-ddl` sees the one
18
+ * spelling on each side, and a directory dump carries it like every other directory table.
19
+ */
20
+ export const MEMBERSHIP_FENCES_DDL = `
21
+ CREATE TABLE IF NOT EXISTS _substrat_membership_fences (
22
+ tenant_id TEXT NOT NULL,
23
+ principal TEXT NOT NULL,
24
+ removed_at TEXT NOT NULL,
25
+ PRIMARY KEY (tenant_id, principal)
26
+ );
27
+ `;
28
+ /** Raise `principal`'s fence to `removed_at`, never lower it. Binds tenant, principal, at. */
29
+ export const RAISE_MEMBERSHIP_FENCE_SQL = `
30
+ INSERT INTO _substrat_membership_fences (tenant_id, principal, removed_at) VALUES (?, ?, ?)
31
+ ON CONFLICT (tenant_id, principal) DO UPDATE SET removed_at = MAX(removed_at, excluded.removed_at)
32
+ `;
33
+ /** The fence, if it stands at or after an instant. Binds tenant, principal, since. */
34
+ export const MEMBERSHIP_FENCE_SINCE_SQL = `
35
+ SELECT removed_at FROM _substrat_membership_fences
36
+ WHERE tenant_id = ? AND principal = ? AND removed_at >= ?
37
+ `;
38
+ /** The fence's table name, as the DDL above spells it. */
39
+ export const MEMBERSHIP_FENCES_TABLE = '_substrat_membership_fences';
40
+ /**
41
+ * The one-time backfill from the admin log: everyone removed at the tenant level before the
42
+ * fence existed. Without it, an add requested back then and never effected — the backlog a host
43
+ * that mounts the executor for the first time drains — would re-admit someone removed by hand
44
+ * since, because the fence would have nothing to say about them.
45
+ *
46
+ * It reads who those removals named: an `unassignRole` row at the tenant node (`scope_id` NULL)
47
+ * carries the assignment as `before.principalId`, a `removeMember` row the membership as
48
+ * `before.principal`. A no-op removal wrote no row and is not recovered — nothing before the
49
+ * fence recorded one.
50
+ *
51
+ * **Every backfilled fence stands at `at`, the adapter's clock at the moment it runs — never the
52
+ * audit row's own time** (Codex round 4). Those rows were stamped by the wall clock, while a
53
+ * request still queued from back then was stamped by the host's clock, which need not agree: a
54
+ * host clock running ahead would put the copied time below the request's cutoff and the backlog
55
+ * would re-admit the person. Stamping at backfill time is conservative instead: every request
56
+ * emitted before it, for anyone ever removed, is refused. That takes nothing legitimate away —
57
+ * anyone re-added before the executor existed got their role inline, by hand, and holds it
58
+ * still — and an invite accepted more than `MEMBERSHIP_REMOVAL_SKEW_MS` after it is unaffected.
59
+ *
60
+ * `INSERT OR IGNORE`, so a fence the live path raised is never lowered by history. The adapters
61
+ * run it on the construction that creates the table, and a directory restore runs it when the
62
+ * dump did not carry the table, so it runs once per directory that never had it.
63
+ */
64
+ export function membershipFencesBackfillSql(at) {
65
+ // Interpolated, not bound: the adapters run it through `exec`. `instant` holds it to an
66
+ // ISO-8601 UTC timestamp, which carries no quote, or throws.
67
+ return `
68
+ INSERT OR IGNORE INTO _substrat_membership_fences (tenant_id, principal, removed_at)
69
+ SELECT DISTINCT tenant_id, principal, '${instant.parse(at)}'
70
+ FROM (
71
+ SELECT tenant_id, json_extract(before, '$.principalId') AS principal
72
+ FROM _substrat_admin_log
73
+ WHERE action = 'unassignRole' AND tenant_id IS NOT NULL AND scope_id IS NULL
74
+ UNION ALL
75
+ SELECT tenant_id, json_extract(before, '$.principal') AS principal
76
+ FROM _substrat_admin_log
77
+ WHERE action = 'removeMember' AND tenant_id IS NOT NULL
78
+ )
79
+ WHERE principal IS NOT NULL
80
+ `;
81
+ }
82
+ /** "Does the fence table exist yet?" — asked BEFORE the DDL, so the backfill runs once. */
83
+ export function membershipFencesTableExists(db) {
84
+ return db.all(`SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = ?`, MEMBERSHIP_FENCES_TABLE).length > 0;
85
+ }
86
+ /** Does a dump carry the fence table? Compared without case, as SQLite resolves a name. */
87
+ export function dumpCarriesMembershipFences(names) {
88
+ return names.some((n) => n.toLowerCase() === MEMBERSHIP_FENCES_TABLE);
89
+ }
90
+ //# sourceMappingURL=membership-fence.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"membership-fence.js","sourceRoot":"","sources":["../src/membership-fence.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,yBAAyB,CAAC;AAElD;;;;;;;;;;;;;GAaG;AAEH;;;GAGG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG;;;;;;;CAOpC,CAAC;AAEF,8FAA8F;AAC9F,MAAM,CAAC,MAAM,0BAA0B,GAAG;;;CAGzC,CAAC;AAEF,sFAAsF;AACtF,MAAM,CAAC,MAAM,0BAA0B,GAAG;;;CAGzC,CAAC;AAEF,0DAA0D;AAC1D,MAAM,CAAC,MAAM,uBAAuB,GAAG,6BAA6B,CAAC;AAErE;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,MAAM,UAAU,2BAA2B,CAAC,EAAU;IACpD,wFAAwF;IACxF,6DAA6D;IAC7D,OAAO;;2CAEkC,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC;;;;;;;;;;;CAW3D,CAAC;AACF,CAAC;AAED,2FAA2F;AAC3F,MAAM,UAAU,2BAA2B,CAAC,EAAwD;IAClG,OAAO,EAAE,CAAC,GAAG,CAAC,0EAA0E,EAAE,uBAAuB,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC;AAChI,CAAC;AAED,2FAA2F;AAC3F,MAAM,UAAU,2BAA2B,CAAC,KAAwB;IAClE,OAAO,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,WAAW,EAAE,KAAK,uBAAuB,CAAC,CAAC;AACxE,CAAC"}
@@ -1,11 +1,13 @@
1
+ import type { EntityStateDeclaration } from '@substrat-run/contracts';
1
2
  import { type ListDeclaration } from './list-index.js';
2
3
  import { type SearchableDeclaration } from './search-index.js';
3
4
  import type { SqlMigration } from './scope-host.js';
4
5
  /**
5
6
  * Every migration a module's scope applies, in the order the host applies them: the module's
6
- * own `migrations`, then the search indexes its `searchables` declare (#827), then the list
7
- * indexes its `lists` declare (#811). The two derived sets come after the authored ones because
8
- * their DDL names the tables those create.
7
+ * own `migrations`, then the search indexes its `searchables` declare (#827), then the archive and
8
+ * trash columns its `entityStates` declare (#119), then the list indexes its `lists` declare
9
+ * (#811). The derived sets come after the authored ones because their DDL names the tables those
10
+ * create, and the columns come before the list indexes because a partial index names them.
9
11
  *
10
12
  * The one place that order is written. Both hosts store what this returns as a module's
11
13
  * migrations, and `substrat push` calls it (from the vertical's own kernel) to carry the SQL a
@@ -20,6 +22,7 @@ export declare function moduleMigrations(registration: {
20
22
  readonly id: string;
21
23
  readonly searchables?: readonly SearchableDeclaration[];
22
24
  readonly lists?: readonly ListDeclaration[];
25
+ readonly entityStates?: readonly EntityStateDeclaration[];
23
26
  };
24
27
  readonly migrations?: readonly SqlMigration[];
25
28
  }): SqlMigration[];
@@ -1 +1 @@
1
- {"version":3,"file":"module-migrations.d.ts","sourceRoot":"","sources":["../src/module-migrations.ts"],"names":[],"mappings":"AAAA,OAAO,EAAuB,KAAK,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAC5E,OAAO,EAAyB,KAAK,qBAAqB,EAAE,MAAM,mBAAmB,CAAC;AACtF,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAEpD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,gBAAgB,CAAC,YAAY,EAAE;IAC7C,QAAQ,CAAC,QAAQ,EAAE;QACjB,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;QACpB,QAAQ,CAAC,WAAW,CAAC,EAAE,SAAS,qBAAqB,EAAE,CAAC;QACxD,QAAQ,CAAC,KAAK,CAAC,EAAE,SAAS,eAAe,EAAE,CAAC;KAC7C,CAAC;IACF,QAAQ,CAAC,UAAU,CAAC,EAAE,SAAS,YAAY,EAAE,CAAC;CAC/C,GAAG,YAAY,EAAE,CAGjB"}
1
+ {"version":3,"file":"module-migrations.d.ts","sourceRoot":"","sources":["../src/module-migrations.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,yBAAyB,CAAC;AAEtE,OAAO,EAAuB,KAAK,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAC5E,OAAO,EAAyB,KAAK,qBAAqB,EAAE,MAAM,mBAAmB,CAAC;AACtF,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAEpD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,gBAAgB,CAAC,YAAY,EAAE;IAC7C,QAAQ,CAAC,QAAQ,EAAE;QACjB,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;QACpB,QAAQ,CAAC,WAAW,CAAC,EAAE,SAAS,qBAAqB,EAAE,CAAC;QACxD,QAAQ,CAAC,KAAK,CAAC,EAAE,SAAS,eAAe,EAAE,CAAC;QAC5C,QAAQ,CAAC,YAAY,CAAC,EAAE,SAAS,sBAAsB,EAAE,CAAC;KAC3D,CAAC;IACF,QAAQ,CAAC,UAAU,CAAC,EAAE,SAAS,YAAY,EAAE,CAAC;CAC/C,GAAG,YAAY,EAAE,CAQjB"}
@@ -1,10 +1,12 @@
1
+ import { entityStateMigrations } from './entity-state.js';
1
2
  import { listIndexMigrations } from './list-index.js';
2
3
  import { searchIndexMigrations } from './search-index.js';
3
4
  /**
4
5
  * Every migration a module's scope applies, in the order the host applies them: the module's
5
- * own `migrations`, then the search indexes its `searchables` declare (#827), then the list
6
- * indexes its `lists` declare (#811). The two derived sets come after the authored ones because
7
- * their DDL names the tables those create.
6
+ * own `migrations`, then the search indexes its `searchables` declare (#827), then the archive and
7
+ * trash columns its `entityStates` declare (#119), then the list indexes its `lists` declare
8
+ * (#811). The derived sets come after the authored ones because their DDL names the tables those
9
+ * create, and the columns come before the list indexes because a partial index names them.
8
10
  *
9
11
  * The one place that order is written. Both hosts store what this returns as a module's
10
12
  * migrations, and `substrat push` calls it (from the vertical's own kernel) to carry the SQL a
@@ -15,7 +17,12 @@ import { searchIndexMigrations } from './search-index.js';
15
17
  * Structural, so a `ModuleRegistration` fits and so does a module read back as data.
16
18
  */
17
19
  export function moduleMigrations(registration) {
18
- const { id, searchables, lists } = registration.manifest;
19
- return [...(registration.migrations ?? []), ...searchIndexMigrations(id, searchables), ...listIndexMigrations(id, lists)];
20
+ const { id, searchables, lists, entityStates } = registration.manifest;
21
+ return [
22
+ ...(registration.migrations ?? []),
23
+ ...searchIndexMigrations(id, searchables),
24
+ ...entityStateMigrations(id, entityStates),
25
+ ...listIndexMigrations(id, lists, entityStates),
26
+ ];
20
27
  }
21
28
  //# sourceMappingURL=module-migrations.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"module-migrations.js","sourceRoot":"","sources":["../src/module-migrations.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAwB,MAAM,iBAAiB,CAAC;AAC5E,OAAO,EAAE,qBAAqB,EAA8B,MAAM,mBAAmB,CAAC;AAGtF;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,gBAAgB,CAAC,YAOhC;IACC,MAAM,EAAE,EAAE,EAAE,WAAW,EAAE,KAAK,EAAE,GAAG,YAAY,CAAC,QAAQ,CAAC;IACzD,OAAO,CAAC,GAAG,CAAC,YAAY,CAAC,UAAU,IAAI,EAAE,CAAC,EAAE,GAAG,qBAAqB,CAAC,EAAE,EAAE,WAAW,CAAC,EAAE,GAAG,mBAAmB,CAAC,EAAE,EAAE,KAAK,CAAC,CAAC,CAAC;AAC5H,CAAC"}
1
+ {"version":3,"file":"module-migrations.js","sourceRoot":"","sources":["../src/module-migrations.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,qBAAqB,EAAE,MAAM,mBAAmB,CAAC;AAC1D,OAAO,EAAE,mBAAmB,EAAwB,MAAM,iBAAiB,CAAC;AAC5E,OAAO,EAAE,qBAAqB,EAA8B,MAAM,mBAAmB,CAAC;AAGtF;;;;;;;;;;;;;;GAcG;AACH,MAAM,UAAU,gBAAgB,CAAC,YAQhC;IACC,MAAM,EAAE,EAAE,EAAE,WAAW,EAAE,KAAK,EAAE,YAAY,EAAE,GAAG,YAAY,CAAC,QAAQ,CAAC;IACvE,OAAO;QACL,GAAG,CAAC,YAAY,CAAC,UAAU,IAAI,EAAE,CAAC;QAClC,GAAG,qBAAqB,CAAC,EAAE,EAAE,WAAW,CAAC;QACzC,GAAG,qBAAqB,CAAC,EAAE,EAAE,YAAY,CAAC;QAC1C,GAAG,mBAAmB,CAAC,EAAE,EAAE,KAAK,EAAE,YAAY,CAAC;KAChD,CAAC;AACJ,CAAC"}
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Business volumes on a clock (#1750): calls per `(entityType, operation)` per time bucket,
3
+ * counted over a scope's outbox. Which pairs are worth counting is the caller's, derived
4
+ * from the model by the dashboard's `lifecycleMovesOf` (see `@substrat-run/contracts`'s
5
+ * `operation-series.ts` for why those counts are exact).
6
+ *
7
+ * ## Bounded, and index-backed with no new index
8
+ *
9
+ * The window is capped at seven days and the answer at `OPERATION_SERIES_MAX_BUCKETS` per
10
+ * pair, and it is ONE aggregate statement: no row leaves SQLite, so there is no per-row
11
+ * work on this side of it. The rows it scans are a CLOSED range of the primary key, so the
12
+ * work is the window's rows whatever lies after it — a window years ago visits only its own.
13
+ * An event id is minted from the instant the operation ran (#956) by a mint whose floor only
14
+ * rises, so an id never carries a time BEFORE its own `occurred_at`: every event at or after
15
+ * `since` has an id at or above `ulidFloor(since)`. The key range ends at
16
+ * `ulidFloor(until + OPERATION_SERIES_ID_SLACK_MS)`, a fixed sliver past the window.
17
+ * `occurred_at` stays the exact filter on both ends; the key range only bounds the scan.
18
+ *
19
+ * The slack is for ids that run AHEAD of their instants. `createUlid` (ulid.ts) holds a
20
+ * clock that is at or behind its floor at `lastTime` — and bumps a millisecond on the
21
+ * (astronomically rare) random overflow — and `seedFrom` raises that floor to the outbox's
22
+ * `MAX(id)` when a writer wakes. So an event in the window's last moments can carry an id
23
+ * past `until`, by as much as the clock stepped back. The mint itself sets no bound on
24
+ * that; the wall clock does: the Durable Object host reads the platform's NTP-disciplined
25
+ * clock, whose corrections are sub-second. `OPERATION_SERIES_ID_SLACK_MS` covers that with
26
+ * two orders of margin, at the cost of scanning one more minute of rows. `occurred_at`
27
+ * stays the exact filter, so the slack admits rows to the scan, never to the count.
28
+ *
29
+ * Past the slack, stated: a step back larger than a minute (a pure host's injected clock
30
+ * rewound by hand, a floor seeded from an id planted in the future) leaves an id beyond
31
+ * the range, and that event is not counted by a window ending before its id.
32
+ *
33
+ * The bucket arithmetic casts its parameters: a driver may bind a JS number as REAL, and
34
+ * then the division would place an event at its own instant instead of its bucket's start.
35
+ *
36
+ * The pairs go in as ONE bound JSON array, matched with `json_each`, rather than a
37
+ * placeholder each: 64 pairs as two `IN` lists is 128 parameters, past the Durable
38
+ * Object's 100.
39
+ *
40
+ * Same permission posture as every read in `timeline.ts`: the caller checks, this does not.
41
+ */
42
+ import type { OperationSeriesInput, OperationSeriesResult } from '@substrat-run/contracts';
43
+ import type { TimelineReader } from './timeline.js';
44
+ /**
45
+ * How far past `until` the key range reaches, for an id held at the mint's floor while its
46
+ * instant was inside the window (see the header for where the size comes from).
47
+ */
48
+ export declare const OPERATION_SERIES_ID_SLACK_MS = 60000;
49
+ /**
50
+ * The read's one statement and its parameters — exported so each adapter's suite can ask
51
+ * its own SQLite for the plan of exactly what runs, not a copy of it.
52
+ */
53
+ export declare function operationSeriesQuery(input: OperationSeriesInput): {
54
+ sql: string;
55
+ params: Array<string | number>;
56
+ };
57
+ export declare function readOperationSeries(ctx: TimelineReader, input: OperationSeriesInput): OperationSeriesResult;
58
+ //# sourceMappingURL=operation-series.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"operation-series.d.ts","sourceRoot":"","sources":["../src/operation-series.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AACH,OAAO,KAAK,EAAE,oBAAoB,EAAE,qBAAqB,EAAE,MAAM,yBAAyB,CAAC;AAC3F,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAGpD;;;GAGG;AACH,eAAO,MAAM,4BAA4B,QAAS,CAAC;AAKnD;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,oBAAoB,GAAG;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,KAAK,CAAC,MAAM,GAAG,MAAM,CAAC,CAAA;CAAE,CA2BjH;AAED,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,cAAc,EAAE,KAAK,EAAE,oBAAoB,GAAG,qBAAqB,CAqB3G"}
@@ -0,0 +1,63 @@
1
+ import { ulidFloor } from './ulid.js';
2
+ /**
3
+ * How far past `until` the key range reaches, for an id held at the mint's floor while its
4
+ * instant was inside the window (see the header for where the size comes from).
5
+ */
6
+ export const OPERATION_SERIES_ID_SLACK_MS = 60_000;
7
+ /** Joins a pair into one key — a control character no entity or operation name carries. */
8
+ const SEP = '\u001f';
9
+ /**
10
+ * The read's one statement and its parameters — exported so each adapter's suite can ask
11
+ * its own SQLite for the plan of exactly what runs, not a copy of it.
12
+ */
13
+ export function operationSeriesQuery(input) {
14
+ const sinceMs = Date.parse(input.since);
15
+ const untilMs = Date.parse(input.until);
16
+ const keys = [...new Set(input.moves.map((m) => `${m.entityType}${SEP}${m.operation}`))];
17
+ // A distinct (entity, call) is one move: a call that emitted three events about one
18
+ // record moved it once. A row from before invocation ids falls back to its own id.
19
+ return {
20
+ sql: `SELECT entity_type, operation,
21
+ (CAST(strftime('%s', occurred_at) AS INTEGER) - CAST(? AS INTEGER)) / CAST(? AS INTEGER) AS b,
22
+ COUNT(DISTINCT entity_id || char(31) || COALESCE(invocation_id, id)) AS n
23
+ FROM _substrat_outbox
24
+ WHERE id >= ? AND id < ? AND occurred_at >= ? AND occurred_at < ?
25
+ AND entity_type || char(31) || operation IN (SELECT value FROM json_each(?))
26
+ GROUP BY entity_type, operation, b
27
+ ORDER BY b`,
28
+ params: [
29
+ sinceMs / 1000,
30
+ input.bucketMinutes * 60,
31
+ ulidFloor(sinceMs),
32
+ // `until` is at most the end of 9999 (the input schema's bound), so a minute past it is
33
+ // far inside what a ULID encodes: `ulidFloor` cannot throw here.
34
+ ulidFloor(untilMs + OPERATION_SERIES_ID_SLACK_MS),
35
+ new Date(sinceMs).toISOString(),
36
+ new Date(untilMs).toISOString(),
37
+ JSON.stringify(keys),
38
+ ],
39
+ };
40
+ }
41
+ export function readOperationSeries(ctx, input) {
42
+ const sinceMs = Date.parse(input.since);
43
+ const step = input.bucketMinutes * 60;
44
+ const q = operationSeriesQuery(input);
45
+ const rows = ctx.sql.query(q.sql, q.params);
46
+ const counted = new Map();
47
+ for (const r of rows) {
48
+ const key = `${r.entity_type}${SEP}${r.operation}`;
49
+ const list = counted.get(key) ?? [];
50
+ list.push({ start: new Date(sinceMs + r.b * step * 1000).toISOString(), count: r.n });
51
+ counted.set(key, list);
52
+ }
53
+ return {
54
+ since: input.since,
55
+ until: input.until,
56
+ bucketMinutes: input.bucketMinutes,
57
+ series: input.moves.map((m) => {
58
+ const buckets = counted.get(`${m.entityType}${SEP}${m.operation}`) ?? [];
59
+ return { entityType: m.entityType, operation: m.operation, total: buckets.reduce((n, b) => n + b.count, 0), buckets };
60
+ }),
61
+ };
62
+ }
63
+ //# sourceMappingURL=operation-series.js.map