@smthrs/notifications 0.0.0-stage → 1.0.0-rc.13
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.
- package/CHANGELOG.md +127 -0
- package/LICENSE +21 -0
- package/README.md +93 -2
- package/dist/cjs/AlertPolicy.d.ts +284 -0
- package/dist/cjs/AlertPolicy.d.ts.map +1 -0
- package/dist/cjs/AlertPolicy.js +124 -0
- package/dist/cjs/AlertPolicy.js.map +7 -0
- package/dist/cjs/AlertRuntime.d.ts +90 -0
- package/dist/cjs/AlertRuntime.d.ts.map +1 -0
- package/dist/cjs/AlertRuntime.js +166 -0
- package/dist/cjs/AlertRuntime.js.map +7 -0
- package/dist/cjs/AlertSink.d.ts +101 -0
- package/dist/cjs/AlertSink.d.ts.map +1 -0
- package/dist/cjs/AlertSink.js +44 -0
- package/dist/cjs/AlertSink.js.map +7 -0
- package/dist/cjs/Alerts.d.ts +57 -0
- package/dist/cjs/Alerts.d.ts.map +1 -0
- package/dist/cjs/Alerts.js +46 -0
- package/dist/cjs/Alerts.js.map +7 -0
- package/dist/cjs/Notification.d.ts +214 -0
- package/dist/cjs/Notification.d.ts.map +1 -0
- package/dist/cjs/Notification.js +62 -0
- package/dist/cjs/Notification.js.map +7 -0
- package/dist/cjs/NotificationEvent.d.ts +214 -0
- package/dist/cjs/NotificationEvent.d.ts.map +1 -0
- package/dist/cjs/NotificationEvent.js +84 -0
- package/dist/cjs/NotificationEvent.js.map +7 -0
- package/dist/cjs/NotificationQueue.d.ts +194 -0
- package/dist/cjs/NotificationQueue.d.ts.map +1 -0
- package/dist/cjs/NotificationQueue.js +477 -0
- package/dist/cjs/NotificationQueue.js.map +7 -0
- package/dist/cjs/NotificationState.d.ts +169 -0
- package/dist/cjs/NotificationState.d.ts.map +1 -0
- package/dist/cjs/NotificationState.js +110 -0
- package/dist/cjs/NotificationState.js.map +7 -0
- package/dist/cjs/Projection.d.ts +30 -0
- package/dist/cjs/Projection.d.ts.map +1 -0
- package/dist/cjs/Projection.js +53 -0
- package/dist/cjs/Projection.js.map +7 -0
- package/dist/cjs/SteerPayload.d.ts +177 -0
- package/dist/cjs/SteerPayload.d.ts.map +1 -0
- package/dist/cjs/SteerPayload.js +61 -0
- package/dist/cjs/SteerPayload.js.map +7 -0
- package/dist/cjs/index.d.ts +41 -0
- package/dist/cjs/index.d.ts.map +1 -0
- package/dist/cjs/index.js +47 -0
- package/dist/cjs/index.js.map +7 -0
- package/dist/cjs/internal/foldCache.d.ts +36 -0
- package/dist/cjs/internal/foldCache.d.ts.map +1 -0
- package/dist/cjs/internal/foldCache.js +54 -0
- package/dist/cjs/internal/foldCache.js.map +7 -0
- package/dist/cjs/internal/replay.d.ts +22 -0
- package/dist/cjs/internal/replay.d.ts.map +1 -0
- package/dist/cjs/internal/replay.js +46 -0
- package/dist/cjs/internal/replay.js.map +7 -0
- package/dist/cjs/layerWebhook.d.ts +60 -0
- package/dist/cjs/layerWebhook.d.ts.map +1 -0
- package/dist/cjs/layerWebhook.js +145 -0
- package/dist/cjs/layerWebhook.js.map +7 -0
- package/dist/cjs/package.json +1 -0
- package/dist/esm/AlertPolicy.d.ts +284 -0
- package/dist/esm/AlertPolicy.d.ts.map +1 -0
- package/dist/esm/AlertPolicy.js +284 -0
- package/dist/esm/AlertPolicy.js.map +1 -0
- package/dist/esm/AlertRuntime.d.ts +90 -0
- package/dist/esm/AlertRuntime.d.ts.map +1 -0
- package/dist/esm/AlertRuntime.js +191 -0
- package/dist/esm/AlertRuntime.js.map +1 -0
- package/dist/esm/AlertSink.d.ts +101 -0
- package/dist/esm/AlertSink.d.ts.map +1 -0
- package/dist/esm/AlertSink.js +63 -0
- package/dist/esm/AlertSink.js.map +1 -0
- package/dist/esm/Alerts.d.ts +57 -0
- package/dist/esm/Alerts.d.ts.map +1 -0
- package/dist/esm/Alerts.js +57 -0
- package/dist/esm/Alerts.js.map +1 -0
- package/dist/esm/Notification.d.ts +214 -0
- package/dist/esm/Notification.d.ts.map +1 -0
- package/dist/esm/Notification.js +121 -0
- package/dist/esm/Notification.js.map +1 -0
- package/dist/esm/NotificationEvent.d.ts +214 -0
- package/dist/esm/NotificationEvent.d.ts.map +1 -0
- package/dist/esm/NotificationEvent.js +158 -0
- package/dist/esm/NotificationEvent.js.map +1 -0
- package/dist/esm/NotificationQueue.d.ts +194 -0
- package/dist/esm/NotificationQueue.d.ts.map +1 -0
- package/dist/esm/NotificationQueue.js +625 -0
- package/dist/esm/NotificationQueue.js.map +1 -0
- package/dist/esm/NotificationState.d.ts +169 -0
- package/dist/esm/NotificationState.d.ts.map +1 -0
- package/dist/esm/NotificationState.js +191 -0
- package/dist/esm/NotificationState.js.map +1 -0
- package/dist/esm/Projection.d.ts +30 -0
- package/dist/esm/Projection.d.ts.map +1 -0
- package/dist/esm/Projection.js +47 -0
- package/dist/esm/Projection.js.map +1 -0
- package/dist/esm/SteerPayload.d.ts +177 -0
- package/dist/esm/SteerPayload.d.ts.map +1 -0
- package/dist/esm/SteerPayload.js +139 -0
- package/dist/esm/SteerPayload.js.map +1 -0
- package/dist/esm/index.d.ts +41 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +41 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/internal/foldCache.d.ts +36 -0
- package/dist/esm/internal/foldCache.d.ts.map +1 -0
- package/dist/esm/internal/foldCache.js +58 -0
- package/dist/esm/internal/foldCache.js.map +1 -0
- package/dist/esm/internal/replay.d.ts +22 -0
- package/dist/esm/internal/replay.d.ts.map +1 -0
- package/dist/esm/internal/replay.js +31 -0
- package/dist/esm/internal/replay.js.map +1 -0
- package/dist/esm/layerWebhook.d.ts +60 -0
- package/dist/esm/layerWebhook.d.ts.map +1 -0
- package/dist/esm/layerWebhook.js +186 -0
- package/dist/esm/layerWebhook.js.map +1 -0
- package/docs/README.md +146 -0
- package/docs/api.md +443 -0
- package/docs/concepts/admission-and-promotion.md +144 -0
- package/docs/concepts/alerting.md +138 -0
- package/docs/concepts/journal-records.md +132 -0
- package/docs/guides/alert-on-a-stuck-run.md +140 -0
- package/docs/guides/drain-at-a-turn-boundary.md +95 -0
- package/docs/guides/handle-a-full-queue.md +102 -0
- package/docs/guides/report-pending-notifications.md +92 -0
- package/docs/guides/send-alerts-to-a-webhook.md +155 -0
- package/docs/guides/steer-a-run.md +127 -0
- package/docs/guides/testing.md +161 -0
- package/docs/installation.md +123 -0
- package/docs/quickstart.md +159 -0
- package/docs/troubleshooting.md +254 -0
- package/package.json +182 -3
- package/src/AlertPolicy.ts +377 -0
- package/src/AlertRuntime.ts +280 -0
- package/src/AlertSink.ts +105 -0
- package/src/Alerts.ts +71 -0
- package/src/Notification.ts +171 -0
- package/src/NotificationEvent.ts +213 -0
- package/src/NotificationQueue.ts +856 -0
- package/src/NotificationState.ts +295 -0
- package/src/Projection.ts +48 -0
- package/src/SteerPayload.ts +198 -0
- package/src/index.ts +47 -0
- package/src/internal/foldCache.ts +72 -0
- package/src/internal/replay.ts +39 -0
- 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
|
-
"
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "1.0.0-rc.13",
|
|
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.13",
|
|
149
|
+
"@smthrs/crypto": "1.0.0-rc.13",
|
|
150
|
+
"@smthrs/journal": "1.0.0-rc.13"
|
|
151
|
+
},
|
|
152
|
+
"devDependencies": {
|
|
153
|
+
"@effect/language-service": "0.87.2",
|
|
154
|
+
"@effect/vitest": "4.0.5",
|
|
155
|
+
"@eslint/js": "9.39.5",
|
|
156
|
+
"@smthrs/database": "1.0.0-rc.13",
|
|
157
|
+
"@types/node": "26.4.1",
|
|
158
|
+
"@vitest/coverage-v8": "5.0.0",
|
|
159
|
+
"dprint": "0.57.1",
|
|
160
|
+
"effect": "4.0.5",
|
|
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.5"
|
|
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
|
}
|