@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,159 @@
1
+ ---
2
+ title: "Quickstart"
3
+ description: "Admit a steer and a follow-up to a run, drain them at a turn boundary, and read the receipt, over an in-memory journal that needs no configuration."
4
+ sidebar:
5
+ order: 2
6
+ ---
7
+
8
+ This quickstart runs the durable queue end to end. The journal is the
9
+ production SQLite one over an in-memory database, so nothing is stubbed and
10
+ nothing needs configuring. By the end you will have admitted two notifications
11
+ from one process, drained them from the code that stands in for a run, and read
12
+ back what each step decided.
13
+
14
+ ## Prerequisites
15
+
16
+ - Node.js 26.4.0 or later.
17
+ - A project with the packages installed: `npm install @smthrs/notifications@next @smthrs/journal@next effect`.
18
+
19
+ ## Write the notifications
20
+
21
+ A notification names what will read it, carries the provenance of whoever wrote
22
+ it, and declares how it may be delivered. `targetLineageId` is that first part:
23
+ an opaque address for the run, or for one branch of the run that closes turns of
24
+ its own. The queue only compares it for equality, so `"run-1/root"` below is a
25
+ readable choice rather than a required format. Create `quickstart.ts`:
26
+
27
+ ```ts
28
+ import type { Notification } from "@smthrs/notifications/Notification"
29
+
30
+ const from = {
31
+ sourceRunId: "operator",
32
+ sourceLineageId: "operator",
33
+ sourceTurn: 0,
34
+ sourceActor: "human:will"
35
+ }
36
+
37
+ /** Urgent: this one is delivered in a batch when the current turn closes. */
38
+ const steer: Notification = {
39
+ _tag: "human-steer",
40
+ id: "message-1",
41
+ delivery: "steer",
42
+ targetLineageId: "run-1/root",
43
+ provenance: from,
44
+ payload: { kind: "Message", body: "look at the failing test first" }
45
+ }
46
+
47
+ /** Not urgent: this one waits until the run would otherwise have nothing to do. */
48
+ const followup: Notification = {
49
+ _tag: "human-followup",
50
+ id: "message-2",
51
+ delivery: "queue",
52
+ targetLineageId: "run-1/root",
53
+ provenance: from,
54
+ payload: { kind: "Message", body: "and update the changelog when you get a moment" }
55
+ }
56
+ ```
57
+
58
+ `id` is your idempotency key. Admitting it twice with the same content is one
59
+ notification; admitting it with different content is a producer bug the queue
60
+ refuses.
61
+
62
+ ## Admit, then drain
63
+
64
+ `admit` and `drain` are separate calls because in production they happen in
65
+ different processes at different times. Here they run in one file, which changes
66
+ nothing about what the journal records:
67
+
68
+ ```ts
69
+ import { NotificationQueue } from "@smthrs/notifications"
70
+ import * as Effect from "effect/Effect"
71
+
72
+ const ids = (receipt: NotificationQueue.DrainReceipt) => receipt.notifications.map((item) => item.id)
73
+
74
+ const program = Effect.gen(function*() {
75
+ const queue = yield* NotificationQueue.NotificationQueue
76
+
77
+ const first = yield* queue.admit("run-1", steer)
78
+ const second = yield* queue.admit("run-1", followup)
79
+
80
+ // The run closes a turn. Steers are delivered at turn close, in a batch.
81
+ const turn1 = yield* queue.drain({
82
+ runId: "run-1",
83
+ targetLineageId: "run-1/root",
84
+ boundary: "turn-1",
85
+ wouldIdle: true
86
+ })
87
+
88
+ // The run closes the next turn with nothing left to do, so it accepts one
89
+ // queued follow-up.
90
+ const turn2 = yield* queue.drain({
91
+ runId: "run-1",
92
+ targetLineageId: "run-1/root",
93
+ boundary: "turn-2",
94
+ wouldIdle: true
95
+ })
96
+
97
+ return {
98
+ admitted: [first.decision, second.decision],
99
+ turn1: ids(turn1),
100
+ turn2: ids(turn2),
101
+ stillPending: (yield* queue.pending("run-1")).map((item) => item.id)
102
+ }
103
+ })
104
+ ```
105
+
106
+ ## Provide the journal and run it
107
+
108
+ `TestJournal.layer()` is the production SQLite journal over an in-memory
109
+ database, with migrations already run:
110
+
111
+ ```ts
112
+ import * as TestJournal from "@smthrs/journal/test/TestJournal"
113
+
114
+ export const main = program.pipe(
115
+ Effect.provide(NotificationQueue.layer),
116
+ Effect.provide(TestJournal.layer()),
117
+ Effect.scoped,
118
+ Effect.orDie
119
+ )
120
+
121
+ console.log(await Effect.runPromise(main))
122
+ ```
123
+
124
+ Run the file with your TypeScript runner:
125
+
126
+ ```text
127
+ {
128
+ admitted: [ 'admitted', 'admitted' ],
129
+ turn1: [ 'message-1' ],
130
+ turn2: [ 'message-2' ],
131
+ stillPending: []
132
+ }
133
+ ```
134
+
135
+ ## What just happened
136
+
137
+ The first boundary delivered the steer and nothing else. A queued notification
138
+ is promoted one at a time, and only when the boundary has no steer to deliver
139
+ and the run would otherwise stall, so the follow-up waited for the second
140
+ boundary. Set `wouldIdle` to `false` on both drains and `message-2` stays
141
+ pending indefinitely, which is the correct answer: a run that always has work
142
+ never needs it.
143
+
144
+ Both operations are journaled, so a second process over the same database sees
145
+ the same answers. Drain `turn-1` again and the receipt comes back with
146
+ `duplicate: true` and the same notifications, read from the committed promotion
147
+ record rather than decided a second time.
148
+
149
+ ## Next steps
150
+
151
+ - [Admission and promotion](./concepts/admission-and-promotion.md): what
152
+ delivery classes, coalescing, the capacity bound, and the drain identity
153
+ actually mean.
154
+ - [Handle a full queue](./guides/handle-a-full-queue.md): the one receipt field
155
+ a caller must never ignore.
156
+ - [Steer a run](./guides/steer-a-run.md): the payload vocabulary that turns a
157
+ notification into a seat change or a thinking-level change.
158
+ - [Test against the queue](./guides/testing.md): the same in-memory stack as a
159
+ test habit, plus the pure state machine underneath it.
@@ -0,0 +1,254 @@
1
+ ---
2
+ title: "Troubleshooting"
3
+ description: "The failures @smthrs/notifications reports and the silent ones it does not: what each symptom means, what causes it, and what to change."
4
+ ---
5
+
6
+ ## A notification never reaches the run, and nothing failed
7
+
8
+ **Symptom.** `admit` returned successfully. The run drains and never sees the
9
+ notification, and no error was raised anywhere.
10
+
11
+ **Cause.** `AdmissionReceipt.decision` was `rejected-full`. The run already held
12
+ its capacity of pending notifications, so the queue retained nothing and wrote
13
+ no journal entry. `admit` does not fail on a full queue.
14
+
15
+ **Fix.** Read `receipt.decision` on every call. Retry the identical notification
16
+ once a boundary has drained: the id was never burned. See
17
+ [Handle a full queue](./guides/handle-a-full-queue.md).
18
+
19
+ ## notification_id_reused: already admitted with different content
20
+
21
+ **Symptom.** `NotificationError` with `code: "notification_id_reused"` and a
22
+ message naming the id.
23
+
24
+ **Cause.** The same `id` was admitted earlier with different content. The
25
+ comparison is on a canonical rendering, with keys sorted and absent values
26
+ dropped, so field order is not the difference; something in the payload,
27
+ provenance, delivery, or lineage changed.
28
+
29
+ **Fix.** Decide which one you mean. A retry of the same instruction sends the
30
+ identical value. A different instruction gets a different id.
31
+
32
+ The common source is a value that varies with time. An alert stamped with the
33
+ reading clock instead of journal time changes between ticks, and becomes
34
+ permanently undeliverable the moment one tick is refused. Every field of an
35
+ `Alerts.Alert` is derived from the journal for exactly this reason; keep your own
36
+ notifications the same way.
37
+
38
+ ## notification_id_reused: the journal identity holds an event this queue did not write
39
+
40
+ **Symptom.** The message says the journal identity for a notification holds an
41
+ event this queue did not write.
42
+
43
+ **Cause.** Something else committed under the source id
44
+ `/notifications/admission/<id>` for that run. The emit deduplicated against it,
45
+ so nothing of yours was written and there is no admission to report.
46
+
47
+ **Fix.** Find the other writer. A second component journaling under this
48
+ package's source-id namespace is the defect; the queue refuses to invent an
49
+ admission rather than reporting one that does not exist.
50
+
51
+ ## notification_invalid, with a path
52
+
53
+ **Symptom.** `NotificationError` with `code: "notification_invalid"` and a
54
+ `path` such as `provenance.sourceTurn` or `id`.
55
+
56
+ **Cause.** The value is not a `Notification.Notification`. `admit` decodes at the
57
+ durability boundary, before anything is journaled, because a structurally invalid
58
+ notification that reached the journal would be acknowledged at a real sequence
59
+ and then skipped by every replay.
60
+
61
+ Common failures:
62
+
63
+ | Field | What is wrong |
64
+ | ----------------------- | ------------------------------------------------------------------------------------- |
65
+ | `id` | Empty, or not a string. The id is the idempotency key and must be a non-empty string. |
66
+ | `targetLineageId` | Empty, or not a string. |
67
+ | `provenance.sourceTurn` | Not an integer, or below zero. Reported as `path: "provenance.sourceTurn"`. |
68
+ | `payload` | Not JSON. A function or a symbol anywhere inside it fails with `path: "payload"`. |
69
+
70
+ **Fix.** Correct the field `path` names. Neither the message nor `path` carries
71
+ the offending value, on purpose: a refusal is logged and journaled in places the
72
+ value must not reach.
73
+
74
+ Two fields can be absent from the error. `path` is absent when the value did not
75
+ match any member of the union closely enough to name one field, and
76
+ `notificationId` is absent when the value carries no readable string `id`, which
77
+ is the honest answer rather than an invented one.
78
+
79
+ ## notification_invalid: nests deeper than the 256 level bound
80
+
81
+ **Symptom.** The message names a depth bound.
82
+
83
+ **Cause.** The payload nests past 256 levels, or contains a cycle. A cycle has no
84
+ finite depth, so it trips the same bound rather than looping forever.
85
+
86
+ **Fix.** Flatten the payload. The bound exists because the value is walked and
87
+ serialized on the way to the journal, and an unbounded walk turns a hostile
88
+ payload into an untyped `RangeError` instead of a refusal a caller can read.
89
+
90
+ ## notification_invalid: larger than the 1048576 code unit bound
91
+
92
+ **Symptom.** The message names a code unit bound.
93
+
94
+ **Cause.** The notification is larger than 1,048,576 UTF-16 code units, counting
95
+ every string and object key at its length and every other value as one.
96
+
97
+ **Fix.** Store the large content elsewhere and put a reference in the payload.
98
+ The bound is checked before the notification is copied, decoded, fingerprinted,
99
+ or journaled, so an oversized payload costs the notifying process no more than
100
+ the bound.
101
+
102
+ ## notification_unavailable
103
+
104
+ **Symptom.** Every call fails with `code: "notification_unavailable"` and a
105
+ message like `admit is unavailable`.
106
+
107
+ **Cause.** The composition provided `NotificationQueue.layerNoop()` or
108
+ `makeNoop()`. That is the explicit absence: a seam that is present and serves
109
+ nothing.
110
+
111
+ **Fix.** Provide `NotificationQueue.layer` and a `Journal.Journal`. If the noop
112
+ is deliberate, handle the failure: a run listing that annotates pending counts
113
+ should catch `/notifications/NotificationError` and leave the field absent
114
+ rather than reporting zero. See
115
+ [Report what a run is waiting on](./guides/report-pending-notifications.md).
116
+
117
+ The same code also reports that the journal identity for a drain boundary holds
118
+ an event this queue did not write, which has the same cause and fix as the
119
+ admission case above.
120
+
121
+ ## A steer stays pending across several boundaries
122
+
123
+ **Symptom.** `pending` keeps returning the notification; `drain` keeps returning
124
+ nothing.
125
+
126
+ **Causes, in the order worth checking:**
127
+
128
+ 1. **The lineage does not match.** `drain` promotes only notifications whose
129
+ `targetLineageId` equals `DrainInput.targetLineageId`. A child lineage
130
+ draining under the parent's id leaves its own notifications untouched.
131
+ 2. **The cutoff excludes it.** `cutoffSeq` is the sequence that opened the turn.
132
+ A steer admitted after it is held until the next boundary. That is correct
133
+ behavior once; if it repeats, the caller is passing a stale cutoff.
134
+ 3. **The boundary name repeats.** The drain identity is
135
+ `(runId, targetLineageId, boundary)`, and the first record committed for it is
136
+ the delivery. Draining the same boundary name again reports that record with
137
+ `duplicate: true` and promotes nothing new. Give each boundary its own name.
138
+
139
+ ## A follow-up is never delivered
140
+
141
+ **Symptom.** A `human-followup` or a `system-event` stays pending while steers
142
+ flow through.
143
+
144
+ **Cause.** A queued notification is promoted only when `DrainInput.wouldIdle` is
145
+ true and the same boundary promoted no steer. A run that always has work, or a
146
+ caller that always passes `wouldIdle: false`, never reaches that case.
147
+
148
+ **Fix.** Pass the run's real idleness. If the notification must not wait for
149
+ idleness, admit it as a `human-steer` instead.
150
+
151
+ ## Two system events became one
152
+
153
+ **Symptom.** An earlier event's payload was replaced, and the receipt said
154
+ `coalesced`.
155
+
156
+ **Cause.** Both are `system-event` values sharing a `coalescingKey`, and the
157
+ first is still pending. That is the design: ten updates about one condition are
158
+ one pending notification carrying the latest of them.
159
+
160
+ **Fix.** Drop the `coalescingKey` for events that must not collapse. A
161
+ `human-followup` never coalesces, whatever it carries.
162
+
163
+ ## A drain returns the same notifications twice
164
+
165
+ **Symptom.** `drain` answers with notifications the run already handled, and
166
+ `duplicate` is true.
167
+
168
+ **Cause.** The boundary had already drained. The receipt is read back from the
169
+ committed promotion record, so two processes draining one boundary report the
170
+ same delivery rather than two divergent guesses.
171
+
172
+ **Fix.** Branch on `DrainReceipt.duplicate`. A resumed run walks its boundaries
173
+ until one answers `duplicate: false`, which is the first it has not consulted.
174
+
175
+ ## An alert never fires
176
+
177
+ **Symptom.** The condition is visibly true in the journal, and `tick` returns
178
+ empty arrays.
179
+
180
+ **Causes, in the order worth checking:**
181
+
182
+ 1. **No rule.** Only conditions named in `Policy.rules` are watched at all.
183
+ 2. **No detector.** A rule whose condition is in neither `defaultDetectors` nor
184
+ the policy's own `detectors` raises nothing.
185
+ 3. **`eventTypes` excludes the entry.** A detector that names event types
186
+ consults no others. Every default detector consults only `control.*`.
187
+ 4. **The payload is not a record.** An entry whose payload is a string, an array,
188
+ or `null` is skipped.
189
+ 5. **The delay has not elapsed.** `decide` compares `now` against
190
+ `since + afterMs`, both in journal time.
191
+ 6. **It already fired.** A delivered alert comes back in `Tick.suppressed`, not
192
+ `Tick.delivered`.
193
+
194
+ ## An alert pages twice about one condition
195
+
196
+ **Symptom.** The receiving system shows two pages with the same `alertId`.
197
+
198
+ **Cause.** Delivery is at-least-once. A process that dies between an accepted
199
+ send and the `flows.alerts.delivered` record pages again on the next tick. The
200
+ alternative, recording the delivery first, turns the same crash into a page
201
+ nobody receives.
202
+
203
+ **Fix.** Deduplicate in the sink on `Alerts.alertId(alert)`. The webhook sink
204
+ sends it as an `Idempotency-Key` header and in the body for exactly this. See
205
+ [Send alerts to a webhook](./guides/send-alerts-to-a-webhook.md).
206
+
207
+ ## An alert re-fires after the condition cleared and came back
208
+
209
+ **Symptom.** A second page about the same run and condition.
210
+
211
+ **Cause.** This is correct. `alertId` includes the opening time, so a condition
212
+ that clears and re-opens is a new alert. The second approval wait is not the
213
+ first one, and an id without the time would suppress it forever.
214
+
215
+ ## The alert runtime dies when the layer builds
216
+
217
+ **Symptom.** Building `Alerts.layer(policy)` fails the composition rather than
218
+ returning a runtime.
219
+
220
+ **Cause.** The policy did not decode. `afterMs` must be a whole number at or
221
+ above zero: a `NaN` delay would fire on the first tick and stamp a `firedAt`
222
+ that JSON writes as `null`, and a negative one would fire with a `firedAt`
223
+ earlier than the condition it describes.
224
+
225
+ **Fix.** Correct the rule. Failing at composition time is deliberate: the
226
+ alternative is mis-paging at 3am.
227
+
228
+ ## The projection reports a capacity the run was not built at
229
+
230
+ **Symptom.** A composition built with `NotificationQueue.layerWith({ capacity:
231
+ 512 })` projects a state whose `capacity` is 128.
232
+
233
+ **Cause.** `Projection.derive` starts at
234
+ `NotificationState.empty(NotificationState.defaultCapacity)`, and no journal
235
+ record carries the bound the layer was built at, so replay has nothing to
236
+ restore it from.
237
+
238
+ **Fix.** Read `items` rather than `capacity`. Replay never re-decides: an
239
+ admitted record is retained whatever the projected bound says, so the projection
240
+ reports every pending notification a raised-capacity run holds. See
241
+ [the journal records](./concepts/journal-records.md).
242
+
243
+ ## Mutating a notification after admitting it changes nothing
244
+
245
+ **Symptom.** A field edited on the object after the call does not appear in the
246
+ journal or in a later drain.
247
+
248
+ **Cause.** `admit` decodes and structurally copies its argument at the durability
249
+ boundary, and journals the snapshot. `SteerPayload.encode` does the same for the
250
+ record it returns.
251
+
252
+ **Fix.** Nothing to fix. Build the value fully before admitting it; a queue whose
253
+ durable record could change after the call returned would be a queue that cannot
254
+ promise what it wrote.
package/package.json CHANGED
@@ -1,6 +1,185 @@
1
1
  {
2
2
  "name": "@smthrs/notifications",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "type": "module",
4
+ "version": "1.0.0-rc.12",
5
+ "license": "MIT",
6
+ "smthrs": {
7
+ "group": "agent"
8
+ },
9
+ "description": "Durable notification queue, admission policy, and journal projection for flows",
10
+ "homepage": "https://github.com/smithersai/smithers",
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "https://github.com/smithersai/smithers.git",
14
+ "directory": "packages/smithers/notifications"
15
+ },
16
+ "bugs": {
17
+ "url": "https://github.com/smithersai/smithers/issues"
18
+ },
19
+ "tags": [
20
+ "typescript",
21
+ "effect",
22
+ "journal",
23
+ "durability"
24
+ ],
25
+ "keywords": [
26
+ "typescript",
27
+ "effect",
28
+ "journal",
29
+ "durability"
30
+ ],
31
+ "engines": {
32
+ "node": ">=26.4.0"
33
+ },
34
+ "sideEffects": [],
35
+ "exports": {
36
+ "./package.json": "./package.json",
37
+ ".": {
38
+ "import": {
39
+ "types": "./dist/esm/index.d.ts",
40
+ "default": "./dist/esm/index.js"
41
+ },
42
+ "require": {
43
+ "types": "./dist/cjs/index.d.ts",
44
+ "default": "./dist/cjs/index.js"
45
+ }
46
+ },
47
+ "./internal/*": null,
48
+ "./*/index": null,
49
+ "./Alerts": {
50
+ "import": {
51
+ "types": "./dist/esm/Alerts.d.ts",
52
+ "default": "./dist/esm/Alerts.js"
53
+ },
54
+ "require": {
55
+ "types": "./dist/cjs/Alerts.d.ts",
56
+ "default": "./dist/cjs/Alerts.js"
57
+ }
58
+ },
59
+ "./Notification": {
60
+ "import": {
61
+ "types": "./dist/esm/Notification.d.ts",
62
+ "default": "./dist/esm/Notification.js"
63
+ },
64
+ "require": {
65
+ "types": "./dist/cjs/Notification.d.ts",
66
+ "default": "./dist/cjs/Notification.js"
67
+ }
68
+ },
69
+ "./NotificationEvent": {
70
+ "import": {
71
+ "types": "./dist/esm/NotificationEvent.d.ts",
72
+ "default": "./dist/esm/NotificationEvent.js"
73
+ },
74
+ "require": {
75
+ "types": "./dist/cjs/NotificationEvent.d.ts",
76
+ "default": "./dist/cjs/NotificationEvent.js"
77
+ }
78
+ },
79
+ "./NotificationQueue": {
80
+ "import": {
81
+ "types": "./dist/esm/NotificationQueue.d.ts",
82
+ "default": "./dist/esm/NotificationQueue.js"
83
+ },
84
+ "require": {
85
+ "types": "./dist/cjs/NotificationQueue.d.ts",
86
+ "default": "./dist/cjs/NotificationQueue.js"
87
+ }
88
+ },
89
+ "./NotificationState": {
90
+ "import": {
91
+ "types": "./dist/esm/NotificationState.d.ts",
92
+ "default": "./dist/esm/NotificationState.js"
93
+ },
94
+ "require": {
95
+ "types": "./dist/cjs/NotificationState.d.ts",
96
+ "default": "./dist/cjs/NotificationState.js"
97
+ }
98
+ },
99
+ "./Projection": {
100
+ "import": {
101
+ "types": "./dist/esm/Projection.d.ts",
102
+ "default": "./dist/esm/Projection.js"
103
+ },
104
+ "require": {
105
+ "types": "./dist/cjs/Projection.d.ts",
106
+ "default": "./dist/cjs/Projection.js"
107
+ }
108
+ },
109
+ "./SteerPayload": {
110
+ "import": {
111
+ "types": "./dist/esm/SteerPayload.d.ts",
112
+ "default": "./dist/esm/SteerPayload.js"
113
+ },
114
+ "require": {
115
+ "types": "./dist/cjs/SteerPayload.d.ts",
116
+ "default": "./dist/cjs/SteerPayload.js"
117
+ }
118
+ },
119
+ "./index": {
120
+ "import": {
121
+ "types": "./dist/esm/index.d.ts",
122
+ "default": "./dist/esm/index.js"
123
+ },
124
+ "require": {
125
+ "types": "./dist/cjs/index.d.ts",
126
+ "default": "./dist/cjs/index.js"
127
+ }
128
+ }
129
+ },
130
+ "files": [
131
+ "src/**/*.ts",
132
+ "dist/**/*.js",
133
+ "dist/**/*.js.map",
134
+ "dist/**/*.d.ts",
135
+ "dist/**/*.d.ts.map",
136
+ "dist/**/package.json",
137
+ "LICENSE",
138
+ "README.md",
139
+ "CHANGELOG.md",
140
+ "docs/"
141
+ ],
142
+ "publishConfig": {
143
+ "access": "public",
144
+ "provenance": true,
145
+ "tag": "next"
146
+ },
147
+ "dependencies": {
148
+ "@smthrs/canonical": "1.0.0-rc.12",
149
+ "@smthrs/crypto": "1.0.0-rc.12",
150
+ "@smthrs/journal": "1.0.0-rc.12"
151
+ },
152
+ "devDependencies": {
153
+ "@effect/language-service": "0.87.2",
154
+ "@effect/vitest": "4.0.4",
155
+ "@eslint/js": "9.39.5",
156
+ "@smthrs/database": "1.0.0-rc.12",
157
+ "@types/node": "26.4.1",
158
+ "@vitest/coverage-v8": "5.0.0",
159
+ "dprint": "0.57.1",
160
+ "effect": "4.0.4",
161
+ "esbuild": "0.28.2",
162
+ "eslint": "9.39.5",
163
+ "eslint-import-resolver-typescript": "4.4.5",
164
+ "eslint-plugin-import": "2.32.0",
165
+ "eslint-plugin-jsdoc": "64.3.4",
166
+ "eslint-plugin-unicorn": "65.0.0",
167
+ "madge": "8.0.0",
168
+ "typescript": "5.9.3",
169
+ "typescript-eslint": "8.69.0",
170
+ "vitest": "5.0.0",
171
+ "fast-check": "4.9.0"
172
+ },
173
+ "peerDependencies": {
174
+ "effect": "^4.0.4"
175
+ },
176
+ "scripts": {
177
+ "lint": "eslint src --max-warnings=0 && dprint check",
178
+ "format": "dprint fmt",
179
+ "build": "node scripts/build.mjs",
180
+ "check": "tsc -b tsconfig.json && tsc -p tsconfig.test.json --noEmit",
181
+ "circular": "node scripts/circular.mjs",
182
+ "test": "vitest",
183
+ "coverage": "vitest --coverage"
184
+ }
6
185
  }