@smthrs/notifications 0.0.0-stage → 1.0.0-rc.12

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 (146) hide show
  1. package/CHANGELOG.md +127 -0
  2. package/LICENSE +21 -0
  3. package/README.md +93 -2
  4. package/dist/cjs/AlertPolicy.d.ts +284 -0
  5. package/dist/cjs/AlertPolicy.d.ts.map +1 -0
  6. package/dist/cjs/AlertPolicy.js +124 -0
  7. package/dist/cjs/AlertPolicy.js.map +7 -0
  8. package/dist/cjs/AlertRuntime.d.ts +90 -0
  9. package/dist/cjs/AlertRuntime.d.ts.map +1 -0
  10. package/dist/cjs/AlertRuntime.js +166 -0
  11. package/dist/cjs/AlertRuntime.js.map +7 -0
  12. package/dist/cjs/AlertSink.d.ts +101 -0
  13. package/dist/cjs/AlertSink.d.ts.map +1 -0
  14. package/dist/cjs/AlertSink.js +44 -0
  15. package/dist/cjs/AlertSink.js.map +7 -0
  16. package/dist/cjs/Alerts.d.ts +57 -0
  17. package/dist/cjs/Alerts.d.ts.map +1 -0
  18. package/dist/cjs/Alerts.js +46 -0
  19. package/dist/cjs/Alerts.js.map +7 -0
  20. package/dist/cjs/Notification.d.ts +214 -0
  21. package/dist/cjs/Notification.d.ts.map +1 -0
  22. package/dist/cjs/Notification.js +62 -0
  23. package/dist/cjs/Notification.js.map +7 -0
  24. package/dist/cjs/NotificationEvent.d.ts +214 -0
  25. package/dist/cjs/NotificationEvent.d.ts.map +1 -0
  26. package/dist/cjs/NotificationEvent.js +84 -0
  27. package/dist/cjs/NotificationEvent.js.map +7 -0
  28. package/dist/cjs/NotificationQueue.d.ts +194 -0
  29. package/dist/cjs/NotificationQueue.d.ts.map +1 -0
  30. package/dist/cjs/NotificationQueue.js +477 -0
  31. package/dist/cjs/NotificationQueue.js.map +7 -0
  32. package/dist/cjs/NotificationState.d.ts +169 -0
  33. package/dist/cjs/NotificationState.d.ts.map +1 -0
  34. package/dist/cjs/NotificationState.js +110 -0
  35. package/dist/cjs/NotificationState.js.map +7 -0
  36. package/dist/cjs/Projection.d.ts +30 -0
  37. package/dist/cjs/Projection.d.ts.map +1 -0
  38. package/dist/cjs/Projection.js +53 -0
  39. package/dist/cjs/Projection.js.map +7 -0
  40. package/dist/cjs/SteerPayload.d.ts +177 -0
  41. package/dist/cjs/SteerPayload.d.ts.map +1 -0
  42. package/dist/cjs/SteerPayload.js +61 -0
  43. package/dist/cjs/SteerPayload.js.map +7 -0
  44. package/dist/cjs/index.d.ts +41 -0
  45. package/dist/cjs/index.d.ts.map +1 -0
  46. package/dist/cjs/index.js +47 -0
  47. package/dist/cjs/index.js.map +7 -0
  48. package/dist/cjs/internal/foldCache.d.ts +36 -0
  49. package/dist/cjs/internal/foldCache.d.ts.map +1 -0
  50. package/dist/cjs/internal/foldCache.js +54 -0
  51. package/dist/cjs/internal/foldCache.js.map +7 -0
  52. package/dist/cjs/internal/replay.d.ts +22 -0
  53. package/dist/cjs/internal/replay.d.ts.map +1 -0
  54. package/dist/cjs/internal/replay.js +46 -0
  55. package/dist/cjs/internal/replay.js.map +7 -0
  56. package/dist/cjs/layerWebhook.d.ts +60 -0
  57. package/dist/cjs/layerWebhook.d.ts.map +1 -0
  58. package/dist/cjs/layerWebhook.js +145 -0
  59. package/dist/cjs/layerWebhook.js.map +7 -0
  60. package/dist/cjs/package.json +1 -0
  61. package/dist/esm/AlertPolicy.d.ts +284 -0
  62. package/dist/esm/AlertPolicy.d.ts.map +1 -0
  63. package/dist/esm/AlertPolicy.js +284 -0
  64. package/dist/esm/AlertPolicy.js.map +1 -0
  65. package/dist/esm/AlertRuntime.d.ts +90 -0
  66. package/dist/esm/AlertRuntime.d.ts.map +1 -0
  67. package/dist/esm/AlertRuntime.js +191 -0
  68. package/dist/esm/AlertRuntime.js.map +1 -0
  69. package/dist/esm/AlertSink.d.ts +101 -0
  70. package/dist/esm/AlertSink.d.ts.map +1 -0
  71. package/dist/esm/AlertSink.js +63 -0
  72. package/dist/esm/AlertSink.js.map +1 -0
  73. package/dist/esm/Alerts.d.ts +57 -0
  74. package/dist/esm/Alerts.d.ts.map +1 -0
  75. package/dist/esm/Alerts.js +57 -0
  76. package/dist/esm/Alerts.js.map +1 -0
  77. package/dist/esm/Notification.d.ts +214 -0
  78. package/dist/esm/Notification.d.ts.map +1 -0
  79. package/dist/esm/Notification.js +121 -0
  80. package/dist/esm/Notification.js.map +1 -0
  81. package/dist/esm/NotificationEvent.d.ts +214 -0
  82. package/dist/esm/NotificationEvent.d.ts.map +1 -0
  83. package/dist/esm/NotificationEvent.js +158 -0
  84. package/dist/esm/NotificationEvent.js.map +1 -0
  85. package/dist/esm/NotificationQueue.d.ts +194 -0
  86. package/dist/esm/NotificationQueue.d.ts.map +1 -0
  87. package/dist/esm/NotificationQueue.js +625 -0
  88. package/dist/esm/NotificationQueue.js.map +1 -0
  89. package/dist/esm/NotificationState.d.ts +169 -0
  90. package/dist/esm/NotificationState.d.ts.map +1 -0
  91. package/dist/esm/NotificationState.js +191 -0
  92. package/dist/esm/NotificationState.js.map +1 -0
  93. package/dist/esm/Projection.d.ts +30 -0
  94. package/dist/esm/Projection.d.ts.map +1 -0
  95. package/dist/esm/Projection.js +47 -0
  96. package/dist/esm/Projection.js.map +1 -0
  97. package/dist/esm/SteerPayload.d.ts +177 -0
  98. package/dist/esm/SteerPayload.d.ts.map +1 -0
  99. package/dist/esm/SteerPayload.js +139 -0
  100. package/dist/esm/SteerPayload.js.map +1 -0
  101. package/dist/esm/index.d.ts +41 -0
  102. package/dist/esm/index.d.ts.map +1 -0
  103. package/dist/esm/index.js +41 -0
  104. package/dist/esm/index.js.map +1 -0
  105. package/dist/esm/internal/foldCache.d.ts +36 -0
  106. package/dist/esm/internal/foldCache.d.ts.map +1 -0
  107. package/dist/esm/internal/foldCache.js +58 -0
  108. package/dist/esm/internal/foldCache.js.map +1 -0
  109. package/dist/esm/internal/replay.d.ts +22 -0
  110. package/dist/esm/internal/replay.d.ts.map +1 -0
  111. package/dist/esm/internal/replay.js +31 -0
  112. package/dist/esm/internal/replay.js.map +1 -0
  113. package/dist/esm/layerWebhook.d.ts +60 -0
  114. package/dist/esm/layerWebhook.d.ts.map +1 -0
  115. package/dist/esm/layerWebhook.js +186 -0
  116. package/dist/esm/layerWebhook.js.map +1 -0
  117. package/docs/README.md +146 -0
  118. package/docs/api.md +443 -0
  119. package/docs/concepts/admission-and-promotion.md +144 -0
  120. package/docs/concepts/alerting.md +138 -0
  121. package/docs/concepts/journal-records.md +132 -0
  122. package/docs/guides/alert-on-a-stuck-run.md +140 -0
  123. package/docs/guides/drain-at-a-turn-boundary.md +95 -0
  124. package/docs/guides/handle-a-full-queue.md +102 -0
  125. package/docs/guides/report-pending-notifications.md +92 -0
  126. package/docs/guides/send-alerts-to-a-webhook.md +155 -0
  127. package/docs/guides/steer-a-run.md +127 -0
  128. package/docs/guides/testing.md +161 -0
  129. package/docs/installation.md +123 -0
  130. package/docs/quickstart.md +159 -0
  131. package/docs/troubleshooting.md +254 -0
  132. package/package.json +182 -3
  133. package/src/AlertPolicy.ts +377 -0
  134. package/src/AlertRuntime.ts +280 -0
  135. package/src/AlertSink.ts +105 -0
  136. package/src/Alerts.ts +71 -0
  137. package/src/Notification.ts +171 -0
  138. package/src/NotificationEvent.ts +213 -0
  139. package/src/NotificationQueue.ts +856 -0
  140. package/src/NotificationState.ts +295 -0
  141. package/src/Projection.ts +48 -0
  142. package/src/SteerPayload.ts +198 -0
  143. package/src/index.ts +47 -0
  144. package/src/internal/foldCache.ts +72 -0
  145. package/src/internal/replay.ts +39 -0
  146. package/src/layerWebhook.ts +239 -0
@@ -0,0 +1,856 @@
1
+ /**
2
+ * Journal-backed durable notification admission and turn-boundary drain.
3
+ *
4
+ * Governing contract: `packages/smithers/notifications/docs/api.md`, published as
5
+ * `/api/notifications`.
6
+ *
7
+ * @since 0.1.0
8
+ */
9
+
10
+ import { canonicalize } from "@smthrs/canonical"
11
+ import { digestSync } from "@smthrs/crypto/Sha256"
12
+ import { Journal, JournalEvent } from "@smthrs/journal"
13
+ import { Context, Effect, HashMap, Layer, Option, Schema, SchemaIssue, Semaphore } from "effect"
14
+ import * as FoldCache from "./internal/foldCache.ts"
15
+ import * as Replay from "./internal/replay.ts"
16
+ import * as NotificationModel from "./Notification.ts"
17
+ import * as NotificationEvent from "./NotificationEvent.ts"
18
+ import * as NotificationState from "./NotificationState.ts"
19
+
20
+ /**
21
+ * The queue could not be reached, or it refused what a caller handed it.
22
+ *
23
+ * `code` is the stable half and the one to branch on. `notification_unavailable`
24
+ * is the seam reporting that it serves nothing; `notification_id_reused` says a
25
+ * stable id was admitted once already with different content, which is a
26
+ * producer bug rather than a storage failure; `notification_invalid` says the
27
+ * value is not a notification, and `path` names the field inside it that failed.
28
+ * `notification_closed` means the receiver has finished and the caller must
29
+ * start a new request; `notification_full` means nothing was retained and the
30
+ * same notification may be retried after pending work drains. Neither the
31
+ * message nor `path` ever carries the offending value.
32
+ * `notification_refused` rejects reserved outside-change payloads before admission.
33
+ *
34
+ * @category errors
35
+ * @since 0.1.0
36
+ */
37
+ export class NotificationError extends Schema.TaggedError<NotificationError>()(
38
+ "/notifications/NotificationError",
39
+ {
40
+ code: Schema.Literals([
41
+ "notification_unavailable",
42
+ "notification_closed",
43
+ "notification_full",
44
+ "notification_id_reused",
45
+ "notification_invalid",
46
+ "notification_refused"
47
+ ]).pipe(
48
+ Schema.withConstructorDefault(Effect.succeed("notification_unavailable" as const))
49
+ ),
50
+ message: Schema.String,
51
+ /** The notification the failure is about, when one was readable. */
52
+ notificationId: Schema.optional(Schema.String),
53
+ /** Dotted path of the offending field, for `notification_invalid`. */
54
+ path: Schema.optional(Schema.String)
55
+ }
56
+ ) {}
57
+
58
+ /**
59
+ * What admitting one notification decided.
60
+ *
61
+ * `seq` is the journal sequence the admission committed at, and it is absent
62
+ * exactly when nothing was written, which today means a `rejected-full`
63
+ * refusal. `duplicate` says the id was already admitted, in which case
64
+ * `decision` and `seq` are read back from the committed record rather than
65
+ * recomputed here.
66
+ *
67
+ * A caller MUST inspect `decision`: `rejected-full` means the queue was at
68
+ * capacity and retained nothing, so the notification never reaches the model
69
+ * unless the caller admits it again once a boundary has drained.
70
+ *
71
+ * @category models
72
+ * @since 0.1.0
73
+ */
74
+ export interface AdmissionReceipt {
75
+ readonly notificationId: string
76
+ readonly decision: NotificationState.AdmissionDecision
77
+ readonly seq: number | undefined
78
+ readonly duplicate: boolean
79
+ readonly consumed?: boolean
80
+ readonly consumedNotification?: NotificationModel.Notification
81
+ }
82
+
83
+ /**
84
+ * The boundary a drain is attempted at, and whether the run would go idle
85
+ * if nothing were delivered.
86
+ *
87
+ * `(runId, targetLineageId, boundary)` is the unit of drain: two lineages
88
+ * closing a turn under the same boundary name are two drains, and each is
89
+ * recorded separately.
90
+ *
91
+ * @category models
92
+ * @since 0.1.0
93
+ */
94
+ export interface DrainInput {
95
+ readonly runId: string
96
+ readonly targetLineageId: string
97
+ readonly boundary: string
98
+ readonly wouldIdle: boolean
99
+ /**
100
+ * The journal sequence that opened this turn. A steer admitted after it is
101
+ * held for the next boundary, which is what keeps a message that arrived
102
+ * mid-turn out of the turn already in flight. Omitting it delivers
103
+ * everything pending for the lineage. A value that is not a non-negative
104
+ * integer fails the drain with `notification_invalid` and records nothing.
105
+ */
106
+ readonly cutoffSeq?: number | undefined
107
+ }
108
+
109
+ /**
110
+ * The notifications this boundary delivers, and whether the boundary had
111
+ * already drained.
112
+ *
113
+ * The notifications are the ones the committed promotion record names, so two
114
+ * processes draining one boundary report the same delivery rather than two
115
+ * divergent guesses.
116
+ *
117
+ * @category models
118
+ * @since 0.1.0
119
+ */
120
+ export interface DrainReceipt {
121
+ readonly notifications: ReadonlyArray<NotificationModel.Notification>
122
+ readonly boundary: string
123
+ readonly duplicate: boolean
124
+ }
125
+
126
+ /**
127
+ * The durable pending queue: admit a notification exactly once, and drain
128
+ * what a boundary is allowed to deliver.
129
+ *
130
+ * @category services
131
+ * @since 0.1.0
132
+ */
133
+ export interface Service {
134
+ readonly admit: (
135
+ runId: string,
136
+ notification: NotificationModel.Notification,
137
+ version?: number
138
+ ) => Effect.Effect<AdmissionReceipt, Journal.JournalError | NotificationError>
139
+ readonly drain: (
140
+ input: DrainInput
141
+ ) => Effect.Effect<DrainReceipt, Journal.JournalError | NotificationError>
142
+ /**
143
+ * What this run has been told that no boundary has delivered yet, in
144
+ * admission order.
145
+ *
146
+ * The queue is the owner of that fact: pending is admitted minus promoted,
147
+ * and both halves are its own journal records. A supervisor that counted
148
+ * admissions alone would report a steer as waiting forever.
149
+ */
150
+ readonly pending: (
151
+ runId: string
152
+ ) => Effect.Effect<ReadonlyArray<NotificationModel.Notification>, Journal.JournalError | NotificationError>
153
+ }
154
+
155
+ /**
156
+ * The {@link Service} tag.
157
+ *
158
+ * @category services
159
+ * @since 0.1.0
160
+ */
161
+ export class NotificationQueue extends Context.Service<NotificationQueue, Service>()(
162
+ "/notifications/NotificationQueue"
163
+ ) {}
164
+
165
+ /**
166
+ * Builds a {@link Service} from an implementation of its methods.
167
+ *
168
+ * @param implementation the methods to serve
169
+ * @category constructors
170
+ * @since 0.1.0
171
+ */
172
+ export const make = (implementation: Service): Service => NotificationQueue.of(implementation)
173
+
174
+ const unavailable = (operation: string): NotificationError =>
175
+ new NotificationError({ code: "notification_unavailable", message: `${operation} is unavailable` })
176
+
177
+ /**
178
+ * A {@link Service} that fails every method as unavailable. Overrides
179
+ * replace individual methods.
180
+ *
181
+ * @param overrides methods to serve instead of failing
182
+ * @category constructors
183
+ * @since 0.1.0
184
+ */
185
+ export const makeNoop = (overrides: Partial<Service> = {}): Service =>
186
+ make({
187
+ admit: Effect.fn("NotificationQueue.admit")(() => Effect.fail(unavailable("admit"))),
188
+ drain: Effect.fn("NotificationQueue.drain")(() => Effect.fail(unavailable("drain"))),
189
+ pending: Effect.fn("NotificationQueue.pending")(() => Effect.fail(unavailable("pending"))),
190
+ ...overrides
191
+ })
192
+
193
+ /**
194
+ * Provides {@link makeNoop}.
195
+ *
196
+ * @param overrides methods to serve instead of failing
197
+ * @category layers
198
+ * @since 0.1.0
199
+ */
200
+ export const layerNoop = (overrides: Partial<Service> = {}): Layer.Layer<NotificationQueue> =>
201
+ Layer.succeed(NotificationQueue)(makeNoop(overrides))
202
+
203
+ /**
204
+ * The deepest a notification's JSON may nest.
205
+ *
206
+ * The bound exists because the value is walked and serialized on the way to
207
+ * the journal, and an unbounded walk turns a hostile payload into an untyped
208
+ * `RangeError` instead of a refusal a caller can read.
209
+ */
210
+ const maximumPayloadDepth = 256
211
+
212
+ /**
213
+ * The largest a notification may be, in UTF-16 code units: every string and
214
+ * object key counted at its length, and every other value as one.
215
+ *
216
+ * The bound exists because an admission copies, decodes, canonicalizes,
217
+ * fingerprints, and journals the whole value, so a producer admitting a huge
218
+ * array or string spends the notifying process's CPU and memory and bloats
219
+ * every replay of the run. It is checked before any of that work starts.
220
+ */
221
+ const maximumNotificationCodeUnits = 1_048_576
222
+
223
+ /** Bounds an issue message so a refusal cannot journal or log an essay. */
224
+ const maximumIssueCodeUnits = 200
225
+
226
+ /**
227
+ * The bound a value breaks, if any, walked iteratively so the walk itself
228
+ * cannot exhaust the stack. A cycle is caught by the depth bound: it has no
229
+ * finite depth, so it trips the limit rather than looping forever. The size
230
+ * count stops the walk as soon as it passes the bound, so an oversized value
231
+ * costs the walk no more than the bound does.
232
+ */
233
+ const exceededBound = (value: unknown): "depth" | "size" | undefined => {
234
+ const pending: Array<{ readonly value: unknown; readonly depth: number }> = [{ value, depth: 0 }]
235
+ let size = 0
236
+ while (pending.length > 0) {
237
+ const next = pending.pop()!
238
+ size += typeof next.value === "string" ? next.value.length : 1
239
+ if (size > maximumNotificationCodeUnits) return "size"
240
+ if (typeof next.value !== "object" || next.value === null) continue
241
+ if (next.depth >= maximumPayloadDepth) return "depth"
242
+ if (Array.isArray(next.value)) {
243
+ for (const child of next.value) pending.push({ value: child, depth: next.depth + 1 })
244
+ continue
245
+ }
246
+ for (const [key, child] of Object.entries(next.value)) {
247
+ size += key.length
248
+ pending.push({ value: child, depth: next.depth + 1 })
249
+ }
250
+ }
251
+ return undefined
252
+ }
253
+
254
+ /**
255
+ * A structural copy of the caller's value, so nothing the queue journals or
256
+ * retains can be edited afterwards through a reference the caller kept. Values
257
+ * that are not arrays or objects pass through, which is what leaves a function
258
+ * or a symbol in place for the schema to reject.
259
+ */
260
+ const copied = (value: unknown): unknown => {
261
+ if (Array.isArray(value)) return value.map(copied)
262
+ if (typeof value === "object" && value !== null) {
263
+ return Object.fromEntries(Object.entries(value).map(([key, nested]) => [key, copied(nested)]))
264
+ }
265
+ return value
266
+ }
267
+
268
+ const formatIssue = SchemaIssue.makeFormatterStandardSchemaV1({ leafHook: (issue) => issue._tag })
269
+
270
+ const bounded = (text: string): string =>
271
+ text.length <= maximumIssueCodeUnits ? text : `${text.slice(0, maximumIssueCodeUnits - 3)}...`
272
+
273
+ const identifiedBy = (value: unknown): string | undefined => {
274
+ if (typeof value !== "object" || value === null) return undefined
275
+ const id = (value as Readonly<Record<string, unknown>>)["id"]
276
+ return typeof id === "string" ? id : undefined
277
+ }
278
+
279
+ const decodeNotification = Schema.decodeUnknownEffect(NotificationModel.Notification)
280
+
281
+ /**
282
+ * Decodes the caller's argument at the durability boundary and returns a value
283
+ * the caller cannot reach.
284
+ *
285
+ * Nothing downstream re-checks the shape: a structurally invalid notification
286
+ * that reached the journal would be acknowledged at a real sequence and then
287
+ * skipped by every replay, which is acknowledged data loss. The depth and size
288
+ * bounds run first so a hostile payload can neither exhaust the stack nor make
289
+ * the copy, the decode, and the fingerprint walk it in full.
290
+ */
291
+ const validated = (
292
+ notification: NotificationModel.Notification
293
+ ): Effect.Effect<NotificationModel.Notification, NotificationError> =>
294
+ Effect.gen(function*() {
295
+ const notificationId = identifiedBy(notification)
296
+ const exceeded = exceededBound(notification)
297
+ if (exceeded !== undefined) {
298
+ return yield* new NotificationError({
299
+ code: "notification_invalid",
300
+ message: exceeded === "depth"
301
+ ? `A notification nests deeper than the ${maximumPayloadDepth} level bound`
302
+ : `A notification is larger than the ${maximumNotificationCodeUnits} code unit bound`,
303
+ ...(notificationId === undefined ? {} : { notificationId })
304
+ })
305
+ }
306
+ return yield* decodeNotification(copied(notification)).pipe(
307
+ Effect.mapError((error) => {
308
+ // The formatter reports at least one issue for a failure and gives
309
+ // every issue a concrete path. Standard Schema's result type widens
310
+ // both, so a fallback for either would be code no input can reach.
311
+ const issue = formatIssue(error.issue).issues[0] as {
312
+ readonly message: string
313
+ readonly path: ReadonlyArray<PropertyKey>
314
+ }
315
+ const path = issue.path.map(String).join(".")
316
+ return new NotificationError({
317
+ code: "notification_invalid",
318
+ message: bounded(issue.message),
319
+ ...(notificationId === undefined ? {} : { notificationId }),
320
+ ...(path === "" ? {} : { path })
321
+ })
322
+ })
323
+ )
324
+ })
325
+
326
+ // Persisted notification JSON predates RFC 8785's rejection of lone surrogates.
327
+ const canonical = (value: unknown): string => canonicalize(value, { loneSurrogates: "escape" })
328
+
329
+ /** Fingerprint the validated input before the journal redacts its payload. */
330
+ const fingerprint = (notification: NotificationModel.Notification): Effect.Effect<string> =>
331
+ Effect.sync(() => digestSync(canonical(notification)))
332
+
333
+ /** Freeze the decoded JSON graph before any queue output can expose it. */
334
+ const freeze = (value: unknown): void => {
335
+ if (typeof value !== "object" || value === null) return
336
+ for (const nested of Object.values(value)) freeze(nested)
337
+ Object.freeze(value)
338
+ }
339
+
340
+ /**
341
+ * One committed admission, as the journal recorded it.
342
+ *
343
+ * This is an identity, not a payload: the fold keeps the fingerprint that
344
+ * settles a duplicate and the sequence the record lives at, and reads the
345
+ * notification itself back from the journal on the rare path that needs it.
346
+ * Retaining every admitted payload for a run's lifetime is what made a
347
+ * capacity-one queue grow without bound.
348
+ */
349
+ interface Committed {
350
+ readonly version: number | undefined
351
+ readonly fingerprint: string | undefined
352
+ readonly decision: NotificationState.AdmissionDecision
353
+ readonly seq: number
354
+ }
355
+
356
+ /**
357
+ * One run's folded notification history.
358
+ *
359
+ * `cursor` is the sequence of the last entry folded, absent when the run has
360
+ * no entries at all. It is what the next read pages from, so a fold costs the
361
+ * entries added since the previous call rather than the run's whole journal.
362
+ */
363
+ interface Loaded {
364
+ readonly state: NotificationState.State
365
+ readonly admissions: HashMap.HashMap<string, Committed>
366
+ /** Drain identity to the sequence its record committed at. */
367
+ readonly promotions: HashMap.HashMap<string, number>
368
+ readonly cursor: number | undefined
369
+ }
370
+
371
+ /**
372
+ * How many runs one layer keeps folded at a time. Evicting is safe: the next
373
+ * call for an evicted run folds it again from the beginning, and the retention
374
+ * policy is scan-resistant, so a supervisor sweeping more runs than this reads
375
+ * journal tails for all but a fixed handful of them.
376
+ */
377
+ const maximumCachedRuns = 64
378
+
379
+ /**
380
+ * Journal-backed production layer, with the pending capacity a composition
381
+ * chooses.
382
+ *
383
+ * Admission and drain evidence is written through `emitDurableUnfenced`, so
384
+ * both return only after the corresponding entry is durably committed and a
385
+ * dropped lifecycle event is unrepresentable. The channel is the unfenced one
386
+ * on purpose: the notifying process owns no run, so there is no ownership
387
+ * fence to hand over, and both records are first-writer-wins on their own
388
+ * identity instead.
389
+ *
390
+ * The capacity guard is a conditional write at the storage layer, not a check
391
+ * against a fold one process happened to load: the fold, the guard, and the
392
+ * admission record are decided and written inside ONE `journal.transact`
393
+ * write transaction, which the durable writer serializes against every other
394
+ * writer of the database and replays when a concurrent writer commits between
395
+ * the guard's reads and the insert. Two processes admitting to one full queue
396
+ * therefore produce one admission and one `rejected-full`, never two
397
+ * admissions.
398
+ *
399
+ * @param options the pending capacity, defaulting to `NotificationState.defaultCapacity`
400
+ * @category layers
401
+ * @since 1.0.0
402
+ */
403
+ export const layerWith = (
404
+ options: { readonly capacity?: number | undefined } = {}
405
+ ): Layer.Layer<NotificationQueue, never, Journal.Journal> =>
406
+ Layer.effect(
407
+ NotificationQueue,
408
+ Effect.gen(function*() {
409
+ const journal = yield* Journal.Journal
410
+ // Orders this layer's own fibers so one process never runs two of its
411
+ // admissions or drains against the fold cache at once. It is
412
+ // process-local by construction and is NOT what enforces capacity or
413
+ // drain identity across processes: those are the conditional writes
414
+ // inside `journal.transact`, decided and committed in one serialized
415
+ // write transaction. Always enter the journal transaction before taking
416
+ // this permit, matching callers already inside an enclosing transaction.
417
+ const operations = yield* Semaphore.make(1)
418
+ const capacity = options.capacity ?? NotificationState.defaultCapacity
419
+ // Only committed folds may be shared. A transaction reads its own
420
+ // inserts, but its read-back is still provisional until the OUTERMOST
421
+ // commit; neither a retry nor a later caller may dedupe against it.
422
+ const folded = FoldCache.make<Loaded>(maximumCachedRuns)
423
+
424
+ /**
425
+ * One entry by the sequence it committed at.
426
+ *
427
+ * The fold keeps identities and sequences rather than payloads, so the
428
+ * paths that must report an admitted notification read it back here.
429
+ */
430
+ const entryAt = (
431
+ runId: JournalEvent.RunId,
432
+ seq: number
433
+ ): Effect.Effect<Option.Option<JournalEvent.Entry>, Journal.JournalError> =>
434
+ Effect.map(
435
+ journal.entries({
436
+ runId,
437
+ ...(seq === 0 ? {} : { after: JournalEvent.Seq.make(seq - 1) }),
438
+ limit: 1
439
+ }),
440
+ (page) => {
441
+ const found = page.entries[0]
442
+ return found !== undefined && found.seq === seq ? Option.some(found) : Option.none()
443
+ }
444
+ )
445
+
446
+ const admittedAt = (
447
+ runId: JournalEvent.RunId,
448
+ seq: number
449
+ ): Effect.Effect<Option.Option<NotificationEvent.Admitted>, Journal.JournalError> =>
450
+ Effect.map(entryAt(runId, seq), (entry) => {
451
+ if (Option.isNone(entry)) return Option.none()
452
+ const decoded = NotificationEvent.fromEntry(entry.value)
453
+ if (Option.isNone(decoded) || !NotificationEvent.isAdmitted(decoded.value)) return Option.none()
454
+ freeze(decoded.value.notification)
455
+ return Option.some(decoded.value)
456
+ })
457
+
458
+ const promotedAt = (
459
+ runId: JournalEvent.RunId,
460
+ seq: number
461
+ ): Effect.Effect<Option.Option<NotificationEvent.Promoted>, Journal.JournalError> =>
462
+ Effect.map(entryAt(runId, seq), (entry) => {
463
+ if (Option.isNone(entry)) return Option.none()
464
+ const decoded = NotificationEvent.fromEntry(entry.value)
465
+ if (Option.isNone(decoded) || !NotificationEvent.isPromoted(decoded.value)) return Option.none()
466
+ return Option.some(decoded.value)
467
+ })
468
+
469
+ const load = (runId: JournalEvent.RunId): Effect.Effect<Loaded, Journal.JournalError> =>
470
+ Effect.gen(function*() {
471
+ const base: Loaded = folded.get(runId) ?? {
472
+ state: NotificationState.empty(capacity),
473
+ admissions: HashMap.empty(),
474
+ promotions: HashMap.empty(),
475
+ cursor: undefined
476
+ }
477
+ const fresh: Array<JournalEvent.Entry> = []
478
+ let after = base.cursor === undefined ? undefined : JournalEvent.Seq.make(base.cursor)
479
+ while (true) {
480
+ // `after` is an exact-optional property upstream, so an explicit
481
+ // `undefined` is not the same as an absent key.
482
+ const page = yield* journal.entries({
483
+ runId,
484
+ eventTypes: [NotificationEvent.AdmittedEventType, NotificationEvent.PromotedEventType],
485
+ ...(after === undefined ? {} : { after }),
486
+ limit: 512
487
+ })
488
+ fresh.push(...page.entries)
489
+ if (!page.hasMore || page.entries.length === 0) break
490
+ after = page.entries.at(-1)!.seq
491
+ }
492
+ if (fresh.length === 0) return base
493
+
494
+ let state = base.state
495
+ let cursor = base.cursor
496
+ // Persistent maps: one event adds a node and leaves the fold the
497
+ // previous load published untouched, where copying both maps would
498
+ // cost the run's whole history on every event and make a long run's
499
+ // folding quadratic in its own admissions.
500
+ let admissions = base.admissions
501
+ let promotions = base.promotions
502
+ for (const entry of fresh) {
503
+ cursor = entry.seq
504
+ const decoded = NotificationEvent.fromEntry(entry)
505
+ if (Option.isNone(decoded)) continue
506
+ const event = decoded.value
507
+ if (NotificationEvent.isAdmitted(event)) {
508
+ freeze(event.notification)
509
+ admissions = HashMap.set(admissions, event.notification.id, {
510
+ version: event.version,
511
+ fingerprint: event.fingerprint,
512
+ decision: event.decision,
513
+ seq: entry.seq
514
+ })
515
+ state = Replay.applyAdmitted(state, event, entry.seq)
516
+ } else {
517
+ promotions = HashMap.set(
518
+ promotions,
519
+ NotificationEvent.drainKey(event.targetLineageId, event.boundary),
520
+ entry.seq
521
+ )
522
+ state = NotificationState.applyPromoted(state, event.ids)
523
+ }
524
+ }
525
+
526
+ const loaded: Loaded = { state, admissions, promotions, cursor }
527
+ yield* journal.whenCommitted(Effect.sync(() => folded.put(runId, loaded)))
528
+ return loaded
529
+ })
530
+
531
+ /**
532
+ * One conditional admission: the duplicate check, the capacity guard,
533
+ * and the admission record, decided and written inside ONE journal
534
+ * write transaction.
535
+ *
536
+ * This is the queue's conditional insert, the same read-guarded write
537
+ * the journal itself uses for its dedupe and fence conditions: the
538
+ * guard's reads and the insert share one serialized write transaction,
539
+ * and the durable writer replays the whole body when a concurrent
540
+ * writer commits between them. A decision a stale fold produced can
541
+ * therefore never commit — the replay re-folds against the committed
542
+ * state and a queue that filled meanwhile refuses — however many
543
+ * processes share the database. The process-local `operations`
544
+ * semaphore orders this layer's own fibers only; it is not what
545
+ * enforces capacity across processes.
546
+ */
547
+ const admitInTransaction = (
548
+ runId: JournalEvent.RunId,
549
+ admitted: NotificationModel.Notification,
550
+ admittedFingerprint: string,
551
+ version?: number
552
+ ): Effect.Effect<AdmissionReceipt, Journal.JournalError | NotificationError> =>
553
+ Effect.gen(function*() {
554
+ const loaded = yield* load(runId)
555
+ const prior = Option.getOrUndefined(HashMap.get(loaded.admissions, admitted.id))
556
+ const replacing = prior !== undefined && version !== undefined && version > (prior.version ?? 0)
557
+ if (replacing) {
558
+ const original = yield* admittedAt(runId, prior.seq)
559
+ if (Option.isNone(original)) {
560
+ return yield* new NotificationError({
561
+ code: "notification_unavailable",
562
+ notificationId: admitted.id,
563
+ message: "Original steering input is unreadable"
564
+ })
565
+ }
566
+ const identity = (input: NotificationModel.Notification) => ({ ...input, payload: null })
567
+ if (canonical(identity(original.value.notification)) !== canonical(identity(admitted))) {
568
+ return yield* new NotificationError({
569
+ code: "notification_id_reused",
570
+ notificationId: admitted.id,
571
+ message: "An edit cannot change the notification owner or delivery binding"
572
+ })
573
+ }
574
+ }
575
+ if (replacing && !loaded.state.items.some((item) => item.notification.id === admitted.id)) {
576
+ const original = yield* admittedAt(runId, prior.seq)
577
+ if (Option.isNone(original)) {
578
+ return yield* new NotificationError({
579
+ code: "notification_unavailable",
580
+ message: "Consumed input is unreadable"
581
+ })
582
+ }
583
+ return {
584
+ notificationId: admitted.id,
585
+ decision: prior.decision,
586
+ seq: prior.seq,
587
+ duplicate: true,
588
+ consumed: true,
589
+ consumedNotification: original.value.notification
590
+ }
591
+ }
592
+ if (prior !== undefined && version !== undefined && version < (prior.version ?? 0)) {
593
+ return { notificationId: admitted.id, decision: prior.decision, seq: prior.seq, duplicate: true }
594
+ }
595
+ if (prior !== undefined && !replacing) {
596
+ // Legacy rows have no fingerprint; only their persisted content is
597
+ // available for comparison, and the fold keeps identities rather
598
+ // than payloads, so the original is read back at its sequence.
599
+ // New rows settle on the fingerprint alone and read nothing.
600
+ let same = prior.fingerprint === admittedFingerprint
601
+ if (prior.fingerprint === undefined) {
602
+ const original = yield* admittedAt(runId, prior.seq)
603
+ if (Option.isNone(original)) {
604
+ return yield* new NotificationError({
605
+ code: "notification_unavailable",
606
+ notificationId: admitted.id,
607
+ message: `The admission for notification ${admitted.id} is no longer readable`
608
+ })
609
+ }
610
+ same = canonical(original.value.notification) === canonical(admitted)
611
+ }
612
+ if (!same) {
613
+ return yield* new NotificationError({
614
+ code: "notification_id_reused",
615
+ notificationId: admitted.id,
616
+ message: `Notification id ${admitted.id} was already admitted with different content`
617
+ })
618
+ }
619
+ return {
620
+ notificationId: admitted.id,
621
+ decision: prior.decision,
622
+ seq: prior.seq,
623
+ duplicate: true
624
+ }
625
+ }
626
+
627
+ const admission = replacing ? { decision: "coalesced" as const } : NotificationState.admit(
628
+ loaded.state,
629
+ admitted,
630
+ loaded.cursor === undefined ? 0 : loaded.cursor + 1
631
+ )
632
+ if (admission.decision === "rejected-full") {
633
+ // Nothing is journaled. A rejection recorded as an admission
634
+ // would match on every later attempt and burn the id forever,
635
+ // so the queue refuses in the receipt alone and the caller may
636
+ // admit again once a boundary has drained.
637
+ return {
638
+ notificationId: admitted.id,
639
+ decision: admission.decision,
640
+ seq: undefined,
641
+ duplicate: false
642
+ }
643
+ }
644
+
645
+ const receipt = yield* journal.emitDurableUnfenced(
646
+ new JournalEvent.Input({
647
+ runId,
648
+ sourceId: NotificationEvent.admissionSourceId(admitted.id),
649
+ sourceSeq: JournalEvent.SourceSeq.make(version ?? 0),
650
+ // The sequence is derived from the notification's own id, so
651
+ // a collision IS this admission observed twice. Comparing
652
+ // bytes instead would fail the second writer over `decision`,
653
+ // a field neither caller supplied and each derives from the
654
+ // fill level it happened to load at.
655
+ dedupe: "identity",
656
+ eventType: NotificationEvent.AdmittedEventType,
657
+ payload: {
658
+ notification: admitted,
659
+ decision: admission.decision,
660
+ fingerprint: admittedFingerprint,
661
+ ...(version === undefined ? {} : { version })
662
+ }
663
+ })
664
+ )
665
+ const committed = Option.getOrUndefined(HashMap.get((yield* load(runId)).admissions, admitted.id))
666
+ if (committed === undefined) {
667
+ // Only reachable when the emit deduped against an entry this
668
+ // queue did not write, so nothing was committed and there is
669
+ // no admission to report.
670
+ return yield* new NotificationError({
671
+ code: "notification_id_reused",
672
+ notificationId: admitted.id,
673
+ message: `The journal identity for notification ${admitted.id} holds an event this queue did not write`
674
+ })
675
+ }
676
+ return {
677
+ notificationId: admitted.id,
678
+ decision: committed.decision,
679
+ seq: committed.seq,
680
+ duplicate: receipt._tag === "Duplicate"
681
+ }
682
+ })
683
+
684
+ return make({
685
+ admit: Effect.fn("NotificationQueue.admit")((rawRunId, notification, version) =>
686
+ Effect.gen(function*() {
687
+ if (version !== undefined && (!Number.isSafeInteger(version) || version < 1)) {
688
+ return yield* new NotificationError({
689
+ code: "notification_invalid",
690
+ message: "version must be a positive safe integer"
691
+ })
692
+ }
693
+ const admitted = yield* validated(notification)
694
+ // T-COL-12 stays dark: nothing yet authenticates a watcher burst, pins
695
+ // its coding run or enforces fresh machine reads. Refusing here, before
696
+ // the journal, keeps co-admitted steers deliverable on every replay.
697
+ const payload = admitted.payload
698
+ if (
699
+ typeof payload === "object" && payload !== null && !Array.isArray(payload) &&
700
+ (payload as Readonly<Record<string, unknown>>)["kind"] === "outside_change"
701
+ ) {
702
+ return yield* new NotificationError({
703
+ code: "notification_refused",
704
+ notificationId: admitted.id,
705
+ message: "Outside-change delivery requires authenticated watcher facts and stale-write enforcement"
706
+ })
707
+ }
708
+ const admittedFingerprint = yield* fingerprint(admitted)
709
+ return yield* journal.transact(
710
+ operations.withPermits(1)(
711
+ admitInTransaction(JournalEvent.RunId.make(rawRunId), admitted, admittedFingerprint, version)
712
+ )
713
+ )
714
+ })
715
+ ),
716
+ drain: Effect.fn("NotificationQueue.drain")((input) =>
717
+ journal.transact(
718
+ operations.withPermits(1)(Effect.gen(function*() {
719
+ // A cutoff no sequence can satisfy would commit this boundary as
720
+ // delivering nothing, and every retry would replay that record.
721
+ if (
722
+ input.cutoffSeq !== undefined && !(Number.isSafeInteger(input.cutoffSeq) && input.cutoffSeq >= 0)
723
+ ) {
724
+ return yield* new NotificationError({
725
+ code: "notification_invalid",
726
+ message: "A drain cutoff must be a non-negative integer journal sequence",
727
+ path: "cutoffSeq"
728
+ })
729
+ }
730
+ const runId = JournalEvent.RunId.make(input.runId)
731
+ const key = NotificationEvent.drainKey(input.targetLineageId, input.boundary)
732
+ const loaded = yield* load(runId)
733
+ // A replay reports what the committed record delivered, read
734
+ // back from the journal: the fold holds the identities, not the
735
+ // notifications, so a run's drained payloads are not retained.
736
+ const delivered = (
737
+ record: NotificationEvent.Promoted,
738
+ admissions: HashMap.HashMap<string, Committed>
739
+ ): Effect.Effect<
740
+ ReadonlyArray<NotificationModel.Notification>,
741
+ Journal.JournalError | NotificationError
742
+ > =>
743
+ Effect.forEach(record.ids, (id) =>
744
+ Effect.gen(function*() {
745
+ const committed = HashMap.get(admissions, id)
746
+ const event = Option.isNone(committed)
747
+ ? Option.none<NotificationEvent.Admitted>()
748
+ : yield* admittedAt(runId, committed.value.seq)
749
+ // A committed drain promises this exact delivery. Missing or
750
+ // corrupt payloads cannot become a successful partial replay.
751
+ if (Option.isNone(event)) {
752
+ return yield* new NotificationError({
753
+ code: "notification_unavailable",
754
+ notificationId: id,
755
+ message: `The delivered notification ${id} is no longer readable`
756
+ })
757
+ }
758
+ return event.value.notification
759
+ }))
760
+
761
+ const replayed = (seq: number, admissions: HashMap.HashMap<string, Committed>) =>
762
+ Effect.gen(function*() {
763
+ const record = yield* promotedAt(runId, seq)
764
+ if (Option.isNone(record)) {
765
+ return yield* new NotificationError({
766
+ code: "notification_unavailable",
767
+ message: `The drain record for boundary ${input.boundary} is no longer readable`
768
+ })
769
+ }
770
+ return {
771
+ notifications: yield* delivered(record.value, admissions),
772
+ boundary: input.boundary,
773
+ duplicate: true
774
+ }
775
+ })
776
+
777
+ const prior = HashMap.get(loaded.promotions, key)
778
+ if (Option.isSome(prior)) return yield* replayed(prior.value, loaded.admissions)
779
+
780
+ const cutoff = input.cutoffSeq ?? loaded.cursor ?? 0
781
+ // The existing coding park ladder waits for a person's answer.
782
+ // System facts must remain pending there: promoting them would
783
+ // make the harness treat an outside edit as an answered question.
784
+ // Filtering this projection changes only promoted IDs; the journal
785
+ // fold retains every held admission and its original sequence.
786
+ const promotionState = /^\d+:[a-f0-9]{64}:park:\d+$/.test(input.boundary)
787
+ ? {
788
+ ...loaded.state,
789
+ items: loaded.state.items.filter((item) => {
790
+ const payload = item.notification.payload
791
+ return !(typeof payload === "object" && payload !== null && !Array.isArray(payload) &&
792
+ (payload as Readonly<Record<string, unknown>>).kind === "outside_change")
793
+ })
794
+ }
795
+ : loaded.state
796
+ const steers = NotificationState.promoteSteers(promotionState, cutoff, input.targetLineageId)
797
+ const queued = input.wouldIdle && steers.promoted.length === 0
798
+ ? NotificationState.promoteQueued(steers.state, input.targetLineageId)
799
+ : { state: steers.state, promoted: [] }
800
+ const promoted = [...steers.promoted, ...queued.promoted].map((item) => item.notification)
801
+ const receipt = yield* journal.emitDurableUnfenced(
802
+ new JournalEvent.Input({
803
+ runId,
804
+ sourceId: NotificationEvent.drainSourceId(input.targetLineageId, input.boundary),
805
+ sourceSeq: JournalEvent.SourceSeq.make(0),
806
+ // The identity is the drain: this lineage closing this
807
+ // boundary. Two processes that both reach it have observed one
808
+ // event, and the first record committed is the delivery.
809
+ dedupe: "identity",
810
+ eventType: NotificationEvent.PromotedEventType,
811
+ payload: {
812
+ boundary: input.boundary,
813
+ targetLineageId: input.targetLineageId,
814
+ ids: promoted.map((notification) => notification.id)
815
+ }
816
+ })
817
+ )
818
+ const settled = yield* load(runId)
819
+ const committed = HashMap.get(settled.promotions, key)
820
+ if (Option.isNone(committed)) {
821
+ return yield* new NotificationError({
822
+ code: "notification_unavailable",
823
+ message: `The journal identity for boundary ${input.boundary} holds an event this queue did not write`
824
+ })
825
+ }
826
+ // An accepted emit wrote THIS drain, so the promoted set is the
827
+ // one already in hand and no read-back is owed.
828
+ if (receipt._tag === "Accepted") {
829
+ return { notifications: promoted, boundary: input.boundary, duplicate: false }
830
+ }
831
+ return yield* replayed(committed.value, settled.admissions)
832
+ }))
833
+ )
834
+ ),
835
+ pending: Effect.fn("NotificationQueue.pending")((rawRunId) =>
836
+ Effect.map(
837
+ load(JournalEvent.RunId.make(rawRunId)),
838
+ // The fold `load` already performs: admissions add, promotions
839
+ // remove. Read outside the operations semaphore, because a count
840
+ // taken while a boundary drains is a count taken at some instant
841
+ // either side of it, and blocking a supervisor behind a turn
842
+ // boundary would be the worse answer.
843
+ (loaded) => loaded.state.items.map((item) => item.notification)
844
+ )
845
+ )
846
+ })
847
+ })
848
+ )
849
+
850
+ /**
851
+ * Journal-backed production layer at the default capacity.
852
+ *
853
+ * @category layers
854
+ * @since 0.1.0
855
+ */
856
+ export const layer: Layer.Layer<NotificationQueue, never, Journal.Journal> = layerWith()