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

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 (137) hide show
  1. package/CHANGELOG.md +127 -0
  2. package/LICENSE +21 -0
  3. package/README.md +91 -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 +193 -0
  29. package/dist/cjs/NotificationQueue.d.ts.map +1 -0
  30. package/dist/cjs/NotificationQueue.js +478 -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 +27 -0
  37. package/dist/cjs/Projection.d.ts.map +1 -0
  38. package/dist/cjs/Projection.js +54 -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/layerWebhook.d.ts +53 -0
  53. package/dist/cjs/layerWebhook.d.ts.map +1 -0
  54. package/dist/cjs/layerWebhook.js +107 -0
  55. package/dist/cjs/layerWebhook.js.map +7 -0
  56. package/dist/cjs/package.json +1 -0
  57. package/dist/esm/AlertPolicy.d.ts +284 -0
  58. package/dist/esm/AlertPolicy.d.ts.map +1 -0
  59. package/dist/esm/AlertPolicy.js +284 -0
  60. package/dist/esm/AlertPolicy.js.map +1 -0
  61. package/dist/esm/AlertRuntime.d.ts +90 -0
  62. package/dist/esm/AlertRuntime.d.ts.map +1 -0
  63. package/dist/esm/AlertRuntime.js +191 -0
  64. package/dist/esm/AlertRuntime.js.map +1 -0
  65. package/dist/esm/AlertSink.d.ts +101 -0
  66. package/dist/esm/AlertSink.d.ts.map +1 -0
  67. package/dist/esm/AlertSink.js +63 -0
  68. package/dist/esm/AlertSink.js.map +1 -0
  69. package/dist/esm/Alerts.d.ts +57 -0
  70. package/dist/esm/Alerts.d.ts.map +1 -0
  71. package/dist/esm/Alerts.js +57 -0
  72. package/dist/esm/Alerts.js.map +1 -0
  73. package/dist/esm/Notification.d.ts +214 -0
  74. package/dist/esm/Notification.d.ts.map +1 -0
  75. package/dist/esm/Notification.js +121 -0
  76. package/dist/esm/Notification.js.map +1 -0
  77. package/dist/esm/NotificationEvent.d.ts +214 -0
  78. package/dist/esm/NotificationEvent.d.ts.map +1 -0
  79. package/dist/esm/NotificationEvent.js +158 -0
  80. package/dist/esm/NotificationEvent.js.map +1 -0
  81. package/dist/esm/NotificationQueue.d.ts +193 -0
  82. package/dist/esm/NotificationQueue.d.ts.map +1 -0
  83. package/dist/esm/NotificationQueue.js +626 -0
  84. package/dist/esm/NotificationQueue.js.map +1 -0
  85. package/dist/esm/NotificationState.d.ts +169 -0
  86. package/dist/esm/NotificationState.d.ts.map +1 -0
  87. package/dist/esm/NotificationState.js +191 -0
  88. package/dist/esm/NotificationState.js.map +1 -0
  89. package/dist/esm/Projection.d.ts +27 -0
  90. package/dist/esm/Projection.d.ts.map +1 -0
  91. package/dist/esm/Projection.js +43 -0
  92. package/dist/esm/Projection.js.map +1 -0
  93. package/dist/esm/SteerPayload.d.ts +177 -0
  94. package/dist/esm/SteerPayload.d.ts.map +1 -0
  95. package/dist/esm/SteerPayload.js +139 -0
  96. package/dist/esm/SteerPayload.js.map +1 -0
  97. package/dist/esm/index.d.ts +41 -0
  98. package/dist/esm/index.d.ts.map +1 -0
  99. package/dist/esm/index.js +41 -0
  100. package/dist/esm/index.js.map +1 -0
  101. package/dist/esm/internal/foldCache.d.ts +36 -0
  102. package/dist/esm/internal/foldCache.d.ts.map +1 -0
  103. package/dist/esm/internal/foldCache.js +58 -0
  104. package/dist/esm/internal/foldCache.js.map +1 -0
  105. package/dist/esm/layerWebhook.d.ts +53 -0
  106. package/dist/esm/layerWebhook.d.ts.map +1 -0
  107. package/dist/esm/layerWebhook.js +118 -0
  108. package/dist/esm/layerWebhook.js.map +1 -0
  109. package/docs/README.md +144 -0
  110. package/docs/api.md +439 -0
  111. package/docs/concepts/admission-and-promotion.md +144 -0
  112. package/docs/concepts/alerting.md +138 -0
  113. package/docs/concepts/journal-records.md +132 -0
  114. package/docs/guides/alert-on-a-stuck-run.md +140 -0
  115. package/docs/guides/drain-at-a-turn-boundary.md +95 -0
  116. package/docs/guides/handle-a-full-queue.md +102 -0
  117. package/docs/guides/report-pending-notifications.md +92 -0
  118. package/docs/guides/send-alerts-to-a-webhook.md +130 -0
  119. package/docs/guides/steer-a-run.md +127 -0
  120. package/docs/guides/testing.md +161 -0
  121. package/docs/installation.md +119 -0
  122. package/docs/quickstart.md +159 -0
  123. package/docs/troubleshooting.md +254 -0
  124. package/package.json +182 -3
  125. package/src/AlertPolicy.ts +377 -0
  126. package/src/AlertRuntime.ts +280 -0
  127. package/src/AlertSink.ts +105 -0
  128. package/src/Alerts.ts +71 -0
  129. package/src/Notification.ts +171 -0
  130. package/src/NotificationEvent.ts +213 -0
  131. package/src/NotificationQueue.ts +857 -0
  132. package/src/NotificationState.ts +295 -0
  133. package/src/Projection.ts +46 -0
  134. package/src/SteerPayload.ts +198 -0
  135. package/src/index.ts +47 -0
  136. package/src/internal/foldCache.ts +72 -0
  137. package/src/layerWebhook.ts +161 -0
@@ -0,0 +1,105 @@
1
+ /**
2
+ * The alert sink port: where a raised alert is sent, and why a send failed.
3
+ *
4
+ * The port is a tag and an error so a deployment supplies the transport and
5
+ * the policy needs none. `./layerWebhook.ts` is the transport this package
6
+ * ships, and {@link layerNoop} is the one a test composes.
7
+ *
8
+ * @since 1.0.0
9
+ */
10
+
11
+ import { Context, Effect, Layer, Schema } from "effect"
12
+ import type { Alert } from "./AlertPolicy.ts"
13
+
14
+ /**
15
+ * Why a sink did not take an alert.
16
+ *
17
+ * `sink_rejected` is an answer that refused the page, `sink_unreachable` a
18
+ * request that never got one, and `sink_timeout` one that got no answer inside
19
+ * the sink's bound. `sink_misconfigured` is an endpoint the sink may not POST
20
+ * to at all; it is raised when the layer is built, before any alert exists,
21
+ * so it never reaches a journal.
22
+ *
23
+ * @category models
24
+ * @since 1.0.0
25
+ */
26
+ export const FailureCode = Schema.Literals(["sink_rejected", "sink_unreachable", "sink_timeout", "sink_misconfigured"])
27
+
28
+ /**
29
+ * Why a sink did not take an alert.
30
+ *
31
+ * @category models
32
+ * @since 1.0.0
33
+ */
34
+ export type FailureCode = typeof FailureCode.Type
35
+
36
+ /**
37
+ * A sink refused or could not take an alert.
38
+ *
39
+ * The error carries a code, the answering status when there was one, and a
40
+ * short reason. It deliberately holds no request: a webhook request carries
41
+ * the credential the deployment handed {@link layerWebhook}, and an error is
42
+ * logged, encoded, and journaled in places a credential must never reach.
43
+ *
44
+ * @category errors
45
+ * @since 0.1.0
46
+ */
47
+ export class AlertError extends Schema.TaggedError<AlertError>()(
48
+ "/notifications/AlertError",
49
+ {
50
+ code: FailureCode.pipe(Schema.withConstructorDefault(Effect.succeed("sink_rejected" as const))),
51
+ message: Schema.String,
52
+ /** The HTTP status that refused the page, when the sink speaks HTTP. */
53
+ status: Schema.optional(Schema.Int),
54
+ /** A short, credential-free description of the transport failure. */
55
+ reason: Schema.optional(Schema.String)
56
+ }
57
+ ) {}
58
+
59
+ /**
60
+ * Where a raised alert is sent.
61
+ *
62
+ * Injected rather than fixed, because who gets paged is a deployment's
63
+ * decision and the policy is not: the same rules feed a webhook in production
64
+ * and nothing at all in a test.
65
+ *
66
+ * @category services
67
+ * @since 0.1.0
68
+ */
69
+ export interface SinkService {
70
+ /**
71
+ * Sends one alert, and does so idempotently on {@link alertId}.
72
+ *
73
+ * Delivery is at-least-once: the `flows.alerts.delivered` record is written
74
+ * after this effect succeeds, so a process that dies in between pages again
75
+ * on the next tick. Every field of the alert, {@link Alert.firedAt}
76
+ * included, is derived from the journal, so the same alert is byte-identical
77
+ * on every attempt and `alertId(alert)` is the deduplication key a receiving
78
+ * system should key on. A sink that cannot dedupe is a sink that will
79
+ * occasionally page twice about one condition.
80
+ *
81
+ * A failure means the page did not go out. Succeeding on a page that was
82
+ * dropped is the one thing this port must never do.
83
+ */
84
+ readonly deliver: (alert: Alert) => Effect.Effect<void, AlertError>
85
+ }
86
+
87
+ /**
88
+ * The {@link SinkService} tag.
89
+ *
90
+ * @category services
91
+ * @since 0.1.0
92
+ */
93
+ export class Sink extends Context.Service<Sink, SinkService>()("/notifications/AlertSink") {}
94
+
95
+ /**
96
+ * A sink that accepts every alert and sends it nowhere.
97
+ *
98
+ * The admission and the delivery record still happen, so a composition without
99
+ * an outbound channel still has the durable evidence of what it would have
100
+ * paged about.
101
+ *
102
+ * @category layers
103
+ * @since 0.1.0
104
+ */
105
+ export const layerNoop: Layer.Layer<Sink> = Layer.succeed(Sink)({ deliver: () => Effect.void })
package/src/Alerts.ts ADDED
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Alert policy: run conditions that have lasted too long, turned into durable,
3
+ * coalesced, delivered-once notifications.
4
+ *
5
+ * A notification queue answers "tell this run something". An alert answers the
6
+ * question nobody is around to ask: a run has been waiting for an approval for
7
+ * an hour, and the person who could grant it does not know. The two are the
8
+ * same machinery, since an alert is admitted as a coalesced system event, and
9
+ * they differ only in who decides to write one.
10
+ *
11
+ * ## Journal time, not wall time
12
+ *
13
+ * A condition's clock starts at the journal entry that opened it, which makes
14
+ * the decision replayable: the same journal produces the same alerts, whatever
15
+ * process reads it and whenever. A restart re-derives every open condition
16
+ * from the entries rather than from a timer it lost.
17
+ *
18
+ * ## Delivered at least once
19
+ *
20
+ * The admission is idempotent on the notification id, so re-admitting the same
21
+ * alert writes nothing new, and `flows.alerts.delivered` is journaled AFTER
22
+ * the sink accepted the page and is checked before the sink is called again.
23
+ * That ordering is deliberate and it is the reason the guarantee is
24
+ * at-least-once rather than exactly-once: a process that dies between the
25
+ * accepted send and the delivery record has paged, left no evidence of it, and
26
+ * will page again on the next tick. Recording the delivery first turns the
27
+ * same crash into a page nobody ever receives, which for an alert is the worse
28
+ * failure.
29
+ *
30
+ * So the sink owns the last mile: {@link SinkService.deliver} MUST be
31
+ * idempotent on {@link alertId}, which is stable for the life of one condition
32
+ * and reaches the sink on every alert it is handed. A sink that fails journals
33
+ * `flows.alerts.failed` once per failure code and the alert is retried on the
34
+ * next tick, because a refused page is not a delivered page.
35
+ *
36
+ * ## What a refusal costs
37
+ *
38
+ * The queue can refuse the admission when a run already holds its capacity of
39
+ * pending notifications. A tick that is refused pages nobody: the alert comes
40
+ * back in {@link Tick.refused}, the refusal is journaled, and the next tick
41
+ * tries again once a boundary has drained. Calling the sink anyway would page
42
+ * about an alert the run will never see.
43
+ *
44
+ * ## Conditions are data
45
+ *
46
+ * A condition is a payload field with a value: `status` is `waiting-approval`,
47
+ * `health` is `stalled`. Those are the fields control-plane entries and monitor
48
+ * beats already carry, so a deployment that journals a different vocabulary
49
+ * supplies its own detectors instead of a fork of this module.
50
+ *
51
+ * @since 0.1.0
52
+ */
53
+
54
+ export {
55
+ type Alert,
56
+ alertId,
57
+ coalescingKey,
58
+ conditions,
59
+ decide,
60
+ defaultDetectors,
61
+ deliveredEventType,
62
+ Detector,
63
+ failedEventType,
64
+ type Open,
65
+ Policy,
66
+ Rule,
67
+ Severity
68
+ } from "./AlertPolicy.ts"
69
+ export { AlertRuntime, layer, type RuntimeService, type Tick } from "./AlertRuntime.ts"
70
+ export { AlertError, FailureCode, layerNoop, Sink, type SinkService } from "./AlertSink.ts"
71
+ export { defaultWebhookTimeout, layerWebhook } from "./layerWebhook.ts"
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Durable notification payloads and their admission classification.
3
+ *
4
+ * @since 0.1.0
5
+ */
6
+
7
+ import { Schema } from "effect"
8
+
9
+ /**
10
+ * Producer-supplied attribution carried as data alongside the authenticated
11
+ * source actor. These labels never authorize a notification or a model action.
12
+ * They pass through the journal's ordinary redaction policy.
13
+ *
14
+ * @category models
15
+ * @since 1.0.0
16
+ */
17
+ export const Attribution = Schema.Record(Schema.String, Schema.String)
18
+
19
+ /**
20
+ * Durable origin of a notification: who said it, from where, and at which
21
+ * turn.
22
+ *
23
+ * Provenance travels with the notification because the run that receives one
24
+ * is not the run that wrote it. An operator steer, a parent run's event, and a
25
+ * webhook all arrive on the same queue, and only these fields say which is
26
+ * which after the fact.
27
+ *
28
+ * @category models
29
+ * @since 0.1.0
30
+ */
31
+ export const Provenance = Schema.Struct({
32
+ sourceRunId: Schema.String,
33
+ sourceLineageId: Schema.String,
34
+ sourceTurn: Schema.Int.pipe(Schema.check(Schema.isGreaterThanOrEqualTo(0))),
35
+ sourceActor: Schema.String,
36
+ attribution: Schema.optional(Attribution)
37
+ })
38
+
39
+ /**
40
+ * The decoded form of {@link Provenance}.
41
+ *
42
+ * @category models
43
+ * @since 0.1.0
44
+ */
45
+ export type Provenance = typeof Provenance.Type
46
+
47
+ const common = {
48
+ id: Schema.NonEmptyString,
49
+ targetLineageId: Schema.NonEmptyString,
50
+ provenance: Provenance,
51
+ payload: Schema.Json
52
+ }
53
+
54
+ /**
55
+ * A human message that must reach the model before the current turn closes.
56
+ *
57
+ * `id` is the caller's idempotency key: admitting it twice with the same
58
+ * content is one notification, and admitting it with different content is a
59
+ * producer bug the queue refuses. New admissions fingerprint the validated
60
+ * content before journal redaction, so redaction does not change retry identity.
61
+ *
62
+ * @category models
63
+ * @since 0.1.0
64
+ */
65
+ export const HumanSteer = Schema.TaggedStruct("human-steer", {
66
+ ...common,
67
+ delivery: Schema.Literal("steer")
68
+ })
69
+
70
+ /**
71
+ * The decoded form of {@link HumanSteer}.
72
+ *
73
+ * @category models
74
+ * @since 0.1.0
75
+ */
76
+ export type HumanSteer = typeof HumanSteer.Type
77
+
78
+ /**
79
+ * A human message that waits for the run to become idle.
80
+ *
81
+ * The delivery class is the whole difference from {@link HumanSteer}: a
82
+ * follow-up is not urgent enough to interrupt the turn in flight, so it is
83
+ * promoted one at a time when the run would otherwise have nothing to do.
84
+ *
85
+ * @category models
86
+ * @since 0.1.0
87
+ */
88
+ export const HumanFollowup = Schema.TaggedStruct("human-followup", {
89
+ ...common,
90
+ delivery: Schema.Literal("queue")
91
+ })
92
+
93
+ /**
94
+ * The decoded form of {@link HumanFollowup}.
95
+ *
96
+ * @category models
97
+ * @since 0.1.0
98
+ */
99
+ export type HumanFollowup = typeof HumanFollowup.Type
100
+
101
+ /**
102
+ * A machine-originated event. Events sharing a coalescing key and a target
103
+ * lineage collapse to the most recent one while pending.
104
+ *
105
+ * Coalescing is what keeps a chatty producer from filling the queue: ten
106
+ * updates about one condition are one pending notification carrying the latest
107
+ * of them. The key is scoped to `targetLineageId`, so two branches reporting
108
+ * one condition under the same ordinary key each keep their own event. An
109
+ * event with no key never coalesces.
110
+ *
111
+ * @category models
112
+ * @since 0.1.0
113
+ */
114
+ export const SystemEvent = Schema.TaggedStruct("system-event", {
115
+ ...common,
116
+ delivery: Schema.Literal("queue"),
117
+ coalescingKey: Schema.optional(Schema.String)
118
+ })
119
+
120
+ /**
121
+ * The decoded form of {@link SystemEvent}.
122
+ *
123
+ * @category models
124
+ * @since 0.1.0
125
+ */
126
+ export type SystemEvent = typeof SystemEvent.Type
127
+
128
+ /**
129
+ * Any durable notification retained by the pending queue.
130
+ *
131
+ * `NotificationQueue.admit` decodes its argument against this union before it
132
+ * journals anything, so a value that does not match it is refused rather than
133
+ * acknowledged and then skipped by every replay. Journal-backed queues deeply
134
+ * freeze decoded notifications, including payloads, before returning them from
135
+ * `pending` or `drain`.
136
+ *
137
+ * @category models
138
+ * @since 0.1.0
139
+ */
140
+ export const Notification = Schema.Union([HumanSteer, HumanFollowup, SystemEvent])
141
+
142
+ /**
143
+ * Any durable notification retained by the pending queue.
144
+ *
145
+ * @category models
146
+ * @since 0.1.0
147
+ */
148
+ export type Notification = typeof Notification.Type
149
+
150
+ /**
151
+ * How a notification is allowed to reach the model.
152
+ *
153
+ * `steer` notifications are promoted in a batch at turn close; `queue`
154
+ * notifications are promoted one at a time when the run would otherwise idle.
155
+ *
156
+ * @param notification the notification to classify
157
+ * @category getters
158
+ * @since 0.1.0
159
+ */
160
+ export const admissionClass = (notification: Notification): "steer" | "queue" => notification.delivery
161
+
162
+ /**
163
+ * The key a pending notification coalesces on, or `null` when it must never
164
+ * be coalesced. Only system events coalesce, and only when they declare a key.
165
+ *
166
+ * @param notification the notification to key
167
+ * @category getters
168
+ * @since 0.1.0
169
+ */
170
+ export const coalesceKey = (notification: Notification): string | null =>
171
+ notification._tag === "system-event" ? notification.coalescingKey ?? null : null
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Journal event types owned by the notification queue.
3
+ *
4
+ * @since 0.1.0
5
+ */
6
+
7
+ import { JournalEvent } from "@smthrs/journal"
8
+ import * as Option from "effect/Option"
9
+ import * as Schema from "effect/Schema"
10
+ import * as Notification from "./Notification.ts"
11
+
12
+ /**
13
+ * The journal `eventType` recorded for one admission decision.
14
+ *
15
+ * The spelling is slashes and a PascalCase leaf, where every other event type
16
+ * in this repository is dot-separated and lowercase (`control.run.parked`,
17
+ * `flows.alerts.delivered`). The value is already durable in every engine
18
+ * database, and a rename would silently stop matching the projections that
19
+ * consumers key on, so it stays as written. A foreign projection must match
20
+ * this literal rather than the repository convention, which is why the two
21
+ * spellings are frozen by `test/WireFormat.test.ts`.
22
+ *
23
+ * @category constants
24
+ * @since 0.1.0
25
+ */
26
+ export const AdmittedEventType = "flows/notifications/Admitted"
27
+
28
+ /**
29
+ * The journal `eventType` recorded when pending notifications are promoted.
30
+ *
31
+ * Spelled like {@link AdmittedEventType}, and frozen for the same reason.
32
+ *
33
+ * @category constants
34
+ * @since 0.1.0
35
+ */
36
+ export const PromotedEventType = "flows/notifications/Promoted"
37
+
38
+ /**
39
+ * What a durable admission record says was decided.
40
+ *
41
+ * This is the one declaration of the admission vocabulary: it validates every
42
+ * journal entry through {@link fromEntry}, and `NotificationState` types its
43
+ * public signatures against it, so a fourth decision cannot be added to one
44
+ * half and forgotten in the other.
45
+ *
46
+ * @category models
47
+ * @since 1.0.0
48
+ */
49
+ export const AdmissionDecision = Schema.Literals(["admitted", "coalesced", "rejected-full"])
50
+
51
+ /**
52
+ * The decoded form of {@link AdmissionDecision}.
53
+ *
54
+ * @category models
55
+ * @since 1.0.0
56
+ */
57
+ export type AdmissionDecision = typeof AdmissionDecision.Type
58
+
59
+ /**
60
+ * A durable admission record: the notification a run was told about, and what
61
+ * the queue decided to do with it.
62
+ *
63
+ * A `rejected-full` decision is never written. The queue refuses a full queue
64
+ * in the receipt alone, so the notification id stays admissible once a
65
+ * boundary drains. The literal remains in {@link AdmissionDecision} because a
66
+ * reader must stay total over a record any writer could have produced.
67
+ *
68
+ * @category models
69
+ * @since 0.1.0
70
+ */
71
+ export const Admitted = Schema.Struct({
72
+ notification: Notification.Notification,
73
+ decision: AdmissionDecision,
74
+ /** SHA-256 of canonical validated input, absent in legacy admission rows. */
75
+ fingerprint: Schema.optional(Schema.String),
76
+ version: Schema.optional(Schema.Number)
77
+ })
78
+
79
+ /**
80
+ * The decoded form of {@link Admitted}.
81
+ *
82
+ * @category models
83
+ * @since 0.1.0
84
+ */
85
+ export type Admitted = typeof Admitted.Type
86
+
87
+ /**
88
+ * A durable promotion record: which notifications one boundary of one lineage
89
+ * delivered.
90
+ *
91
+ * The triple `(runId, targetLineageId, boundary)` is the unit of promotion, so
92
+ * a second lineage draining the same boundary name is a separate record rather
93
+ * than a repeat of the first.
94
+ *
95
+ * @category models
96
+ * @since 0.1.0
97
+ */
98
+ export const Promoted = Schema.Struct({
99
+ boundary: Schema.String,
100
+ targetLineageId: Schema.String,
101
+ ids: Schema.Array(Schema.String)
102
+ })
103
+
104
+ /**
105
+ * The decoded form of {@link Promoted}.
106
+ *
107
+ * @category models
108
+ * @since 0.1.0
109
+ */
110
+ export type Promoted = typeof Promoted.Type
111
+
112
+ /**
113
+ * Any journal event owned by this package.
114
+ *
115
+ * The two members are told apart with {@link isAdmitted} and
116
+ * {@link isPromoted} rather than a stored discriminant, because the discriminant
117
+ * would be a durable field this vocabulary does not have.
118
+ *
119
+ * @category models
120
+ * @since 0.1.0
121
+ */
122
+ export type Event = Admitted | Promoted
123
+
124
+ /**
125
+ * Whether an owned event is an admission record.
126
+ *
127
+ * @param event an owned event
128
+ * @category refinements
129
+ * @since 1.0.0
130
+ */
131
+ export const isAdmitted = (event: Event): event is Admitted => "notification" in event
132
+
133
+ /**
134
+ * Whether an owned event is a promotion record.
135
+ *
136
+ * @param event an owned event
137
+ * @category refinements
138
+ * @since 1.0.0
139
+ */
140
+ export const isPromoted = (event: Event): event is Promoted => !isAdmitted(event)
141
+
142
+ /**
143
+ * The journal source every admission of one notification is written under.
144
+ *
145
+ * @param notificationId the admitted notification's id
146
+ * @category constructors
147
+ * @since 1.0.0
148
+ */
149
+ export const admissionSourceId = (notificationId: string): JournalEvent.SourceId =>
150
+ JournalEvent.SourceId.make(`${admissionSourcePrefix}${notificationId}`)
151
+
152
+ /**
153
+ * The identity of one drain: the lineage and the boundary, each encoded so a
154
+ * value containing a slash cannot forge another pair's identity.
155
+ *
156
+ * @param targetLineageId the lineage the boundary closed on
157
+ * @param boundary the boundary name
158
+ * @category constructors
159
+ * @since 1.0.0
160
+ */
161
+ export const drainKey = (targetLineageId: string, boundary: string): string =>
162
+ `${encodeURIComponent(targetLineageId)}/${encodeURIComponent(boundary)}`
163
+
164
+ /**
165
+ * The journal source one drain's promotion record is written under.
166
+ *
167
+ * @param targetLineageId the lineage the boundary closed on
168
+ * @param boundary the boundary name
169
+ * @category constructors
170
+ * @since 1.0.0
171
+ */
172
+ export const drainSourceId = (targetLineageId: string, boundary: string): JournalEvent.SourceId =>
173
+ JournalEvent.SourceId.make(`${drainSourcePrefix}${drainKey(targetLineageId, boundary)}`)
174
+
175
+ const admissionSourcePrefix = "/notifications/admission/"
176
+ const drainSourcePrefix = "/notifications/drain/"
177
+
178
+ const decodeAdmitted = Schema.decodeUnknownOption(Admitted)
179
+ const decodePromoted = Schema.decodeUnknownOption(Promoted)
180
+
181
+ /**
182
+ * Decodes an owned notification event from a journal entry.
183
+ *
184
+ * Foreign entries and structurally invalid payloads decode to `None` so a
185
+ * projection over a shared journal stays total.
186
+ *
187
+ * The event type alone does not make an entry owned: any producer on a shared
188
+ * journal can choose one. An admission counts only under
189
+ * {@link admissionSourceId} for the notification it carries, and a promotion
190
+ * only under the `/notifications/drain/` source family the queue writes, so
191
+ * another producer's record with the same event type stays out of the
192
+ * projection. This scopes records; it does not authenticate the writer. The
193
+ * journal fences writes on run ownership, not on source, so a writer that
194
+ * sets this package's source id is read as this package. The drain check is
195
+ * a prefix because records written before the lineage joined the identity
196
+ * carry the boundary alone.
197
+ *
198
+ * @param entry one journal entry, owned or foreign
199
+ * @category constructors
200
+ * @since 0.1.0
201
+ */
202
+ export const fromEntry = (entry: JournalEvent.Entry): Option.Option<Event> => {
203
+ if (entry.eventType === AdmittedEventType) {
204
+ return Option.filter(
205
+ decodeAdmitted(entry.payload),
206
+ (event) => entry.sourceId === admissionSourceId(event.notification.id)
207
+ )
208
+ }
209
+ if (entry.eventType === PromotedEventType && entry.sourceId.startsWith(drainSourcePrefix)) {
210
+ return decodePromoted(entry.payload)
211
+ }
212
+ return Option.none()
213
+ }