@smthrs/triggers 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 (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
@@ -0,0 +1,303 @@
1
+ /**
2
+ * The one claim decision both trigger stores apply.
3
+ *
4
+ * A claim is a pure function of the rows it reads: the trigger declaration,
5
+ * the fire row at the occurrence being claimed, and the fire row the active
6
+ * reservation holds. Each store gathers that snapshot in its own storage,
7
+ * calls {@link decide}, and applies the writes it answers. Writing the fences,
8
+ * the expired-reservation reclaim, and the supersede predecessor substitution
9
+ * once is what keeps `SqlTriggerStore` and the published in-memory store from
10
+ * drifting apart.
11
+ *
12
+ * @since 1.0.0-rc.0
13
+ */
14
+
15
+ import * as Overlap from "./Overlap.ts"
16
+ import type { Overlap as Policy } from "./Trigger.ts"
17
+ import { TriggerError } from "./TriggerError.ts"
18
+ import {
19
+ type Claim,
20
+ type ClaimFire,
21
+ isReservation,
22
+ type Outcome,
23
+ reservationLeaseMs,
24
+ reservationOccurrence
25
+ } from "./TriggerStore.ts"
26
+
27
+ /**
28
+ * A fire row as the decision reads it. `outcome` is `null` for a claimed but
29
+ * unsettled occurrence, matching the nullable column.
30
+ *
31
+ * @category models
32
+ * @since 1.0.0-rc.0
33
+ */
34
+ export interface Fire {
35
+ readonly outcome: Outcome | null
36
+ readonly runId?: string | undefined
37
+ }
38
+
39
+ /**
40
+ * Everything the decision reads, gathered by the store before it runs.
41
+ *
42
+ * `existingFire` is absent when this claim is what created the row.
43
+ * `activeFire` is the row at `reservationOccurrence(activeRunId)`, read after
44
+ * that insert, and is needed only while `activeRunId` is a reservation.
45
+ *
46
+ * @category models
47
+ * @since 1.0.0-rc.0
48
+ */
49
+ export interface Snapshot {
50
+ readonly revision: number
51
+ readonly enabled: boolean
52
+ readonly overlap: Policy
53
+ readonly activeRunId?: string | undefined
54
+ readonly activeClaimedAt?: number | undefined
55
+ readonly pending?: number | undefined
56
+ readonly existingFire?: Fire | undefined
57
+ readonly activeFire?: Fire | undefined
58
+ }
59
+
60
+ /**
61
+ * One durable edit a claim owes, in the order the store must apply them.
62
+ *
63
+ * `SetOutcome` with `whileOpen` may only settle a fire that is still `null` or
64
+ * `buffered`; the SQL store spells that as a `WHERE` clause.
65
+ * `ReleaseReservation` may only land while `expected` still owns the trigger.
66
+ *
67
+ * @category models
68
+ * @since 1.0.0-rc.0
69
+ */
70
+ export type Write =
71
+ | {
72
+ readonly _tag: "SetOutcome"
73
+ readonly occurrence: number
74
+ readonly outcome: Outcome
75
+ readonly whileOpen: boolean
76
+ }
77
+ | { readonly _tag: "SetRunId"; readonly occurrence: number; readonly runId: string }
78
+ | {
79
+ readonly _tag: "ReleaseReservation"
80
+ readonly expected: string
81
+ readonly activeRunId?: string | undefined
82
+ readonly pending?: number | undefined
83
+ }
84
+ | { readonly _tag: "AdvanceCursor"; readonly occurrence: number }
85
+ | { readonly _tag: "SetPending"; readonly occurrence: number }
86
+ | { readonly _tag: "Reserve"; readonly reservationId: string; readonly claimedAt: number }
87
+
88
+ /**
89
+ * What a claim decided: a refusal the store fails with, or a claim outcome and
90
+ * the writes that make it durable. An unclaimed occurrence answers
91
+ * `{ claimed: false }` with no writes.
92
+ *
93
+ * @category models
94
+ * @since 1.0.0-rc.0
95
+ */
96
+ export type Decision =
97
+ | { readonly _tag: "Refused"; readonly error: TriggerError }
98
+ | { readonly _tag: "Decided"; readonly claim: Claim; readonly writes: ReadonlyArray<Write> }
99
+
100
+ /**
101
+ * The claim request, the rows it reads, the reservation id the store minted
102
+ * for it, and the store clock reading it was claimed at.
103
+ *
104
+ * @category models
105
+ * @since 1.0.0-rc.0
106
+ */
107
+ export interface Input {
108
+ readonly fire: ClaimFire
109
+ readonly snapshot: Snapshot
110
+ readonly reservationId: string
111
+ readonly claimedAt: number
112
+ }
113
+
114
+ type AdmittedDecision = Extract<Decision, { readonly _tag: "Decided" }>
115
+
116
+ const decided = (claim: Claim, writes: ReadonlyArray<Write>): AdmittedDecision => ({ _tag: "Decided", claim, writes })
117
+
118
+ /**
119
+ * What a reader finds behind a trigger's active owner: nothing, a live owner,
120
+ * or an expired reservation to release.
121
+ *
122
+ * `expected` is the reservation the release must still find in place.
123
+ * `recovered` is the launched predecessor the release hands back, and
124
+ * `pending` is the occurrence it re-arms; both are absent when the reservation
125
+ * covered no unsettled fire.
126
+ *
127
+ * @category models
128
+ * @since 1.0.0-rc.0
129
+ */
130
+ export type Lease =
131
+ | { readonly _tag: "Idle" }
132
+ | { readonly _tag: "Held"; readonly runId: string }
133
+ | {
134
+ readonly _tag: "Expired"
135
+ readonly expected: string
136
+ readonly recovered?: string | undefined
137
+ readonly pending?: number | undefined
138
+ }
139
+
140
+ /**
141
+ * Applies the reservation lease to a trigger's active owner.
142
+ *
143
+ * `unfinished` is the fire row at `reservationOccurrence(activeRunId)` while
144
+ * that row is still `null` or `buffered`, and is absent otherwise. A process
145
+ * that died after claiming an occurrence but before launching it left the
146
+ * reservation; the expired lease releases it and re-arms both ordinary and
147
+ * buffered work.
148
+ *
149
+ * @category decision
150
+ * @since 1.0.0-rc.0
151
+ */
152
+ export const lease = (input: {
153
+ readonly activeRunId?: string | undefined
154
+ readonly activeClaimedAt?: number | undefined
155
+ readonly pending?: number | undefined
156
+ readonly now: number
157
+ readonly unfinished?: Fire | undefined
158
+ }): Lease => {
159
+ const { activeClaimedAt, activeRunId, now, pending, unfinished } = input
160
+ if (activeRunId === undefined) return { _tag: "Idle" }
161
+ if (!isReservation(activeRunId) || (activeClaimedAt !== undefined && activeClaimedAt > now - reservationLeaseMs)) {
162
+ return { _tag: "Held", runId: activeRunId }
163
+ }
164
+ const occurrence = reservationOccurrence(activeRunId)
165
+ if (occurrence === undefined || unfinished === undefined) return { _tag: "Expired", expected: activeRunId }
166
+ const predecessor = unfinished.runId
167
+ return {
168
+ _tag: "Expired",
169
+ expected: activeRunId,
170
+ pending: Overlap.pendingAfter({ running: false, pending, due: occurrence }),
171
+ ...(predecessor !== undefined && !isReservation(predecessor) ? { recovered: predecessor } : {})
172
+ }
173
+ }
174
+
175
+ /**
176
+ * The refusals a claim owes before it reads any fire row, in the order the
177
+ * stores apply them. A store checks these first so a refused claim leaves the
178
+ * ledger untouched; {@link decide} re-applies them so a store that only calls
179
+ * it is still fenced.
180
+ *
181
+ * @category decision
182
+ * @since 1.0.0-rc.0
183
+ */
184
+ export const refuse = (
185
+ snapshot: Pick<Snapshot, "enabled" | "revision">,
186
+ fire: ClaimFire
187
+ ): TriggerError | undefined => {
188
+ if (snapshot.revision !== fire.expectedRevision) {
189
+ return new TriggerError({
190
+ code: "revision_mismatch",
191
+ message: `trigger ${fire.triggerId} is at revision ${snapshot.revision}, not the claimed ${fire.expectedRevision}`
192
+ })
193
+ }
194
+ if (!snapshot.enabled) {
195
+ return new TriggerError({ code: "trigger_disabled", message: `trigger ${fire.triggerId} is disabled` })
196
+ }
197
+ return undefined
198
+ }
199
+
200
+ /**
201
+ * Applies the claim protocol to a snapshot.
202
+ *
203
+ * @category decision
204
+ * @since 1.0.0-rc.0
205
+ */
206
+ export const decide = (input: Input): Decision => {
207
+ const refusal = refuse(input.snapshot, input.fire)
208
+ return refusal === undefined ? decideFenced(input) : { _tag: "Refused", error: refusal }
209
+ }
210
+
211
+ /**
212
+ * Decides over a snapshot already admitted by {@link refuse} in the same
213
+ * transaction. This avoids checking the same immutable revision twice.
214
+ * @category decision
215
+ * @since 1.0.0-rc.0
216
+ * @private
217
+ */
218
+ export const decideFenced = ({ claimedAt, fire, reservationId, snapshot }: Input): AdmittedDecision => {
219
+ let activeRunId = snapshot.activeRunId
220
+ let pending = snapshot.pending
221
+ // A reservation with no claim timestamp predates the lease column. Nothing
222
+ // writes that shape now, so treating it as expired is the only way such a
223
+ // row is ever reclaimed.
224
+ const expiredReservation = activeRunId !== undefined && isReservation(activeRunId) &&
225
+ (snapshot.activeClaimedAt === undefined || snapshot.activeClaimedAt <= claimedAt - reservationLeaseMs)
226
+ ? activeRunId
227
+ : undefined
228
+ const existing = snapshot.existingFire
229
+ if (existing !== undefined) {
230
+ const resumableBuffer = fire.resumeBuffered === true && existing.outcome === "buffered"
231
+ const resumableReservation = existing.outcome === null &&
232
+ (activeRunId === undefined ||
233
+ (reservationOccurrence(activeRunId) === fire.occurrence && expiredReservation !== undefined))
234
+ const resumableSupersede = fire.resumeBuffered === true && existing.outcome === null &&
235
+ snapshot.overlap === "supersede" && activeRunId !== undefined && existing.runId === activeRunId
236
+ if (!resumableBuffer && !resumableReservation && !resumableSupersede) return decided({ claimed: false }, [])
237
+ }
238
+ const writes: Array<Write> = []
239
+ if (expiredReservation !== undefined) {
240
+ const expiredOccurrence = reservationOccurrence(expiredReservation)
241
+ const expiredFire = expiredOccurrence === undefined ? undefined : snapshot.activeFire
242
+ if (
243
+ expiredOccurrence !== undefined && expiredFire !== undefined &&
244
+ (expiredFire.outcome === null || expiredFire.outcome === "buffered")
245
+ ) {
246
+ if (snapshot.overlap === "supersede") {
247
+ const predecessor = expiredFire.runId
248
+ activeRunId = predecessor !== undefined && !isReservation(predecessor) ? predecessor : undefined
249
+ if (expiredOccurrence !== fire.occurrence) {
250
+ writes.push({ _tag: "SetOutcome", occurrence: expiredOccurrence, outcome: "superseded", whileOpen: false })
251
+ }
252
+ } else {
253
+ activeRunId = undefined
254
+ if (expiredOccurrence !== fire.occurrence) {
255
+ pending = Overlap.pendingAfter({ running: false, pending, due: expiredOccurrence })
256
+ }
257
+ }
258
+ } else {
259
+ activeRunId = undefined
260
+ }
261
+ writes.push({ _tag: "ReleaseReservation", expected: expiredReservation, activeRunId, pending })
262
+ }
263
+ const state: Overlap.State = { running: activeRunId !== undefined, pending, due: fire.occurrence }
264
+ const action = Overlap.decide(snapshot.overlap, state)
265
+ // A skip or buffer is complete inside the claim transaction. A fire or
266
+ // supersede is only reserved here; its cursor advances when `recordResult`
267
+ // makes the launched run durable.
268
+ if (action === "skip" || action === "buffer") {
269
+ writes.push({
270
+ _tag: "SetOutcome",
271
+ occurrence: fire.occurrence,
272
+ outcome: action === "skip" ? "skipped" : "buffered",
273
+ whileOpen: false
274
+ })
275
+ writes.push({ _tag: "AdvanceCursor", occurrence: fire.occurrence })
276
+ if (action === "buffer") writes.push({ _tag: "SetPending", occurrence: Overlap.pendingAfter(state) })
277
+ return decided({ claimed: true, action }, writes)
278
+ }
279
+ let supersededRunId = activeRunId
280
+ if (action === "supersede" && activeRunId !== undefined) {
281
+ if (isReservation(activeRunId)) {
282
+ const activeOccurrence = reservationOccurrence(activeRunId)
283
+ if (activeOccurrence !== undefined) {
284
+ const predecessor = snapshot.activeFire?.runId
285
+ if (predecessor !== undefined && !isReservation(predecessor)) supersededRunId = predecessor
286
+ writes.push({ _tag: "SetOutcome", occurrence: activeOccurrence, outcome: "superseded", whileOpen: true })
287
+ }
288
+ }
289
+ if (supersededRunId !== undefined && !isReservation(supersededRunId)) {
290
+ writes.push({ _tag: "SetRunId", occurrence: fire.occurrence, runId: supersededRunId })
291
+ }
292
+ }
293
+ writes.push({ _tag: "Reserve", reservationId, claimedAt })
294
+ return decided(
295
+ {
296
+ claimed: true,
297
+ action,
298
+ reservationId,
299
+ ...(supersededRunId === undefined ? {} : { activeRunId: supersededRunId })
300
+ },
301
+ writes
302
+ )
303
+ }
package/src/Cron.ts ADDED
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Typed wrappers around Effect Cron.
3
+ *
4
+ * Effect's field parser only range-checks each field, so an expression like
5
+ * `0 0 30 2 *` parses and then never matches a calendar date. Its occurrence
6
+ * search answers that by exhausting its bound and throwing, which inside
7
+ * `Effect.gen` is a defect rather than a typed failure: the fiber that owns the
8
+ * search dies instead of reporting. Every search here is wrapped so the
9
+ * exhausted bound arrives as `unsatisfiable_cron`, and {@link parse} probes for
10
+ * one occurrence so an unsatisfiable expression is refused where it is
11
+ * declared rather than at the tick that would have fired it.
12
+ *
13
+ * A satisfiable expression carries the opposite hazard: `* * * * *` across a
14
+ * year is 525,600 occurrences, and materializing them costs seconds and
15
+ * hundreds of megabytes. {@link occurrencesBetween} therefore always searches
16
+ * under a finite cap and reports when an unstated limit would exceed
17
+ * {@link maxOccurrences}.
18
+ *
19
+ * A timezone makes the expression a wall-clock schedule. A wall time that
20
+ * spring forward skips fires at the instant it would have had on the old
21
+ * offset, so a daily 02:30 fires at 03:30 daylight time. A wall time that fall
22
+ * back repeats fires once, at its first instant, unless the hour field is every
23
+ * hour, in which case both passes fire. Occurrences are whole seconds in UTC,
24
+ * whatever the host's zone.
25
+ *
26
+ * @since 0.1.0
27
+ */
28
+
29
+ import * as Clock from "effect/Clock"
30
+ import * as EffectCron from "effect/Cron"
31
+ import * as Effect from "effect/Effect"
32
+ import * as Result from "effect/Result"
33
+ import * as WallClock from "./internal/WallClock.ts"
34
+ import { TriggerError } from "./TriggerError.ts"
35
+
36
+ /**
37
+ * A parsed cron expression, kept beside the text it came from so the
38
+ * declaration can be round-tripped.
39
+ *
40
+ * @category models
41
+ * @since 0.1.0
42
+ */
43
+ export interface Cron {
44
+ readonly expression: string
45
+ readonly timezone?: string | undefined
46
+ readonly value: EffectCron.Cron
47
+ }
48
+
49
+ const exhausted = (cron: Cron, search: string, cause: unknown): TriggerError =>
50
+ new TriggerError({
51
+ code: "unsatisfiable_cron",
52
+ message: `cron expression '${cron.expression}' has no ${search} occurrence`,
53
+ cause
54
+ })
55
+
56
+ const attempt = <A>(cron: Cron, search: string, find: () => A): Effect.Effect<A, TriggerError> =>
57
+ Effect.try({ try: find, catch: (cause) => exhausted(cron, search, cause) })
58
+
59
+ /**
60
+ * The first occurrence strictly after `from`.
61
+ *
62
+ * @category getters
63
+ * @since 0.1.0
64
+ */
65
+ export const next = (cron: Cron, from: Date): Effect.Effect<Date, TriggerError> =>
66
+ attempt(cron, "next", () => new Date(WallClock.next(WallClock.compile(cron.value), from.getTime())))
67
+
68
+ /**
69
+ * Returns the latest occurrence at or before `at`.
70
+ *
71
+ * @category getters
72
+ * @since 0.1.0
73
+ */
74
+ export const previousAtOrBefore = (cron: Cron, at: Date): Effect.Effect<Date, TriggerError> =>
75
+ attempt(
76
+ cron,
77
+ "previous",
78
+ () => new Date(WallClock.previousAtOrBefore(WallClock.compile(cron.value), at.getTime()))
79
+ )
80
+
81
+ /**
82
+ * The greatest number of occurrences one search returns when its caller states
83
+ * no limit of its own.
84
+ *
85
+ * @category constants
86
+ * @since 0.1.0
87
+ */
88
+ export const maxOccurrences = 1000
89
+
90
+ /**
91
+ * The occurrences in `(from, to]`, in order.
92
+ *
93
+ * A stated `limit` caps the result silently and must be a non-negative safe
94
+ * integer. With no stated limit, the search fails with
95
+ * `catch_up_bound_exceeded` when the interval holds more than
96
+ * {@link maxOccurrences}. A caller with its own bound can pass one more than
97
+ * that bound and compare the length, the way `CatchUp.occurrences` does.
98
+ *
99
+ * @category sequencing
100
+ * @since 0.1.0
101
+ */
102
+ export const occurrencesBetween = (
103
+ cron: Cron,
104
+ from: Date,
105
+ to: Date,
106
+ limit?: number | undefined
107
+ ): Effect.Effect<ReadonlyArray<Date>, TriggerError> => {
108
+ if (limit !== undefined && (!Number.isSafeInteger(limit) || limit < 0)) {
109
+ return Effect.fail(
110
+ new TriggerError({
111
+ code: "invalid_options",
112
+ message: `occurrence limit must be a non-negative safe integer, received ${limit}`,
113
+ path: "limit"
114
+ })
115
+ )
116
+ }
117
+ const searchLimit = limit ?? maxOccurrences + 1
118
+ if (searchLimit === 0) return Effect.succeed([])
119
+ const search = attempt(cron, "interval", () => {
120
+ const wall = WallClock.compile(cron.value)
121
+ const occurrences: Array<Date> = []
122
+ let cursor = from.getTime()
123
+ while (occurrences.length < searchLimit) {
124
+ cursor = WallClock.next(wall, cursor)
125
+ if (cursor > to.getTime()) break
126
+ occurrences.push(new Date(cursor))
127
+ }
128
+ return occurrences
129
+ })
130
+ if (limit !== undefined) return search
131
+ return search.pipe(
132
+ Effect.flatMap((occurrences) =>
133
+ occurrences.length > maxOccurrences
134
+ ? Effect.fail(
135
+ new TriggerError({
136
+ code: "catch_up_bound_exceeded",
137
+ message: `interval contains more than ${maxOccurrences} occurrences; maxOccurrences is ${maxOccurrences}`
138
+ })
139
+ )
140
+ : Effect.succeed(occurrences)
141
+ )
142
+ )
143
+ }
144
+
145
+ /**
146
+ * Parses a cron expression in an optional timezone, reporting a malformed one
147
+ * as `invalid_cron` and one the calendar never satisfies as
148
+ * `unsatisfiable_cron`.
149
+ *
150
+ * The satisfiability probe searches forward from the current instant, which is
151
+ * the same search every tick performs, so an expression that survives parsing
152
+ * is one the scheduler can keep answering.
153
+ *
154
+ * @category constructors
155
+ * @since 0.1.0
156
+ */
157
+ export const parse = (expression: string, timezone?: string): Effect.Effect<Cron, TriggerError> =>
158
+ Effect.gen(function*() {
159
+ const parsed = EffectCron.parse(expression, timezone)
160
+ if (Result.isFailure(parsed)) {
161
+ return yield* Effect.fail(
162
+ new TriggerError({ code: "invalid_cron", message: parsed.failure.message, cause: parsed.failure })
163
+ )
164
+ }
165
+ const cron: Cron = {
166
+ expression,
167
+ ...(timezone === undefined ? {} : { timezone }),
168
+ value: parsed.success
169
+ }
170
+ const now = yield* Clock.currentTimeMillis
171
+ yield* next(cron, new Date(now))
172
+ return cron
173
+ })
@@ -0,0 +1,179 @@
1
+ /**
2
+ * The `@smthrs/control` `DispatchReader` port served from a `TriggerStore`.
3
+ *
4
+ * `Control.list` answers `{ _tag: "triggers" }` and `{ _tag: "fires" }` through
5
+ * the port declared in `@smthrs/control/DispatchReader`. The port lives there
6
+ * because this package depends on control (the scheduler launches runs through
7
+ * it); the adapter lives here because only this package can read the store.
8
+ * The host composes the two: {@link layer} over the same store the scheduler
9
+ * writes.
10
+ *
11
+ * @see packages/smithers/agent/triggers/docs/api.md
12
+ *
13
+ * @since 1.0.0-rc.0
14
+ */
15
+
16
+ import type { ControlError } from "@smthrs/control/ControlError"
17
+ import { PersistenceError } from "@smthrs/control/ControlError"
18
+ import { defaultPageSize, type FireSummary, type TriggerSummary } from "@smthrs/control/ControlSchema"
19
+ import * as Port from "@smthrs/control/DispatchReader"
20
+ import * as Clock from "effect/Clock"
21
+ import * as Effect from "effect/Effect"
22
+ import * as Layer from "effect/Layer"
23
+ import * as Option from "effect/Option"
24
+ import * as Result from "effect/Result"
25
+ import * as Cron from "./Cron.ts"
26
+ import type { TriggerError } from "./TriggerError.ts"
27
+ import { type FireRecord, type Held, isReservation, type Registered, TriggerStore } from "./TriggerStore.ts"
28
+
29
+ /**
30
+ * How many upcoming occurrences a trigger summary carries.
31
+ *
32
+ * @category constants
33
+ * @since 1.0.0-rc.0
34
+ */
35
+ export const nextOccurrenceCount = 5
36
+
37
+ /**
38
+ * How many ledger rows one `fires` page needs: every row before the page's
39
+ * offset cursor, the page, and one more so `Control.list` can tell a next page
40
+ * exists. A cursor Control will refuse reads nothing extra: it answers
41
+ * `undefined`, and the refusal comes from Control.
42
+ */
43
+ const firesNeeded = (request: Port.FiresRequest): number | undefined => {
44
+ const start = request.cursor === undefined ? 0 : Number(request.cursor)
45
+ const size = request.limit ?? defaultPageSize
46
+ return Number.isSafeInteger(start) && start >= 0 && Number.isSafeInteger(size) && size >= 1
47
+ ? start + size + 1
48
+ : undefined
49
+ }
50
+
51
+ const persistence = (operation: string) => (error: TriggerError): ControlError =>
52
+ new PersistenceError({ operation, message: error.message, cause: error })
53
+
54
+ /**
55
+ * The next {@link nextOccurrenceCount} occurrences strictly after `now`, as
56
+ * epoch milliseconds in ascending order.
57
+ *
58
+ * @category getters
59
+ * @since 1.0.0-rc.0
60
+ */
61
+ export const nextOccurrences = (
62
+ trigger: Pick<Registered, "cron" | "timezone">,
63
+ now: number
64
+ ): Effect.Effect<ReadonlyArray<number>, TriggerError> =>
65
+ Effect.gen(function*() {
66
+ const cron = yield* Cron.parse(trigger.cron, trigger.timezone)
67
+ const occurrences: Array<number> = []
68
+ let from = new Date(now)
69
+ while (occurrences.length < nextOccurrenceCount) {
70
+ from = yield* Cron.next(cron, from)
71
+ occurrences.push(from.getTime())
72
+ }
73
+ return occurrences
74
+ })
75
+
76
+ /**
77
+ * Maps one stored trigger and what it holds to the control plane's summary.
78
+ *
79
+ * A launch reservation is not a run the runtime knows about, so it is
80
+ * reported as no active run; the ledger still shows the claimed occurrence.
81
+ *
82
+ * @category constructors
83
+ * @since 1.0.0-rc.0
84
+ */
85
+ export const toTriggerSummary = (
86
+ trigger: Registered,
87
+ held: Held,
88
+ nextOccurrencesMs: ReadonlyArray<number>,
89
+ schedulerLastTickMs: Option.Option<number>
90
+ ): TriggerSummary => ({
91
+ triggerId: trigger.id,
92
+ flowId: trigger.flowId,
93
+ input: trigger.input,
94
+ cron: trigger.cron,
95
+ ...(trigger.timezone === undefined ? {} : { timezone: trigger.timezone }),
96
+ overlap: trigger.overlap,
97
+ catchUp: trigger.catchUp,
98
+ maxCatchUp: trigger.maxCatchUp,
99
+ enabled: trigger.enabled,
100
+ revision: trigger.revision,
101
+ ...(trigger.lastFiredAt === undefined ? {} : { lastFiredAtMs: trigger.lastFiredAt }),
102
+ ...(held.pendingAt === undefined ? {} : { pendingAtMs: held.pendingAt }),
103
+ ...(held.activeRunId === undefined || isReservation(held.activeRunId) ? {} : { activeRunId: held.activeRunId }),
104
+ nextOccurrencesMs,
105
+ ...(Option.isSome(schedulerLastTickMs) ? { schedulerLastTickMs: schedulerLastTickMs.value } : {})
106
+ })
107
+
108
+ /**
109
+ * Maps one ledger row to the control plane's fire summary.
110
+ *
111
+ * @category constructors
112
+ * @since 1.0.0-rc.0
113
+ */
114
+ export const toFireSummary = (record: FireRecord): FireSummary => ({
115
+ triggerId: record.triggerId,
116
+ occurrenceAtMs: record.occurrence,
117
+ outcome: record.outcome,
118
+ ...(record.runId === undefined ? {} : { runId: record.runId }),
119
+ ...(record.error === undefined ? {} : { error: record.error })
120
+ })
121
+
122
+ /**
123
+ * Builds the port over the ambient {@link TriggerStore}.
124
+ *
125
+ * `list` answers every trigger the store holds, taking each row's held state
126
+ * from the listing itself; `fires` pushes the request's filters into the
127
+ * ledger query and answers the matching rows newest first, reading only as
128
+ * many as the requested page needs.
129
+ * `Control.list` applies the filters again and pages. A store failure is a
130
+ * `PersistenceError` naming the listing that failed.
131
+ *
132
+ * @category constructors
133
+ * @since 1.0.0-rc.0
134
+ */
135
+ export const make: Effect.Effect<Port.Service, never, TriggerStore> = Effect.gen(function*() {
136
+ const store = yield* TriggerStore
137
+ return Port.make({
138
+ list: Effect.fn("DispatchReader.list")(() =>
139
+ Effect.gen(function*() {
140
+ const now = yield* Clock.currentTimeMillis
141
+ const heartbeat = yield* store.lastHeartbeat()
142
+ const lastTick = Option.map(heartbeat, (beat) => beat.tickedAt)
143
+ const listing = yield* store.list()
144
+ return yield* Effect.forEach(listing, (row) =>
145
+ Effect.gen(function*() {
146
+ // A row the store could not decode fails the listing rather than
147
+ // being reported as a trigger with invented fields; the failure
148
+ // names the row. The listing already carries what `inspect` reads,
149
+ // so a summary costs no second read per trigger.
150
+ if (Result.isFailure(row.trigger)) return yield* Effect.fail(row.trigger.failure)
151
+ const trigger = row.trigger.success
152
+ const upcoming = yield* nextOccurrences(trigger, now)
153
+ return toTriggerSummary(trigger, row, upcoming, lastTick)
154
+ }))
155
+ }).pipe(Effect.mapError(persistence("triggers")))
156
+ ),
157
+ fires: Effect.fn("DispatchReader.fires")((request) =>
158
+ store.history({
159
+ triggerId: request.filters?.triggerId,
160
+ runId: request.filters?.runId,
161
+ outcome: request.filters?.outcome,
162
+ limit: firesNeeded(request)
163
+ }).pipe(
164
+ Effect.map((page) => page.items.map(toFireSummary)),
165
+ Effect.mapError(persistence("fires"))
166
+ )
167
+ )
168
+ })
169
+ })
170
+
171
+ /**
172
+ * Provides the control plane's `DispatchReader` from the ambient
173
+ * {@link TriggerStore}, so `Control.list` answers `triggers` and `fires` from
174
+ * the store the scheduler writes.
175
+ *
176
+ * @category layers
177
+ * @since 1.0.0-rc.0
178
+ */
179
+ export const layer: Layer.Layer<Port.DispatchReader, never, TriggerStore> = Layer.effect(Port.DispatchReader)(make)