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

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,158 @@
1
+ /**
2
+ * Journal event types owned by the notification queue.
3
+ *
4
+ * @since 0.1.0
5
+ */
6
+ import { JournalEvent } from "@smthrs/journal";
7
+ import * as Option from "effect/Option";
8
+ import * as Schema from "effect/Schema";
9
+ import * as Notification from "./Notification.js";
10
+ /**
11
+ * The journal `eventType` recorded for one admission decision.
12
+ *
13
+ * The spelling is slashes and a PascalCase leaf, where every other event type
14
+ * in this repository is dot-separated and lowercase (`control.run.parked`,
15
+ * `flows.alerts.delivered`). The value is already durable in every engine
16
+ * database, and a rename would silently stop matching the projections that
17
+ * consumers key on, so it stays as written. A foreign projection must match
18
+ * this literal rather than the repository convention, which is why the two
19
+ * spellings are frozen by `test/WireFormat.test.ts`.
20
+ *
21
+ * @category constants
22
+ * @since 0.1.0
23
+ */
24
+ export const AdmittedEventType = "flows/notifications/Admitted";
25
+ /**
26
+ * The journal `eventType` recorded when pending notifications are promoted.
27
+ *
28
+ * Spelled like {@link AdmittedEventType}, and frozen for the same reason.
29
+ *
30
+ * @category constants
31
+ * @since 0.1.0
32
+ */
33
+ export const PromotedEventType = "flows/notifications/Promoted";
34
+ /**
35
+ * What a durable admission record says was decided.
36
+ *
37
+ * This is the one declaration of the admission vocabulary: it validates every
38
+ * journal entry through {@link fromEntry}, and `NotificationState` types its
39
+ * public signatures against it, so a fourth decision cannot be added to one
40
+ * half and forgotten in the other.
41
+ *
42
+ * @category models
43
+ * @since 1.0.0
44
+ */
45
+ export const AdmissionDecision = Schema.Literals(["admitted", "coalesced", "rejected-full"]);
46
+ /**
47
+ * A durable admission record: the notification a run was told about, and what
48
+ * the queue decided to do with it.
49
+ *
50
+ * A `rejected-full` decision is never written. The queue refuses a full queue
51
+ * in the receipt alone, so the notification id stays admissible once a
52
+ * boundary drains. The literal remains in {@link AdmissionDecision} because a
53
+ * reader must stay total over a record any writer could have produced.
54
+ *
55
+ * @category models
56
+ * @since 0.1.0
57
+ */
58
+ export const Admitted = Schema.Struct({
59
+ notification: Notification.Notification,
60
+ decision: AdmissionDecision,
61
+ /** SHA-256 of canonical validated input, absent in legacy admission rows. */
62
+ fingerprint: Schema.optional(Schema.String),
63
+ version: Schema.optional(Schema.Number)
64
+ });
65
+ /**
66
+ * A durable promotion record: which notifications one boundary of one lineage
67
+ * delivered.
68
+ *
69
+ * The triple `(runId, targetLineageId, boundary)` is the unit of promotion, so
70
+ * a second lineage draining the same boundary name is a separate record rather
71
+ * than a repeat of the first.
72
+ *
73
+ * @category models
74
+ * @since 0.1.0
75
+ */
76
+ export const Promoted = Schema.Struct({
77
+ boundary: Schema.String,
78
+ targetLineageId: Schema.String,
79
+ ids: Schema.Array(Schema.String)
80
+ });
81
+ /**
82
+ * Whether an owned event is an admission record.
83
+ *
84
+ * @param event an owned event
85
+ * @category refinements
86
+ * @since 1.0.0
87
+ */
88
+ export const isAdmitted = (event) => "notification" in event;
89
+ /**
90
+ * Whether an owned event is a promotion record.
91
+ *
92
+ * @param event an owned event
93
+ * @category refinements
94
+ * @since 1.0.0
95
+ */
96
+ export const isPromoted = (event) => !isAdmitted(event);
97
+ /**
98
+ * The journal source every admission of one notification is written under.
99
+ *
100
+ * @param notificationId the admitted notification's id
101
+ * @category constructors
102
+ * @since 1.0.0
103
+ */
104
+ export const admissionSourceId = (notificationId) => JournalEvent.SourceId.make(`${admissionSourcePrefix}${notificationId}`);
105
+ /**
106
+ * The identity of one drain: the lineage and the boundary, each encoded so a
107
+ * value containing a slash cannot forge another pair's identity.
108
+ *
109
+ * @param targetLineageId the lineage the boundary closed on
110
+ * @param boundary the boundary name
111
+ * @category constructors
112
+ * @since 1.0.0
113
+ */
114
+ export const drainKey = (targetLineageId, boundary) => `${encodeURIComponent(targetLineageId)}/${encodeURIComponent(boundary)}`;
115
+ /**
116
+ * The journal source one drain's promotion record is written under.
117
+ *
118
+ * @param targetLineageId the lineage the boundary closed on
119
+ * @param boundary the boundary name
120
+ * @category constructors
121
+ * @since 1.0.0
122
+ */
123
+ export const drainSourceId = (targetLineageId, boundary) => JournalEvent.SourceId.make(`${drainSourcePrefix}${drainKey(targetLineageId, boundary)}`);
124
+ const admissionSourcePrefix = "/notifications/admission/";
125
+ const drainSourcePrefix = "/notifications/drain/";
126
+ const decodeAdmitted = Schema.decodeUnknownOption(Admitted);
127
+ const decodePromoted = Schema.decodeUnknownOption(Promoted);
128
+ /**
129
+ * Decodes an owned notification event from a journal entry.
130
+ *
131
+ * Foreign entries and structurally invalid payloads decode to `None` so a
132
+ * projection over a shared journal stays total.
133
+ *
134
+ * The event type alone does not make an entry owned: any producer on a shared
135
+ * journal can choose one. An admission counts only under
136
+ * {@link admissionSourceId} for the notification it carries, and a promotion
137
+ * only under the `/notifications/drain/` source family the queue writes, so
138
+ * another producer's record with the same event type stays out of the
139
+ * projection. This scopes records; it does not authenticate the writer. The
140
+ * journal fences writes on run ownership, not on source, so a writer that
141
+ * sets this package's source id is read as this package. The drain check is
142
+ * a prefix because records written before the lineage joined the identity
143
+ * carry the boundary alone.
144
+ *
145
+ * @param entry one journal entry, owned or foreign
146
+ * @category constructors
147
+ * @since 0.1.0
148
+ */
149
+ export const fromEntry = (entry) => {
150
+ if (entry.eventType === AdmittedEventType) {
151
+ return Option.filter(decodeAdmitted(entry.payload), (event) => entry.sourceId === admissionSourceId(event.notification.id));
152
+ }
153
+ if (entry.eventType === PromotedEventType && entry.sourceId.startsWith(drainSourcePrefix)) {
154
+ return decodePromoted(entry.payload);
155
+ }
156
+ return Option.none();
157
+ };
158
+ //# sourceMappingURL=NotificationEvent.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"NotificationEvent.js","sourceRoot":"","sources":["../../src/NotificationEvent.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Journal event types owned by the notification queue.\n *\n * @since 0.1.0\n */\n\nimport { JournalEvent } from \"@smthrs/journal\"\nimport * as Option from \"effect/Option\"\nimport * as Schema from \"effect/Schema\"\nimport * as Notification from \"./Notification.ts\"\n\n/**\n * The journal `eventType` recorded for one admission decision.\n *\n * The spelling is slashes and a PascalCase leaf, where every other event type\n * in this repository is dot-separated and lowercase (`control.run.parked`,\n * `flows.alerts.delivered`). The value is already durable in every engine\n * database, and a rename would silently stop matching the projections that\n * consumers key on, so it stays as written. A foreign projection must match\n * this literal rather than the repository convention, which is why the two\n * spellings are frozen by `test/WireFormat.test.ts`.\n *\n * @category constants\n * @since 0.1.0\n */\nexport const AdmittedEventType = \"flows/notifications/Admitted\"\n\n/**\n * The journal `eventType` recorded when pending notifications are promoted.\n *\n * Spelled like {@link AdmittedEventType}, and frozen for the same reason.\n *\n * @category constants\n * @since 0.1.0\n */\nexport const PromotedEventType = \"flows/notifications/Promoted\"\n\n/**\n * What a durable admission record says was decided.\n *\n * This is the one declaration of the admission vocabulary: it validates every\n * journal entry through {@link fromEntry}, and `NotificationState` types its\n * public signatures against it, so a fourth decision cannot be added to one\n * half and forgotten in the other.\n *\n * @category models\n * @since 1.0.0\n */\nexport const AdmissionDecision = Schema.Literals([\"admitted\", \"coalesced\", \"rejected-full\"])\n\n/**\n * The decoded form of {@link AdmissionDecision}.\n *\n * @category models\n * @since 1.0.0\n */\nexport type AdmissionDecision = typeof AdmissionDecision.Type\n\n/**\n * A durable admission record: the notification a run was told about, and what\n * the queue decided to do with it.\n *\n * A `rejected-full` decision is never written. The queue refuses a full queue\n * in the receipt alone, so the notification id stays admissible once a\n * boundary drains. The literal remains in {@link AdmissionDecision} because a\n * reader must stay total over a record any writer could have produced.\n *\n * @category models\n * @since 0.1.0\n */\nexport const Admitted = Schema.Struct({\n notification: Notification.Notification,\n decision: AdmissionDecision,\n /** SHA-256 of canonical validated input, absent in legacy admission rows. */\n fingerprint: Schema.optional(Schema.String),\n version: Schema.optional(Schema.Number)\n})\n\n/**\n * The decoded form of {@link Admitted}.\n *\n * @category models\n * @since 0.1.0\n */\nexport type Admitted = typeof Admitted.Type\n\n/**\n * A durable promotion record: which notifications one boundary of one lineage\n * delivered.\n *\n * The triple `(runId, targetLineageId, boundary)` is the unit of promotion, so\n * a second lineage draining the same boundary name is a separate record rather\n * than a repeat of the first.\n *\n * @category models\n * @since 0.1.0\n */\nexport const Promoted = Schema.Struct({\n boundary: Schema.String,\n targetLineageId: Schema.String,\n ids: Schema.Array(Schema.String)\n})\n\n/**\n * The decoded form of {@link Promoted}.\n *\n * @category models\n * @since 0.1.0\n */\nexport type Promoted = typeof Promoted.Type\n\n/**\n * Any journal event owned by this package.\n *\n * The two members are told apart with {@link isAdmitted} and\n * {@link isPromoted} rather than a stored discriminant, because the discriminant\n * would be a durable field this vocabulary does not have.\n *\n * @category models\n * @since 0.1.0\n */\nexport type Event = Admitted | Promoted\n\n/**\n * Whether an owned event is an admission record.\n *\n * @param event an owned event\n * @category refinements\n * @since 1.0.0\n */\nexport const isAdmitted = (event: Event): event is Admitted => \"notification\" in event\n\n/**\n * Whether an owned event is a promotion record.\n *\n * @param event an owned event\n * @category refinements\n * @since 1.0.0\n */\nexport const isPromoted = (event: Event): event is Promoted => !isAdmitted(event)\n\n/**\n * The journal source every admission of one notification is written under.\n *\n * @param notificationId the admitted notification's id\n * @category constructors\n * @since 1.0.0\n */\nexport const admissionSourceId = (notificationId: string): JournalEvent.SourceId =>\n JournalEvent.SourceId.make(`${admissionSourcePrefix}${notificationId}`)\n\n/**\n * The identity of one drain: the lineage and the boundary, each encoded so a\n * value containing a slash cannot forge another pair's identity.\n *\n * @param targetLineageId the lineage the boundary closed on\n * @param boundary the boundary name\n * @category constructors\n * @since 1.0.0\n */\nexport const drainKey = (targetLineageId: string, boundary: string): string =>\n `${encodeURIComponent(targetLineageId)}/${encodeURIComponent(boundary)}`\n\n/**\n * The journal source one drain's promotion record is written under.\n *\n * @param targetLineageId the lineage the boundary closed on\n * @param boundary the boundary name\n * @category constructors\n * @since 1.0.0\n */\nexport const drainSourceId = (targetLineageId: string, boundary: string): JournalEvent.SourceId =>\n JournalEvent.SourceId.make(`${drainSourcePrefix}${drainKey(targetLineageId, boundary)}`)\n\nconst admissionSourcePrefix = \"/notifications/admission/\"\nconst drainSourcePrefix = \"/notifications/drain/\"\n\nconst decodeAdmitted = Schema.decodeUnknownOption(Admitted)\nconst decodePromoted = Schema.decodeUnknownOption(Promoted)\n\n/**\n * Decodes an owned notification event from a journal entry.\n *\n * Foreign entries and structurally invalid payloads decode to `None` so a\n * projection over a shared journal stays total.\n *\n * The event type alone does not make an entry owned: any producer on a shared\n * journal can choose one. An admission counts only under\n * {@link admissionSourceId} for the notification it carries, and a promotion\n * only under the `/notifications/drain/` source family the queue writes, so\n * another producer's record with the same event type stays out of the\n * projection. This scopes records; it does not authenticate the writer. The\n * journal fences writes on run ownership, not on source, so a writer that\n * sets this package's source id is read as this package. The drain check is\n * a prefix because records written before the lineage joined the identity\n * carry the boundary alone.\n *\n * @param entry one journal entry, owned or foreign\n * @category constructors\n * @since 0.1.0\n */\nexport const fromEntry = (entry: JournalEvent.Entry): Option.Option<Event> => {\n if (entry.eventType === AdmittedEventType) {\n return Option.filter(\n decodeAdmitted(entry.payload),\n (event) => entry.sourceId === admissionSourceId(event.notification.id)\n )\n }\n if (entry.eventType === PromotedEventType && entry.sourceId.startsWith(drainSourcePrefix)) {\n return decodePromoted(entry.payload)\n }\n return Option.none()\n}\n"]}
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Journal-backed durable notification admission and turn-boundary drain.
3
+ *
4
+ * Governing contract: `packages/smithers/notifications/docs/api.md`, published as
5
+ * `/api/notifications`.
6
+ *
7
+ * @since 0.1.0
8
+ */
9
+ import { Journal } from "@smthrs/journal";
10
+ import { Context, Effect, Layer, Schema } from "effect";
11
+ import * as NotificationModel from "./Notification.ts";
12
+ import * as NotificationState from "./NotificationState.ts";
13
+ declare const NotificationError_base: Schema.Class<NotificationError, Schema.TaggedStruct<"/notifications/NotificationError", {
14
+ readonly code: Schema.withConstructorDefault<Schema.Literals<readonly ["notification_unavailable", "notification_closed", "notification_full", "notification_id_reused", "notification_invalid", "notification_refused"]>>;
15
+ readonly message: Schema.String;
16
+ /** The notification the failure is about, when one was readable. */
17
+ readonly notificationId: Schema.optional<Schema.String>;
18
+ /** Dotted path of the offending field, for `notification_invalid`. */
19
+ readonly path: Schema.optional<Schema.String>;
20
+ }>, import("effect/Cause").YieldableError>;
21
+ /**
22
+ * The queue could not be reached, or it refused what a caller handed it.
23
+ *
24
+ * `code` is the stable half and the one to branch on. `notification_unavailable`
25
+ * is the seam reporting that it serves nothing; `notification_id_reused` says a
26
+ * stable id was admitted once already with different content, which is a
27
+ * producer bug rather than a storage failure; `notification_invalid` says the
28
+ * value is not a notification, and `path` names the field inside it that failed.
29
+ * `notification_closed` means the receiver has finished and the caller must
30
+ * start a new request; `notification_full` means nothing was retained and the
31
+ * same notification may be retried after pending work drains. Neither the
32
+ * message nor `path` ever carries the offending value.
33
+ * `notification_refused` rejects reserved outside-change payloads before admission.
34
+ *
35
+ * @category errors
36
+ * @since 0.1.0
37
+ */
38
+ export declare class NotificationError extends NotificationError_base {
39
+ }
40
+ /**
41
+ * What admitting one notification decided.
42
+ *
43
+ * `seq` is the journal sequence the admission committed at, and it is absent
44
+ * exactly when nothing was written, which today means a `rejected-full`
45
+ * refusal. `duplicate` says the id was already admitted, in which case
46
+ * `decision` and `seq` are read back from the committed record rather than
47
+ * recomputed here.
48
+ *
49
+ * A caller MUST inspect `decision`: `rejected-full` means the queue was at
50
+ * capacity and retained nothing, so the notification never reaches the model
51
+ * unless the caller admits it again once a boundary has drained.
52
+ *
53
+ * @category models
54
+ * @since 0.1.0
55
+ */
56
+ export interface AdmissionReceipt {
57
+ readonly notificationId: string;
58
+ readonly decision: NotificationState.AdmissionDecision;
59
+ readonly seq: number | undefined;
60
+ readonly duplicate: boolean;
61
+ readonly consumed?: boolean;
62
+ readonly consumedNotification?: NotificationModel.Notification;
63
+ }
64
+ /**
65
+ * The boundary a drain is attempted at, and whether the run would go idle
66
+ * if nothing were delivered.
67
+ *
68
+ * `(runId, targetLineageId, boundary)` is the unit of drain: two lineages
69
+ * closing a turn under the same boundary name are two drains, and each is
70
+ * recorded separately.
71
+ *
72
+ * @category models
73
+ * @since 0.1.0
74
+ */
75
+ export interface DrainInput {
76
+ readonly runId: string;
77
+ readonly targetLineageId: string;
78
+ readonly boundary: string;
79
+ readonly wouldIdle: boolean;
80
+ /**
81
+ * The journal sequence that opened this turn. A steer admitted after it is
82
+ * held for the next boundary, which is what keeps a message that arrived
83
+ * mid-turn out of the turn already in flight. Omitting it delivers
84
+ * everything pending for the lineage.
85
+ */
86
+ readonly cutoffSeq?: number | undefined;
87
+ }
88
+ /**
89
+ * The notifications this boundary delivers, and whether the boundary had
90
+ * already drained.
91
+ *
92
+ * The notifications are the ones the committed promotion record names, so two
93
+ * processes draining one boundary report the same delivery rather than two
94
+ * divergent guesses.
95
+ *
96
+ * @category models
97
+ * @since 0.1.0
98
+ */
99
+ export interface DrainReceipt {
100
+ readonly notifications: ReadonlyArray<NotificationModel.Notification>;
101
+ readonly boundary: string;
102
+ readonly duplicate: boolean;
103
+ }
104
+ /**
105
+ * The durable pending queue: admit a notification exactly once, and drain
106
+ * what a boundary is allowed to deliver.
107
+ *
108
+ * @category services
109
+ * @since 0.1.0
110
+ */
111
+ export interface Service {
112
+ readonly admit: (runId: string, notification: NotificationModel.Notification, version?: number) => Effect.Effect<AdmissionReceipt, Journal.JournalError | NotificationError>;
113
+ readonly drain: (input: DrainInput) => Effect.Effect<DrainReceipt, Journal.JournalError | NotificationError>;
114
+ /**
115
+ * What this run has been told that no boundary has delivered yet, in
116
+ * admission order.
117
+ *
118
+ * The queue is the owner of that fact: pending is admitted minus promoted,
119
+ * and both halves are its own journal records. A supervisor that counted
120
+ * admissions alone would report a steer as waiting forever.
121
+ */
122
+ readonly pending: (runId: string) => Effect.Effect<ReadonlyArray<NotificationModel.Notification>, Journal.JournalError | NotificationError>;
123
+ }
124
+ declare const NotificationQueue_base: Context.ServiceClass<NotificationQueue, "/notifications/NotificationQueue", Service>;
125
+ /**
126
+ * The {@link Service} tag.
127
+ *
128
+ * @category services
129
+ * @since 0.1.0
130
+ */
131
+ export declare class NotificationQueue extends NotificationQueue_base {
132
+ }
133
+ /**
134
+ * Builds a {@link Service} from an implementation of its methods.
135
+ *
136
+ * @param implementation the methods to serve
137
+ * @category constructors
138
+ * @since 0.1.0
139
+ */
140
+ export declare const make: (implementation: Service) => Service;
141
+ /**
142
+ * A {@link Service} that fails every method as unavailable. Overrides
143
+ * replace individual methods.
144
+ *
145
+ * @param overrides methods to serve instead of failing
146
+ * @category constructors
147
+ * @since 0.1.0
148
+ */
149
+ export declare const makeNoop: (overrides?: Partial<Service>) => Service;
150
+ /**
151
+ * Provides {@link makeNoop}.
152
+ *
153
+ * @param overrides methods to serve instead of failing
154
+ * @category layers
155
+ * @since 0.1.0
156
+ */
157
+ export declare const layerNoop: (overrides?: Partial<Service>) => Layer.Layer<NotificationQueue>;
158
+ /**
159
+ * Journal-backed production layer, with the pending capacity a composition
160
+ * chooses.
161
+ *
162
+ * Admission and drain evidence is written through `emitDurableUnfenced`, so
163
+ * both return only after the corresponding entry is durably committed and a
164
+ * dropped lifecycle event is unrepresentable. The channel is the unfenced one
165
+ * on purpose: the notifying process owns no run, so there is no ownership
166
+ * fence to hand over, and both records are first-writer-wins on their own
167
+ * identity instead.
168
+ *
169
+ * The capacity guard is a conditional write at the storage layer, not a check
170
+ * against a fold one process happened to load: the fold, the guard, and the
171
+ * admission record are decided and written inside ONE `journal.transact`
172
+ * write transaction, which the durable writer serializes against every other
173
+ * writer of the database and replays when a concurrent writer commits between
174
+ * the guard's reads and the insert. Two processes admitting to one full queue
175
+ * therefore produce one admission and one `rejected-full`, never two
176
+ * admissions.
177
+ *
178
+ * @param options the pending capacity, defaulting to `NotificationState.defaultCapacity`
179
+ * @category layers
180
+ * @since 1.0.0
181
+ */
182
+ export declare const layerWith: (options?: {
183
+ readonly capacity?: number | undefined;
184
+ }) => Layer.Layer<NotificationQueue, never, Journal.Journal>;
185
+ /**
186
+ * Journal-backed production layer at the default capacity.
187
+ *
188
+ * @category layers
189
+ * @since 0.1.0
190
+ */
191
+ export declare const layer: Layer.Layer<NotificationQueue, never, Journal.Journal>;
192
+ export {};
193
+ //# sourceMappingURL=NotificationQueue.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"NotificationQueue.d.ts","sourceRoot":"","sources":["../../src/NotificationQueue.ts"],"names":[],"mappings":"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"}