@smthrs/triggers 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 (226) hide show
  1. package/CHANGELOG.md +219 -0
  2. package/LICENSE +21 -0
  3. package/README.md +109 -2
  4. package/dist/cjs/CatchUp.d.ts +25 -0
  5. package/dist/cjs/CatchUp.d.ts.map +1 -0
  6. package/dist/cjs/CatchUp.js +72 -0
  7. package/dist/cjs/CatchUp.js.map +7 -0
  8. package/dist/cjs/Channel.d.ts +108 -0
  9. package/dist/cjs/Channel.d.ts.map +1 -0
  10. package/dist/cjs/Channel.js +25 -0
  11. package/dist/cjs/Channel.js.map +7 -0
  12. package/dist/cjs/ClaimDecision.d.ts +183 -0
  13. package/dist/cjs/ClaimDecision.d.ts.map +1 -0
  14. package/dist/cjs/ClaimDecision.js +144 -0
  15. package/dist/cjs/ClaimDecision.js.map +7 -0
  16. package/dist/cjs/Cron.d.ts +91 -0
  17. package/dist/cjs/Cron.d.ts.map +1 -0
  18. package/dist/cjs/Cron.js +108 -0
  19. package/dist/cjs/Cron.js.map +7 -0
  20. package/dist/cjs/DispatchReader.d.ts +77 -0
  21. package/dist/cjs/DispatchReader.d.ts.map +1 -0
  22. package/dist/cjs/DispatchReader.js +121 -0
  23. package/dist/cjs/DispatchReader.js.map +7 -0
  24. package/dist/cjs/Overlap.d.ts +40 -0
  25. package/dist/cjs/Overlap.d.ts.map +1 -0
  26. package/dist/cjs/Overlap.js +37 -0
  27. package/dist/cjs/Overlap.js.map +7 -0
  28. package/dist/cjs/Schedule.d.ts +96 -0
  29. package/dist/cjs/Schedule.d.ts.map +1 -0
  30. package/dist/cjs/Schedule.js +61 -0
  31. package/dist/cjs/Schedule.js.map +7 -0
  32. package/dist/cjs/Scheduler.d.ts +248 -0
  33. package/dist/cjs/Scheduler.d.ts.map +1 -0
  34. package/dist/cjs/Scheduler.js +647 -0
  35. package/dist/cjs/Scheduler.js.map +7 -0
  36. package/dist/cjs/SqlTriggerStore.d.ts +29 -0
  37. package/dist/cjs/SqlTriggerStore.d.ts.map +1 -0
  38. package/dist/cjs/SqlTriggerStore.js +483 -0
  39. package/dist/cjs/SqlTriggerStore.js.map +7 -0
  40. package/dist/cjs/Trigger.d.ts +81 -0
  41. package/dist/cjs/Trigger.d.ts.map +1 -0
  42. package/dist/cjs/Trigger.js +54 -0
  43. package/dist/cjs/Trigger.js.map +7 -0
  44. package/dist/cjs/TriggerError.d.ts +58 -0
  45. package/dist/cjs/TriggerError.d.ts.map +1 -0
  46. package/dist/cjs/TriggerError.js +83 -0
  47. package/dist/cjs/TriggerError.js.map +7 -0
  48. package/dist/cjs/TriggerStore.d.ts +411 -0
  49. package/dist/cjs/TriggerStore.d.ts.map +1 -0
  50. package/dist/cjs/TriggerStore.js +127 -0
  51. package/dist/cjs/TriggerStore.js.map +7 -0
  52. package/dist/cjs/Webhook.d.ts +118 -0
  53. package/dist/cjs/Webhook.d.ts.map +1 -0
  54. package/dist/cjs/Webhook.js +153 -0
  55. package/dist/cjs/Webhook.js.map +7 -0
  56. package/dist/cjs/index.d.ts +30 -0
  57. package/dist/cjs/index.d.ts.map +1 -0
  58. package/dist/cjs/index.js +57 -0
  59. package/dist/cjs/index.js.map +7 -0
  60. package/dist/cjs/internal/ActiveRuns.d.ts +51 -0
  61. package/dist/cjs/internal/ActiveRuns.d.ts.map +1 -0
  62. package/dist/cjs/internal/ActiveRuns.js +56 -0
  63. package/dist/cjs/internal/ActiveRuns.js.map +7 -0
  64. package/dist/cjs/internal/DueOccurrences.d.ts +32 -0
  65. package/dist/cjs/internal/DueOccurrences.d.ts.map +1 -0
  66. package/dist/cjs/internal/DueOccurrences.js +80 -0
  67. package/dist/cjs/internal/DueOccurrences.js.map +7 -0
  68. package/dist/cjs/internal/WallClock.d.ts +59 -0
  69. package/dist/cjs/internal/WallClock.d.ts.map +1 -0
  70. package/dist/cjs/internal/WallClock.js +132 -0
  71. package/dist/cjs/internal/WallClock.js.map +7 -0
  72. package/dist/cjs/migrations/0001_triggers.d.ts +15 -0
  73. package/dist/cjs/migrations/0001_triggers.d.ts.map +1 -0
  74. package/dist/cjs/migrations/0001_triggers.js +64 -0
  75. package/dist/cjs/migrations/0001_triggers.js.map +7 -0
  76. package/dist/cjs/migrations/0002_reservation_lease.d.ts +15 -0
  77. package/dist/cjs/migrations/0002_reservation_lease.d.ts.map +1 -0
  78. package/dist/cjs/migrations/0002_reservation_lease.js +41 -0
  79. package/dist/cjs/migrations/0002_reservation_lease.js.map +7 -0
  80. package/dist/cjs/migrations/0003_heartbeat.d.ts +16 -0
  81. package/dist/cjs/migrations/0003_heartbeat.d.ts.map +1 -0
  82. package/dist/cjs/migrations/0003_heartbeat.js +44 -0
  83. package/dist/cjs/migrations/0003_heartbeat.js.map +7 -0
  84. package/dist/cjs/migrations/0004_fire_run_index.d.ts +17 -0
  85. package/dist/cjs/migrations/0004_fire_run_index.d.ts.map +1 -0
  86. package/dist/cjs/migrations/0004_fire_run_index.js +40 -0
  87. package/dist/cjs/migrations/0004_fire_run_index.js.map +7 -0
  88. package/dist/cjs/migrations/index.d.ts +33 -0
  89. package/dist/cjs/migrations/index.d.ts.map +1 -0
  90. package/dist/cjs/migrations/index.js +50 -0
  91. package/dist/cjs/migrations/index.js.map +7 -0
  92. package/dist/cjs/package.json +1 -0
  93. package/dist/cjs/test/TestTriggers.d.ts +12 -0
  94. package/dist/cjs/test/TestTriggers.d.ts.map +1 -0
  95. package/dist/cjs/test/TestTriggers.js +421 -0
  96. package/dist/cjs/test/TestTriggers.js.map +7 -0
  97. package/dist/esm/CatchUp.d.ts +25 -0
  98. package/dist/esm/CatchUp.d.ts.map +1 -0
  99. package/dist/esm/CatchUp.js +52 -0
  100. package/dist/esm/CatchUp.js.map +1 -0
  101. package/dist/esm/Channel.d.ts +108 -0
  102. package/dist/esm/Channel.d.ts.map +1 -0
  103. package/dist/esm/Channel.js +19 -0
  104. package/dist/esm/Channel.js.map +1 -0
  105. package/dist/esm/ClaimDecision.d.ts +183 -0
  106. package/dist/esm/ClaimDecision.d.ts.map +1 -0
  107. package/dist/esm/ClaimDecision.js +172 -0
  108. package/dist/esm/ClaimDecision.js.map +1 -0
  109. package/dist/esm/Cron.d.ts +91 -0
  110. package/dist/esm/Cron.d.ts.map +1 -0
  111. package/dist/esm/Cron.js +132 -0
  112. package/dist/esm/Cron.js.map +1 -0
  113. package/dist/esm/DispatchReader.d.ts +77 -0
  114. package/dist/esm/DispatchReader.d.ts.map +1 -0
  115. package/dist/esm/DispatchReader.js +152 -0
  116. package/dist/esm/DispatchReader.js.map +1 -0
  117. package/dist/esm/Overlap.d.ts +40 -0
  118. package/dist/esm/Overlap.d.ts.map +1 -0
  119. package/dist/esm/Overlap.js +31 -0
  120. package/dist/esm/Overlap.js.map +1 -0
  121. package/dist/esm/Schedule.d.ts +96 -0
  122. package/dist/esm/Schedule.d.ts.map +1 -0
  123. package/dist/esm/Schedule.js +73 -0
  124. package/dist/esm/Schedule.js.map +1 -0
  125. package/dist/esm/Scheduler.d.ts +248 -0
  126. package/dist/esm/Scheduler.d.ts.map +1 -0
  127. package/dist/esm/Scheduler.js +734 -0
  128. package/dist/esm/Scheduler.js.map +1 -0
  129. package/dist/esm/SqlTriggerStore.d.ts +29 -0
  130. package/dist/esm/SqlTriggerStore.d.ts.map +1 -0
  131. package/dist/esm/SqlTriggerStore.js +483 -0
  132. package/dist/esm/SqlTriggerStore.js.map +1 -0
  133. package/dist/esm/Trigger.d.ts +81 -0
  134. package/dist/esm/Trigger.d.ts.map +1 -0
  135. package/dist/esm/Trigger.js +56 -0
  136. package/dist/esm/Trigger.js.map +1 -0
  137. package/dist/esm/TriggerError.d.ts +58 -0
  138. package/dist/esm/TriggerError.d.ts.map +1 -0
  139. package/dist/esm/TriggerError.js +110 -0
  140. package/dist/esm/TriggerError.js.map +1 -0
  141. package/dist/esm/TriggerStore.d.ts +411 -0
  142. package/dist/esm/TriggerStore.d.ts.map +1 -0
  143. package/dist/esm/TriggerStore.js +209 -0
  144. package/dist/esm/TriggerStore.js.map +1 -0
  145. package/dist/esm/Webhook.d.ts +118 -0
  146. package/dist/esm/Webhook.d.ts.map +1 -0
  147. package/dist/esm/Webhook.js +186 -0
  148. package/dist/esm/Webhook.js.map +1 -0
  149. package/dist/esm/index.d.ts +30 -0
  150. package/dist/esm/index.d.ts.map +1 -0
  151. package/dist/esm/index.js +30 -0
  152. package/dist/esm/index.js.map +1 -0
  153. package/dist/esm/internal/ActiveRuns.d.ts +51 -0
  154. package/dist/esm/internal/ActiveRuns.d.ts.map +1 -0
  155. package/dist/esm/internal/ActiveRuns.js +34 -0
  156. package/dist/esm/internal/ActiveRuns.js.map +1 -0
  157. package/dist/esm/internal/DueOccurrences.d.ts +32 -0
  158. package/dist/esm/internal/DueOccurrences.d.ts.map +1 -0
  159. package/dist/esm/internal/DueOccurrences.js +55 -0
  160. package/dist/esm/internal/DueOccurrences.js.map +1 -0
  161. package/dist/esm/internal/WallClock.d.ts +59 -0
  162. package/dist/esm/internal/WallClock.d.ts.map +1 -0
  163. package/dist/esm/internal/WallClock.js +155 -0
  164. package/dist/esm/internal/WallClock.js.map +1 -0
  165. package/dist/esm/migrations/0001_triggers.d.ts +15 -0
  166. package/dist/esm/migrations/0001_triggers.d.ts.map +1 -0
  167. package/dist/esm/migrations/0001_triggers.js +42 -0
  168. package/dist/esm/migrations/0001_triggers.js.map +1 -0
  169. package/dist/esm/migrations/0002_reservation_lease.d.ts +15 -0
  170. package/dist/esm/migrations/0002_reservation_lease.d.ts.map +1 -0
  171. package/dist/esm/migrations/0002_reservation_lease.js +19 -0
  172. package/dist/esm/migrations/0002_reservation_lease.js.map +1 -0
  173. package/dist/esm/migrations/0003_heartbeat.d.ts +16 -0
  174. package/dist/esm/migrations/0003_heartbeat.d.ts.map +1 -0
  175. package/dist/esm/migrations/0003_heartbeat.js +23 -0
  176. package/dist/esm/migrations/0003_heartbeat.js.map +1 -0
  177. package/dist/esm/migrations/0004_fire_run_index.d.ts +17 -0
  178. package/dist/esm/migrations/0004_fire_run_index.d.ts.map +1 -0
  179. package/dist/esm/migrations/0004_fire_run_index.js +20 -0
  180. package/dist/esm/migrations/0004_fire_run_index.js.map +1 -0
  181. package/dist/esm/migrations/index.d.ts +33 -0
  182. package/dist/esm/migrations/index.d.ts.map +1 -0
  183. package/dist/esm/migrations/index.js +37 -0
  184. package/dist/esm/migrations/index.js.map +1 -0
  185. package/dist/esm/test/TestTriggers.d.ts +12 -0
  186. package/dist/esm/test/TestTriggers.d.ts.map +1 -0
  187. package/dist/esm/test/TestTriggers.js +439 -0
  188. package/dist/esm/test/TestTriggers.js.map +1 -0
  189. package/docs/README.md +140 -0
  190. package/docs/api.md +1186 -0
  191. package/docs/concepts/channels.md +103 -0
  192. package/docs/concepts/claim-protocol.md +229 -0
  193. package/docs/concepts/policies.md +124 -0
  194. package/docs/concepts/schedules.md +134 -0
  195. package/docs/guides/choose-a-policy.md +113 -0
  196. package/docs/guides/declare-a-trigger.md +136 -0
  197. package/docs/guides/ingest-a-webhook.md +164 -0
  198. package/docs/guides/run-the-scheduler.md +173 -0
  199. package/docs/guides/testing.md +157 -0
  200. package/docs/installation.md +81 -0
  201. package/docs/quickstart.md +163 -0
  202. package/docs/troubleshooting.md +281 -0
  203. package/package.json +241 -3
  204. package/src/CatchUp.ts +73 -0
  205. package/src/Channel.ts +121 -0
  206. package/src/ClaimDecision.ts +303 -0
  207. package/src/Cron.ts +173 -0
  208. package/src/DispatchReader.ts +179 -0
  209. package/src/Overlap.ts +53 -0
  210. package/src/Schedule.ts +112 -0
  211. package/src/Scheduler.ts +1154 -0
  212. package/src/SqlTriggerStore.ts +638 -0
  213. package/src/Trigger.ts +84 -0
  214. package/src/TriggerError.ts +129 -0
  215. package/src/TriggerStore.ts +514 -0
  216. package/src/Webhook.ts +270 -0
  217. package/src/index.ts +30 -0
  218. package/src/internal/ActiveRuns.ts +83 -0
  219. package/src/internal/DueOccurrences.ts +101 -0
  220. package/src/internal/WallClock.ts +172 -0
  221. package/src/migrations/0001_triggers.ts +43 -0
  222. package/src/migrations/0002_reservation_lease.ts +20 -0
  223. package/src/migrations/0003_heartbeat.ts +24 -0
  224. package/src/migrations/0004_fire_run_index.ts +21 -0
  225. package/src/migrations/index.ts +40 -0
  226. package/src/test/TestTriggers.ts +563 -0
package/src/Webhook.ts ADDED
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Verified webhook ingestion through the authoritative Control boundary.
3
+ *
4
+ * @see packages/smithers/agent/triggers/docs/api.md
5
+ * @since 0.1.0
6
+ */
7
+
8
+ import * as ControlChannels from "@smthrs/control/Channels"
9
+ import type { ControlError } from "@smthrs/control/ControlError"
10
+ import { InvalidInput, Unauthorized } from "@smthrs/control/ControlError"
11
+ import type { Receipt, RunSummary } from "@smthrs/control/ControlSchema"
12
+ import type { CredentialRef } from "@smthrs/control/Credential"
13
+ import * as ControlWebhook from "@smthrs/control/WebhookChannel"
14
+ import * as Effect from "effect/Effect"
15
+ import type * as Redacted from "effect/Redacted"
16
+ import * as Schema from "effect/Schema"
17
+ import type * as Channel from "./Channel.ts"
18
+ import { TriggerError } from "./TriggerError.ts"
19
+
20
+ /**
21
+ * Compares a supplied byte string against the expected one without returning
22
+ * early on a mismatch.
23
+ *
24
+ * The loop runs exactly `expected.length` times, so its iteration count is
25
+ * fixed by the secret side of the comparison and never by the caller's. Reading
26
+ * the longer of the two instead let a caller lengthen its input until the work
27
+ * stopped growing, which reports the expected signature's length. The length
28
+ * difference is folded into the result, so inputs of unequal length always
29
+ * disagree.
30
+ *
31
+ * @category verification
32
+ * @since 0.1.0
33
+ */
34
+ export const constantTimeEqual = (expected: Uint8Array, supplied: Uint8Array): boolean => {
35
+ let difference = expected.length ^ supplied.length
36
+ for (const [index, byte] of expected.entries()) {
37
+ difference |= byte ^ (supplied[index] ?? 0)
38
+ }
39
+ return difference === 0
40
+ }
41
+
42
+ /**
43
+ * Configuration for a raw-byte signature verifier.
44
+ *
45
+ * `expected` receives a private copy of the request bytes and the redacted
46
+ * credential reference the channel was declared with, and answers with the
47
+ * signature bytes the request must carry in `header`. It returns an Effect so
48
+ * the secret is resolved through the host's resolver per request rather than
49
+ * captured in a closure at declaration time, and so a resolution or HMAC
50
+ * failure arrives as a typed `verification_failed` instead of a defect that
51
+ * kills the fiber.
52
+ *
53
+ * @category models
54
+ * @since 0.1.0
55
+ */
56
+ export interface SignatureConfig {
57
+ readonly header: string
58
+ readonly expected: (
59
+ body: Uint8Array,
60
+ credential: Redacted.Redacted<CredentialRef>
61
+ ) => Effect.Effect<Uint8Array, TriggerError>
62
+ }
63
+
64
+ /**
65
+ * Copies request bytes into memory nobody else holds.
66
+ *
67
+ * `body.slice()` is not enough: a Node `Buffer` is a `Uint8Array` whose
68
+ * `slice` is an alias of `subarray` and answers with a view over the same
69
+ * memory, so a caller that reused its `Buffer` after handing it over changed
70
+ * what got verified, and a verifier that edited the bytes it was handed edited
71
+ * the caller's. `Uint8Array.from` always allocates, and it copies only the
72
+ * viewed range of an offset view and only a snapshot of a `SharedArrayBuffer`.
73
+ */
74
+ const ownedCopy = (body: Uint8Array): Uint8Array => Uint8Array.from(body)
75
+
76
+ /**
77
+ * Builds a verifier that compares a signature header in constant time.
78
+ *
79
+ * An absent or empty header is refused before `expected` runs, and an
80
+ * `expected` that answers with zero bytes is refused after it. Neither is a
81
+ * comparison the constant-time equality can decide: it agrees on two empty
82
+ * byte strings, so a request carrying no signature at all would have
83
+ * authenticated against a secret that resolved to the empty string. A
84
+ * zero-length expected signature is a misconfigured credential, never a valid
85
+ * one, so the door stays closed instead of opening for everyone.
86
+ *
87
+ * Every refusal carries the same message, `webhook signature in <header> did
88
+ * not verify`, whatever the reason. A failure raised by `expected` is kept in
89
+ * `cause` only: its message names the credential reference or the resolver
90
+ * that could not produce a secret, which is a host detail, and the refusal
91
+ * that answers a bad signature answers a broken resolver the same way.
92
+ *
93
+ * @category constructors
94
+ * @since 0.1.0
95
+ */
96
+ export const makeSignatureVerifier = (config: SignatureConfig): Channel.Verify => (raw, credential) => {
97
+ const refuse = (cause?: TriggerError) =>
98
+ Effect.fail(
99
+ new TriggerError({
100
+ code: "verification_failed",
101
+ message: `webhook signature in ${config.header} did not verify`,
102
+ ...(cause === undefined ? {} : { cause })
103
+ })
104
+ )
105
+ return Effect.suspend(() => {
106
+ const supplied = raw.headers[config.header.toLowerCase()] ?? raw.headers[config.header]
107
+ if (supplied === undefined || supplied.length === 0) {
108
+ return refuse()
109
+ }
110
+ const actual = new TextEncoder().encode(supplied)
111
+ // The verifier gets its own copy: nothing it does to these bytes can reach
112
+ // the buffer that is about to be fingerprinted and decoded.
113
+ return config.expected(ownedCopy(raw.body), credential).pipe(
114
+ Effect.matchEffect({
115
+ onFailure: (error) => refuse(error),
116
+ onSuccess: (expected) =>
117
+ expected.length > 0 && constantTimeEqual(expected, actual)
118
+ ? Effect.void
119
+ : refuse()
120
+ })
121
+ )
122
+ })
123
+ }
124
+
125
+ /**
126
+ * Webhook declaration configuration.
127
+ *
128
+ * `credential` is required. It used to default to a reference named after the
129
+ * channel, so a webhook declared without one verified against whatever
130
+ * credential happened to share its name instead of being refused, and two
131
+ * declarations differing only in credential verified identically.
132
+ *
133
+ * @category models
134
+ * @since 0.1.0
135
+ */
136
+ export interface Config<Payload, Outbound = never> extends Channel.Config<Payload, RunSummary, Outbound> {
137
+ readonly credential: Redacted.Redacted<CredentialRef>
138
+ }
139
+
140
+ /**
141
+ * A webhook door that can only ingest through `Channels` and `Control`.
142
+ *
143
+ * @category services
144
+ * @since 0.1.0
145
+ */
146
+ export interface Webhook {
147
+ readonly name: string
148
+ readonly register: Effect.Effect<void, never, ControlChannels.Channels>
149
+ readonly ingest: (
150
+ raw: Channel.RawInbound
151
+ ) => Effect.Effect<Receipt, ControlError | TriggerError, ControlChannels.Channels>
152
+ }
153
+
154
+ const invalidInput = (issue: string): InvalidInput => new InvalidInput({ issue })
155
+
156
+ const toControlChannel = <Payload, Outbound>(
157
+ config: Config<Payload, Outbound>
158
+ ): ControlChannels.Channel<Payload> => {
159
+ const channel = ControlWebhook.make({
160
+ name: config.name,
161
+ schema: config.schema,
162
+ credential: config.credential,
163
+ // The message is fixed here rather than forwarded from the verifier: a
164
+ // custom `verify` may say why it refused, and that reason travels toward
165
+ // the unauthenticated sender on the same error that answers a bad
166
+ // signature. `Unauthorized` carries no cause, so nothing else survives
167
+ // the crossing.
168
+ verify: (raw, credential) =>
169
+ config.verify(raw, credential).pipe(
170
+ Effect.mapError(() => new Unauthorized({ message: `webhook ${config.name} did not verify the request` }))
171
+ ),
172
+ map: (payload) => {
173
+ const inbound = config.inbound(payload)
174
+ if ("start" in inbound) {
175
+ return Effect.succeed({
176
+ _tag: "Start" as const,
177
+ flowId: inbound.start.flowId,
178
+ input: inbound.start.input
179
+ })
180
+ }
181
+ return Schema.decodeUnknownEffect(Schema.Json)(inbound.signal.value).pipe(
182
+ Effect.map(
183
+ (value) => ({
184
+ _tag: "Signal" as const,
185
+ runId: inbound.signal.runId,
186
+ signal: {
187
+ name: inbound.signal.stepId,
188
+ payload: value
189
+ }
190
+ })
191
+ ),
192
+ Effect.mapError((error) => invalidInput(String(error)))
193
+ )
194
+ },
195
+ project: (run) => {
196
+ if (config.outbound === undefined) {
197
+ return {
198
+ cursor: String(run.updatedAt),
199
+ operation: "noop" as const,
200
+ message: null
201
+ }
202
+ }
203
+ return {
204
+ cursor: String(run.updatedAt),
205
+ operation: "post" as const,
206
+ message: config.outbound(run)
207
+ }
208
+ }
209
+ })
210
+ return channel
211
+ }
212
+
213
+ /**
214
+ * Builds a webhook door whose only dispatch path is the Control channel
215
+ * coordinator.
216
+ *
217
+ * Verification occurs inside `Channels.ingest` before the adapter's JSON or
218
+ * schema decoder and before any Control operation.
219
+ *
220
+ * `ingest` does not register: a channel is registered once, deliberately,
221
+ * through {@link Webhook.register}, so traffic to an unregistered channel is
222
+ * reported as unavailable rather than silently self-registering the door it
223
+ * arrived at.
224
+ *
225
+ * `ingest` also copies `body`, `headers`, and `idempotencyKey` before anything
226
+ * reads them. Verification, delivery fingerprinting, and decoding then all see
227
+ * one private snapshot, so a verifier that edits the bytes it was handed, or a
228
+ * caller that mutates its own object between building this Effect and running
229
+ * it, cannot authenticate one payload and have another decoded. A
230
+ * `SharedArrayBuffer`-backed view is copied out of shared memory by the same
231
+ * step.
232
+ *
233
+ * @category constructors
234
+ * @since 0.1.0
235
+ */
236
+ export const make = <Payload, Outbound = never>(
237
+ config: Config<Payload, Outbound>
238
+ ): Webhook => {
239
+ const channel = toControlChannel(config)
240
+ const register = Effect.flatMap(ControlChannels.Channels, (channels) => channels.register(channel))
241
+ return {
242
+ name: config.name,
243
+ register,
244
+ ingest: (raw) => {
245
+ // Copied here rather than inside the Effect: the copy has to happen when
246
+ // the caller hands the request over, not when the returned Effect
247
+ // eventually runs, or a caller that reuses its own buffer in between
248
+ // changes what gets authenticated.
249
+ const snapshot: Channel.RawInbound = {
250
+ body: ownedCopy(raw.body),
251
+ headers: { ...raw.headers },
252
+ idempotencyKey: raw.idempotencyKey
253
+ }
254
+ return Effect.gen(function*() {
255
+ const channels = yield* ControlChannels.Channels
256
+ return yield* channels.ingest({ channel: config.name, raw: snapshot })
257
+ }).pipe(
258
+ Effect.mapError((error) =>
259
+ error instanceof Unauthorized
260
+ ? new TriggerError({
261
+ code: "verification_failed",
262
+ message: `webhook ${config.name} did not verify the request`,
263
+ cause: error
264
+ })
265
+ : error
266
+ )
267
+ )
268
+ }
269
+ }
270
+ }
package/src/index.ts ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * `/triggers` — durable schedules and verified inbound channels.
3
+ *
4
+ * @since 0.1.0
5
+ */
6
+
7
+ /** @category errors @since 0.1.0 */
8
+ export * as TriggerError from "./TriggerError.ts"
9
+ /** @category schedules @since 0.1.0 */
10
+ export * as Cron from "./Cron.ts"
11
+ /** @category declarations @since 0.1.0 */
12
+ export * as Trigger from "./Trigger.ts"
13
+ /** @category declarations @since 0.1.0 */
14
+ export * as Schedule from "./Schedule.ts"
15
+ /** @category policies @since 0.1.0 */
16
+ export * as Overlap from "./Overlap.ts"
17
+ /** @category policies @since 0.1.0 */
18
+ export * as CatchUp from "./CatchUp.ts"
19
+ /** @category services @since 0.1.0 */
20
+ export * as TriggerStore from "./TriggerStore.ts"
21
+ /** @category services @since 0.1.0 */
22
+ export * as SqlTriggerStore from "./SqlTriggerStore.ts"
23
+ /** @category scheduling @since 0.1.0 */
24
+ export * as Scheduler from "./Scheduler.ts"
25
+ /** @category services @since 1.0.0-rc.0 */
26
+ export * as DispatchReader from "./DispatchReader.ts"
27
+ /** @category channels @since 0.1.0 */
28
+ export * as Channel from "./Channel.ts"
29
+ /** @category webhooks @since 0.1.0 */
30
+ export * as Webhook from "./Webhook.ts"
@@ -0,0 +1,83 @@
1
+ /**
2
+ * What one scheduler process knows about the run each trigger owns.
3
+ *
4
+ * @since 1.0.0-rc.0
5
+ */
6
+
7
+ import * as Effect from "effect/Effect"
8
+ import type * as Fiber from "effect/Fiber"
9
+ import * as Ref from "effect/Ref"
10
+
11
+ /**
12
+ * What this process knows about the run one trigger currently owns: which
13
+ * occurrence claimed it, the reservation or run id the store holds for it, and
14
+ * the monitor fiber when this process is the one watching it.
15
+ *
16
+ * `runId` is always known. A claim that hands out work names the reservation it
17
+ * wrote, and a recovered entry is read back from the store, so an entry with
18
+ * nothing to release cannot be constructed.
19
+ *
20
+ * @private
21
+ * @since 1.0.0-rc.0
22
+ */
23
+ export interface Active {
24
+ readonly occurrence: number
25
+ readonly runId: string
26
+ readonly fiber?: Fiber.Fiber<void> | undefined
27
+ }
28
+
29
+ /**
30
+ * The entries of one scheduler, keyed by trigger id.
31
+ *
32
+ * `take` is the only unfenced write: an occurrence that claimed the trigger,
33
+ * or a run recovered from the store, replaces whatever entry was there. Every
34
+ * later write is fenced on the occurrence that took the entry, so one guard
35
+ * states the rule once: a launch that has been superseded, or whose run
36
+ * already settled, no longer owns the entry and must not write to it. Spelling
37
+ * the fence out at each site is how the three copies of it drifted apart.
38
+ *
39
+ * @private
40
+ * @since 1.0.0-rc.0
41
+ */
42
+ export interface ActiveRuns {
43
+ readonly get: (triggerId: string) => Effect.Effect<Active | undefined>
44
+ readonly take: (triggerId: string, entry: Active) => Effect.Effect<void>
45
+ readonly update: (
46
+ triggerId: string,
47
+ occurrence: number,
48
+ change: (entry: Active) => Active
49
+ ) => Effect.Effect<void>
50
+ readonly remove: (triggerId: string, occurrence: number) => Effect.Effect<void>
51
+ }
52
+
53
+ /**
54
+ * Allocates an empty set of entries.
55
+ *
56
+ * @private
57
+ * @since 1.0.0-rc.0
58
+ */
59
+ export const make: Effect.Effect<ActiveRuns> = Effect.map(
60
+ Ref.make<ReadonlyMap<string, Active>>(new Map()),
61
+ (entries) => {
62
+ const fenced = (
63
+ triggerId: string,
64
+ occurrence: number,
65
+ change: (entry: Active) => Active | undefined
66
+ ): Effect.Effect<void> =>
67
+ Ref.update(entries, (current) => {
68
+ const entry = current.get(triggerId)
69
+ if (entry?.occurrence !== occurrence) return current
70
+ const next = new Map(current)
71
+ const updated = change(entry)
72
+ if (updated === undefined) next.delete(triggerId)
73
+ else next.set(triggerId, updated)
74
+ return next
75
+ })
76
+ return {
77
+ get: (triggerId) => Effect.map(Ref.get(entries), (current) => current.get(triggerId)),
78
+ take: (triggerId, entry) => Ref.update(entries, (current) => new Map(current).set(triggerId, entry)),
79
+ update: fenced,
80
+ remove: (triggerId, occurrence) => fenced(triggerId, occurrence, () => undefined)
81
+ }
82
+ }
83
+ )
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Which occurrences one scheduler tick owes a trigger.
3
+ *
4
+ * @since 1.0.0-rc.0
5
+ */
6
+
7
+ import * as Effect from "effect/Effect"
8
+ import * as CatchUp from "../CatchUp.ts"
9
+ import * as Cron from "../Cron.ts"
10
+ import type { TriggerError } from "../TriggerError.ts"
11
+ import type { Registered } from "../TriggerStore.ts"
12
+
13
+ /**
14
+ * The occurrences to claim, oldest first, and the watermark that stands once
15
+ * every one of them has been dispatched.
16
+ *
17
+ * @private
18
+ * @since 1.0.0-rc.0
19
+ */
20
+ export interface Due {
21
+ readonly occurrences: ReadonlyArray<number>
22
+ readonly watermark: number
23
+ }
24
+
25
+ // A bound the declaration cannot honour is a statement about how much history
26
+ // to replay, not a reason to stop scheduling: the backlog beyond the bound is
27
+ // abandoned, loudly, and the current occurrence still fires.
28
+ const withinBound = (
29
+ triggerId: string,
30
+ owed: Effect.Effect<ReadonlyArray<Date>, TriggerError>
31
+ ): Effect.Effect<ReadonlyArray<Date>, TriggerError> =>
32
+ Effect.catch(owed, (error) =>
33
+ error.code === "catch_up_bound_exceeded"
34
+ ? Effect.as(
35
+ Effect.annotateLogs(
36
+ Effect.logWarning("A trigger abandoned catch-up work beyond its bound", error),
37
+ { triggerId }
38
+ ),
39
+ [] as ReadonlyArray<Date>
40
+ )
41
+ : Effect.fail(error))
42
+
43
+ /**
44
+ * Computes what a trigger owes at `now`, given the watermark this process has
45
+ * already dispatched through, or `undefined` on first sight of the trigger.
46
+ *
47
+ * The answer depends on nothing but its arguments. The watermark is the
48
+ * caller's to keep: it only moves forward, and only past occurrences the
49
+ * caller finished dispatching.
50
+ *
51
+ * @private
52
+ * @since 1.0.0-rc.0
53
+ */
54
+ export const compute = (
55
+ trigger: Registered,
56
+ now: number,
57
+ observed: number | undefined
58
+ ): Effect.Effect<Due, TriggerError> =>
59
+ Effect.gen(function*() {
60
+ const cron = yield* Cron.parse(trigger.cron, trigger.timezone)
61
+ const current = (yield* Cron.previousAtOrBefore(cron, new Date(now))).getTime()
62
+ if (observed === undefined) {
63
+ // First sight of this trigger in this process. A trigger that has never
64
+ // fired starts from here rather than from whatever occurrence last
65
+ // passed: registering a weekly trigger on a Sunday evening owes nothing
66
+ // for the Monday six days gone, which is what `catchUp` says.
67
+ if (trigger.lastFiredAt === undefined) return { occurrences: [], watermark: current }
68
+ if (current <= trigger.lastFiredAt) return { occurrences: [], watermark: current }
69
+ const owed = yield* withinBound(
70
+ trigger.id,
71
+ CatchUp.occurrences(
72
+ trigger.catchUp,
73
+ trigger.maxCatchUp,
74
+ new Date(trigger.lastFiredAt),
75
+ new Date(now),
76
+ cron
77
+ )
78
+ )
79
+ // The current boundary is owed whatever the policy says about the
80
+ // backlog, exactly as on every later poll. Under `none`, or once the
81
+ // bound abandoned the backlog, a restart used to skip it.
82
+ const occurrences = owed.map((occurrence) => occurrence.getTime())
83
+ if (occurrences.at(-1) !== current) occurrences.push(current)
84
+ return { occurrences, watermark: current }
85
+ }
86
+ if (current <= observed) return { occurrences: [], watermark: observed }
87
+ const backlog = yield* withinBound(
88
+ trigger.id,
89
+ CatchUp.occurrences(
90
+ trigger.catchUp,
91
+ trigger.maxCatchUp,
92
+ new Date(observed),
93
+ new Date(current - 1),
94
+ cron
95
+ )
96
+ )
97
+ return {
98
+ occurrences: [...backlog.map((occurrence) => occurrence.getTime()), current],
99
+ watermark: current
100
+ }
101
+ })
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Wall-clock cron occurrences in a time zone, daylight saving included.
3
+ *
4
+ * A cron expression names wall-clock times. Searching those times with a UTC
5
+ * cron is exact, so the zone only matters when a wall time is turned into an
6
+ * instant:
7
+ *
8
+ * - A wall time that exists once is that instant.
9
+ * - A wall time inside a spring-forward gap fires at the instant it would have
10
+ * had on the old offset: a daily 02:30 fires at 03:30 daylight time.
11
+ * - A wall time repeated by fall back fires once, at its first instant. An
12
+ * expression whose hour field is every hour fires in both passes instead, so
13
+ * an hourly or minutely schedule keeps running through the repeated hour.
14
+ *
15
+ * Instants are whole seconds in UTC, so millisecond zeroing never reads the
16
+ * host's zone.
17
+ *
18
+ * @since 1.0.0-rc.1
19
+ */
20
+
21
+ import * as EffectCron from "effect/Cron"
22
+ import * as DateTime from "effect/DateTime"
23
+ import * as Option from "effect/Option"
24
+
25
+ const day = 86_400_000
26
+ const second = 1_000
27
+
28
+ /** The zone's wall clock minus UTC at an instant, in milliseconds. */
29
+ type Offset = (instant: number) => number
30
+
31
+ const formatters = new Map<string, Intl.DateTimeFormat>()
32
+
33
+ const formatter = (timeZone: string): Intl.DateTimeFormat => {
34
+ let format = formatters.get(timeZone)
35
+ if (format === undefined) {
36
+ format = new Intl.DateTimeFormat("en-US", {
37
+ timeZone,
38
+ year: "numeric",
39
+ month: "numeric",
40
+ day: "numeric",
41
+ hour: "numeric",
42
+ minute: "numeric",
43
+ second: "numeric",
44
+ hourCycle: "h23"
45
+ })
46
+ formatters.set(timeZone, format)
47
+ }
48
+ return format
49
+ }
50
+
51
+ const named = (timeZone: string): Offset => (instant) => {
52
+ const whole = Math.floor(instant / second) * second
53
+ const fields = { year: 0, month: 1, day: 1, hour: 0, minute: 0, second: 0 }
54
+ for (const part of formatter(timeZone).formatToParts(whole)) {
55
+ if (part.type in fields) fields[part.type as keyof typeof fields] = Number(part.value)
56
+ }
57
+ const wall = Date.UTC(fields.year, fields.month - 1, fields.day, fields.hour, fields.minute, fields.second)
58
+ return wall - whole
59
+ }
60
+
61
+ // Read on every call, so a host whose `TZ` changes is followed.
62
+ const host: Offset = (instant) => -new Date(instant).getTimezoneOffset() * 60_000
63
+
64
+ const offsetOf = (zone: Option.Option<DateTime.TimeZone>): Offset => {
65
+ if (Option.isNone(zone)) return host
66
+ const value = zone.value
67
+ if (DateTime.isTimeZoneOffset(value)) return () => value.offset
68
+ return named(value.id)
69
+ }
70
+
71
+ /**
72
+ * A cron compiled for wall-clock search.
73
+ *
74
+ * @private
75
+ * @since 1.0.0-rc.1
76
+ */
77
+ export interface WallCron {
78
+ readonly wall: EffectCron.Cron
79
+ readonly offset: Offset
80
+ readonly everyHour: boolean
81
+ }
82
+
83
+ const compiled = new WeakMap<EffectCron.Cron, WallCron>()
84
+
85
+ /**
86
+ * Compiles an Effect cron once: its fields as a UTC cron over wall-clock
87
+ * times, and its zone's offset.
88
+ *
89
+ * @private
90
+ * @since 1.0.0-rc.1
91
+ */
92
+ export const compile = (cron: EffectCron.Cron): WallCron => {
93
+ let wallCron = compiled.get(cron)
94
+ if (wallCron === undefined) {
95
+ wallCron = {
96
+ wall: EffectCron.make({
97
+ seconds: cron.seconds,
98
+ minutes: cron.minutes,
99
+ hours: cron.hours,
100
+ days: cron.days,
101
+ months: cron.months,
102
+ weekdays: cron.weekdays,
103
+ // Whether day of month and weekday must both match. Effect keeps the
104
+ // flag off its public type, but `make` takes it and every cron has it.
105
+ and: (cron as EffectCron.Cron & { readonly and: boolean }).and,
106
+ tz: DateTime.zoneMakeNamedUnsafe("UTC")
107
+ }),
108
+ offset: offsetOf(cron.tz),
109
+ everyHour: cron.hours.size === 0 || cron.hours.size === 24
110
+ }
111
+ compiled.set(cron, wallCron)
112
+ }
113
+ return wallCron
114
+ }
115
+
116
+ /** The offsets in force from a day before an instant to a day after it. */
117
+ const near = (cron: WallCron, instant: number): ReadonlyArray<number> => [
118
+ cron.offset(instant - day),
119
+ cron.offset(instant),
120
+ cron.offset(instant + day)
121
+ ]
122
+
123
+ /** The instants one matching wall time fires at, earliest first. */
124
+ const resolve = (cron: WallCron, wall: number): ReadonlyArray<number> => {
125
+ const before = cron.offset(wall - day)
126
+ const after = cron.offset(wall + day)
127
+ const candidates = before === after ? [before] : [before, after]
128
+ const valid = candidates.map((offset) => wall - offset).filter((instant) => cron.offset(instant) === wall - instant)
129
+ // No offset reads this wall time back: it is inside a gap.
130
+ if (valid.length === 0) return [wall - before]
131
+ valid.sort((left, right) => left - right)
132
+ return cron.everyHour ? valid : valid.slice(0, 1)
133
+ }
134
+
135
+ /**
136
+ * The first occurrence strictly after `from`. Throws when the wall-clock search
137
+ * is exhausted.
138
+ *
139
+ * @private
140
+ * @since 1.0.0-rc.1
141
+ */
142
+ export const next = (cron: WallCron, from: number): number => {
143
+ // Every wall time that can fire after `from` lies after `from` plus the
144
+ // smallest nearby offset.
145
+ let wall = from + Math.min(...near(cron, from))
146
+ let best = Number.POSITIVE_INFINITY
147
+ for (;;) {
148
+ wall = EffectCron.next(cron.wall, wall).getTime()
149
+ if (wall - Math.max(...near(cron, wall)) >= best) return best
150
+ for (const instant of resolve(cron, wall)) if (instant > from && instant < best) best = instant
151
+ }
152
+ }
153
+
154
+ /**
155
+ * The latest occurrence at or before `at`, in whole seconds. Throws when the
156
+ * wall-clock search is exhausted.
157
+ *
158
+ * @private
159
+ * @since 1.0.0-rc.1
160
+ */
161
+ export const previousAtOrBefore = (cron: WallCron, at: number): number => {
162
+ const until = Math.floor(at / second) * second
163
+ // Every wall time that can fire at or before `until` lies at or before
164
+ // `until` plus the largest nearby offset.
165
+ let wall = until + Math.max(...near(cron, until)) + second
166
+ let best = Number.NEGATIVE_INFINITY
167
+ for (;;) {
168
+ wall = EffectCron.prev(cron.wall, wall).getTime()
169
+ if (wall - Math.min(...near(cron, wall)) <= best) return best
170
+ for (const instant of resolve(cron, wall)) if (instant <= until && instant > best) best = instant
171
+ }
172
+ }