@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
@@ -0,0 +1,514 @@
1
+ /**
2
+ * Durable trigger declaration and fire store.
3
+ *
4
+ * @see packages/smithers/agent/triggers/docs/api.md
5
+ *
6
+ * @since 0.1.0
7
+ */
8
+
9
+ import * as Context from "effect/Context"
10
+ import * as Effect from "effect/Effect"
11
+ import * as Layer from "effect/Layer"
12
+ import type * as Option from "effect/Option"
13
+ // `Result` already names the reported end of an occurrence in this module, so
14
+ // the `effect/Result` a listed row decodes to is imported as `Decode`.
15
+ import * as Decode from "effect/Result"
16
+ import type { Action } from "./Overlap.ts"
17
+ import type { Trigger } from "./Trigger.ts"
18
+ import { TriggerError } from "./TriggerError.ts"
19
+
20
+ /**
21
+ * A stored trigger, with the revision that fences concurrent edits and
22
+ * when it last fired.
23
+ *
24
+ * @category models
25
+ * @since 0.1.0
26
+ */
27
+ export interface Registered extends Trigger {
28
+ readonly revision: number
29
+ readonly lastFiredAt?: number | undefined
30
+ }
31
+ /**
32
+ * One scheduled occurrence of a trigger, addressed by its occurrence
33
+ * number so a retry cannot fire it twice.
34
+ *
35
+ * @category models
36
+ * @since 0.1.0
37
+ */
38
+ export interface Fire {
39
+ readonly triggerId: string
40
+ readonly occurrence: number
41
+ }
42
+ /**
43
+ * A {@link Fire} together with the revision it was computed from and whether
44
+ * it is resuming a buffered occurrence.
45
+ *
46
+ * The overlap policy is deliberately absent. A claim applies the policy stored
47
+ * on the trigger row, read inside the same transaction, so a caller holding a
48
+ * stale snapshot cannot fire a trigger that has since been disabled, cannot
49
+ * point it at a different flow, and cannot supersede a run that the stored
50
+ * declaration says to leave alone. `expectedRevision` is the fence: a claim
51
+ * whose revision no longer matches the row is refused with `revision_mismatch`
52
+ * so the caller re-reads before deciding again.
53
+ *
54
+ * @category models
55
+ * @since 0.1.0
56
+ */
57
+ export interface ClaimFire extends Fire {
58
+ readonly expectedRevision: number
59
+ readonly resumeBuffered?: boolean | undefined
60
+ }
61
+ /**
62
+ * The outcome of claiming an occurrence: either another worker holds it, or
63
+ * this caller does and must take `action`.
64
+ *
65
+ * A claim that hands the caller work to launch always names the reservation it
66
+ * wrote against the trigger row, so the caller has an id to release. A claim
67
+ * that only records a decision has no reservation and names none: the two
68
+ * shapes are separate so a caller cannot read a reservation id that was never
69
+ * written.
70
+ *
71
+ * @category models
72
+ * @since 0.1.0
73
+ */
74
+ export type Claim =
75
+ | { readonly claimed: false }
76
+ | { readonly claimed: true; readonly action: Extract<Action, "skip" | "buffer"> }
77
+ | {
78
+ readonly claimed: true
79
+ readonly action: Extract<Action, "fire" | "supersede">
80
+ readonly reservationId: string
81
+ readonly activeRunId?: string | undefined
82
+ }
83
+
84
+ /**
85
+ * Time after which an uncommitted launch reservation may be reclaimed.
86
+ *
87
+ * Both store implementations use this value so swapping the test store for
88
+ * the SQL store cannot change recovery timing.
89
+ *
90
+ * @category constants
91
+ * @since 0.1.0
92
+ */
93
+ export const reservationLeaseMs = 5 * 60 * 1000
94
+
95
+ /**
96
+ * The prefix marking an `active_run_id` that is a launch reservation rather
97
+ * than a run the runtime knows about.
98
+ *
99
+ * A reservation is written by the claim and replaced by the real run id once
100
+ * the launch reports one, so both stores and the scheduler read this prefix.
101
+ * It is a contract between them and lives here rather than being re-spelled at
102
+ * each site.
103
+ *
104
+ * @category constants
105
+ * @since 0.1.0
106
+ */
107
+ export const reservationPrefix = "trigger-reservation:"
108
+
109
+ /**
110
+ * The reservation id one occurrence of one trigger claims.
111
+ *
112
+ * @category constructors
113
+ * @since 0.1.0
114
+ */
115
+ export const reservationId = (triggerId: string, occurrence: number, attempt?: string): string =>
116
+ `${reservationPrefix}${triggerId}:${attempt === undefined ? "" : `${attempt}:`}${occurrence}`
117
+
118
+ /**
119
+ * Whether a stored `active_run_id` is a launch reservation.
120
+ *
121
+ * @category predicates
122
+ * @since 0.1.0
123
+ */
124
+ export const isReservation = (runId: string | undefined): boolean =>
125
+ runId !== undefined && runId.startsWith(reservationPrefix)
126
+
127
+ /**
128
+ * Reads the occurrence encoded in a launch reservation.
129
+ *
130
+ * @category getters
131
+ * @since 0.1.0
132
+ */
133
+ export const reservationOccurrence = (runId: string): number | undefined => {
134
+ if (!isReservation(runId)) return undefined
135
+ const occurrence = Number(runId.slice(runId.lastIndexOf(":") + 1))
136
+ return Number.isFinite(occurrence) ? occurrence : undefined
137
+ }
138
+
139
+ /**
140
+ * How a claimed occurrence ended.
141
+ *
142
+ * @category models
143
+ * @since 0.1.0
144
+ */
145
+ export type Outcome = "launched" | "completed" | "skipped" | "buffered" | "superseded" | "failed"
146
+ /**
147
+ * The reported end of one occurrence, with the run it started when it
148
+ * launched one.
149
+ *
150
+ * @category models
151
+ * @since 0.1.0
152
+ */
153
+ export type Result =
154
+ & Fire
155
+ & { readonly error?: string | undefined }
156
+ & (
157
+ | { readonly outcome: "launched"; readonly runId: string; readonly reservationId: string }
158
+ | {
159
+ readonly outcome: Exclude<Outcome, "launched">
160
+ readonly runId?: string | undefined
161
+ readonly reservationId?: string | undefined
162
+ }
163
+ )
164
+
165
+ /**
166
+ * Refuses results that no longer own a permissible fire transition.
167
+ * Launched run ids are validated at runtime for adapters outside TypeScript.
168
+ *
169
+ * @category predicates
170
+ * @since 1.0.0-rc.0
171
+ */
172
+ export const resultRefusal = (
173
+ result: Result,
174
+ fire: FireRecord | undefined,
175
+ activeRunId: string | undefined
176
+ ): TriggerError | undefined => {
177
+ if (result.outcome === "launched" && (typeof result.runId !== "string" || result.runId.trim().length === 0)) {
178
+ return new TriggerError({ code: "invalid_options", message: "launched requires a non-empty runId", path: "runId" })
179
+ }
180
+ const unfinished = fire?.outcome === null || fire?.outcome === "buffered"
181
+ const reservation = activeRunId !== undefined && isReservation(activeRunId) &&
182
+ reservationOccurrence(activeRunId) === result.occurrence
183
+ const owner = result.reservationId ?? result.runId ?? (reservation ? activeRunId : undefined)
184
+ const permitted = result.outcome === "launched"
185
+ ? unfinished && reservation && result.reservationId === activeRunId
186
+ : fire !== undefined && (
187
+ // A repeated decision or settlement is harmless; callers do not rewrite it.
188
+ (fire.outcome === result.outcome && (result.runId === undefined || result.runId === fire.runId)) ||
189
+ (fire.outcome === "launched" && result.outcome !== "skipped" && result.outcome !== "buffered" &&
190
+ result.reservationId === undefined && (result.runId === undefined || result.runId === fire.runId)) ||
191
+ (unfinished && reservation && owner === activeRunId)
192
+ )
193
+ return permitted ? undefined : new TriggerError({
194
+ code: "stale_owner",
195
+ message: `result no longer owns trigger ${result.triggerId} occurrence ${result.occurrence}`
196
+ })
197
+ }
198
+
199
+ /**
200
+ * One row of the fire ledger: a claimed occurrence and what became of it.
201
+ *
202
+ * `outcome` is `null` between a claim and its `recordResult`, the window in
203
+ * which a launch is reserved but not yet reported.
204
+ *
205
+ * @category models
206
+ * @since 1.0.0-rc.0
207
+ */
208
+ export interface FireRecord extends Fire {
209
+ readonly outcome: Outcome | null
210
+ readonly runId?: string | undefined
211
+ readonly error?: string | undefined
212
+ }
213
+
214
+ /**
215
+ * A ledger query. Every filter narrows; `cursor` is the last record of the
216
+ * previous page, so the page after it holds only older records; `limit` caps
217
+ * a page and must be a positive safe integer when stated. With no limit the
218
+ * whole ledger answers in one page.
219
+ *
220
+ * @category models
221
+ * @since 1.0.0-rc.0
222
+ */
223
+ export interface HistoryQuery {
224
+ readonly triggerId?: string | undefined
225
+ readonly runId?: string | undefined
226
+ readonly outcome?: Outcome | undefined
227
+ readonly cursor?: Fire | undefined
228
+ readonly limit?: number | undefined
229
+ }
230
+
231
+ /**
232
+ * One page of the ledger, newest occurrence first, with the cursor that
233
+ * addresses the next page when the limit cut the page short.
234
+ *
235
+ * @category models
236
+ * @since 1.0.0-rc.0
237
+ */
238
+ export interface HistoryPage {
239
+ readonly items: ReadonlyArray<FireRecord>
240
+ readonly nextCursor?: Fire | undefined
241
+ }
242
+
243
+ /**
244
+ * What one trigger holds right now: the run or launch reservation on its row
245
+ * and the occurrence buffered behind it. A plain read; `activeRun` is the read
246
+ * that expires a stale reservation.
247
+ *
248
+ * @category models
249
+ * @since 1.0.0-rc.0
250
+ */
251
+ export interface Held {
252
+ readonly activeRunId?: string | undefined
253
+ readonly pendingAt?: number | undefined
254
+ }
255
+
256
+ /**
257
+ * One row of a listing: what the trigger holds right now, and either the
258
+ * declaration the row decoded to or the failure it could not be read through.
259
+ *
260
+ * `trigger` is an `effect/Result`: narrow it with `Result.isFailure` before
261
+ * reading the declaration. An undecodable row is isolated here rather than
262
+ * failing the whole listing,
263
+ * so one corrupt input cannot stop every other trigger from being scheduled.
264
+ * The held state travels with the row because a scheduler tick would otherwise
265
+ * ask for it again per trigger, once to expire a reservation that is not there
266
+ * and once for a buffered occurrence that is not there either.
267
+ *
268
+ * @category models
269
+ * @since 1.0.0-rc.0
270
+ */
271
+ export interface Listed extends Held {
272
+ readonly triggerId: string
273
+ readonly trigger: Decode.Result<Registered, TriggerError>
274
+ }
275
+
276
+ /**
277
+ * A listed row for a declaration that decoded, holding what the trigger holds
278
+ * right now. Both stores build their rows through this, so a caller composing
279
+ * a store or a scheduler fixture of its own does not restate the shape.
280
+ *
281
+ * @category constructors
282
+ * @since 1.0.0-rc.0
283
+ */
284
+ export const listed = (trigger: Registered, held: Held = {}): Listed => ({
285
+ triggerId: trigger.id,
286
+ trigger: Decode.succeed(trigger),
287
+ ...held
288
+ })
289
+
290
+ /**
291
+ * The last poll one scheduler host recorded.
292
+ *
293
+ * @category models
294
+ * @since 1.0.0-rc.0
295
+ */
296
+ export interface Heartbeat {
297
+ readonly host: string
298
+ readonly tickedAt: number
299
+ }
300
+
301
+ /**
302
+ * Validates a {@link HistoryQuery} limit: absent, or a positive safe integer.
303
+ * Both stores apply this before reading so swapping one for the other cannot
304
+ * change which queries are refused.
305
+ *
306
+ * @category constructors
307
+ * @since 1.0.0-rc.0
308
+ */
309
+ export const historyLimit = (limit: number | undefined): Effect.Effect<number | undefined, TriggerError> =>
310
+ limit === undefined || (Number.isSafeInteger(limit) && limit > 0)
311
+ ? Effect.succeed(limit)
312
+ : Effect.fail(
313
+ new TriggerError({
314
+ code: "invalid_options",
315
+ message: `history limit must be a positive safe integer, received ${limit}`,
316
+ path: "limit"
317
+ })
318
+ )
319
+
320
+ /**
321
+ * Validates a prune cutoff: any safe integer. Both stores apply this before
322
+ * deleting so swapping one for the other cannot change which cutoffs are
323
+ * refused.
324
+ *
325
+ * @category constructors
326
+ * @since 1.0.0-rc.0
327
+ */
328
+ export const pruneCutoff = (olderThan: number): Effect.Effect<number, TriggerError> =>
329
+ Number.isSafeInteger(olderThan)
330
+ ? Effect.succeed(olderThan)
331
+ : Effect.fail(
332
+ new TriggerError({
333
+ code: "invalid_options",
334
+ message: `prune cutoff must be a safe integer, received ${olderThan}`,
335
+ path: "olderThan"
336
+ })
337
+ )
338
+
339
+ /**
340
+ * Newest occurrence first; equal occurrences of different triggers order by
341
+ * descending trigger id so a cursor names one position.
342
+ *
343
+ * @category ordering
344
+ * @since 1.0.0-rc.0
345
+ */
346
+ export const compareNewestFirst = (left: Fire, right: Fire): number =>
347
+ right.occurrence - left.occurrence ||
348
+ (right.triggerId < left.triggerId ? -1 : right.triggerId > left.triggerId ? 1 : 0)
349
+
350
+ /**
351
+ * Whether a record lies after `cursor` in {@link compareNewestFirst} order.
352
+ *
353
+ * @category predicates
354
+ * @since 1.0.0-rc.0
355
+ */
356
+ export const isAfterCursor = (record: Fire, cursor: Fire): boolean => compareNewestFirst(cursor, record) < 0
357
+
358
+ /**
359
+ * Cuts an ordered, filtered record list to one page. The caller fetches one
360
+ * record past the limit; that record's presence is what says a next page
361
+ * exists, and the page's last record is the cursor addressing it.
362
+ *
363
+ * @category constructors
364
+ * @since 1.0.0-rc.0
365
+ */
366
+ export const historyPage = (records: ReadonlyArray<FireRecord>, limit: number | undefined): HistoryPage => {
367
+ if (limit === undefined || records.length <= limit) return { items: records }
368
+ const items = records.slice(0, limit)
369
+ const last = items[items.length - 1] as FireRecord
370
+ return { items, nextCursor: { triggerId: last.triggerId, occurrence: last.occurrence } }
371
+ }
372
+
373
+ /**
374
+ * Durable trigger state: registration, listing, and the claim protocol that
375
+ * keeps two schedulers from firing the same occurrence.
376
+ *
377
+ * Neither listing is a due-time query. Due-ness is a cron computation the
378
+ * scheduler performs against its own watermark, so the store is asked only for
379
+ * the rows eligible to be considered. A scheduler tick reads `list`, not
380
+ * `listEnabled`: a disabled trigger can still hold an active occurrence that
381
+ * has to recover, and the tick skips only its new claims. `listEnabled`
382
+ * answers the narrower question for a caller that wants the declarations.
383
+ *
384
+ * A claim, result, pending-state, or active-run method fails with
385
+ * `unknown_trigger` when the row it addresses does not exist. `get` answers
386
+ * `None` for an absent row and `register` creates one. `clearActive` is a
387
+ * compare-and-swap that cannot tell a missing trigger from a run id that no
388
+ * longer matches, so it stays a no-op for both. `history`, `pruneFires`,
389
+ * `heartbeat`, and `lastHeartbeat` address the whole store.
390
+ *
391
+ * @category models
392
+ * @since 0.1.0
393
+ */
394
+ export interface Service {
395
+ readonly register: (trigger: Trigger) => Effect.Effect<Registered, TriggerError>
396
+ readonly get: (triggerId: string) => Effect.Effect<Option.Option<Registered>, TriggerError>
397
+ /**
398
+ * Every trigger, enabled or not, ordered by id. This is what a scheduler
399
+ * tick polls: see {@link Listed} for why a row carries its decode result and
400
+ * its held state rather than a bare declaration.
401
+ */
402
+ readonly list: () => Effect.Effect<ReadonlyArray<Listed>, TriggerError>
403
+ readonly listEnabled: () => Effect.Effect<ReadonlyArray<Registered>, TriggerError>
404
+ readonly claimFire: (fire: ClaimFire) => Effect.Effect<Claim, TriggerError>
405
+ /**
406
+ * Claims the buffered occurrence, when one exists.
407
+ *
408
+ * One transaction reads the occurrence, applies the same claim protocol as
409
+ * {@link Service.claimFire}, and clears the buffer only when the decision
410
+ * consumes it. A concurrent buffer decision keeps it pending. No failure can
411
+ * land between reading the buffer and claiming it.
412
+ */
413
+ readonly claimPending: (fire: {
414
+ readonly triggerId: string
415
+ readonly expectedRevision: number
416
+ }) => Effect.Effect<
417
+ Option.Option<{ readonly occurrence: number; readonly claim: Claim }>,
418
+ TriggerError
419
+ >
420
+ readonly recordResult: (result: Result) => Effect.Effect<void, TriggerError>
421
+ /** Atomically re-arms unfinished work and releases only its matching reservation.
422
+ * A retained predecessor is restored with the pending pointer in the same write.
423
+ * A stale token fails with `stale_owner`; a failed write leaves the lease intact.
424
+ */
425
+ readonly restorePending: (fire: Fire & { readonly reservationId: string }) => Effect.Effect<void, TriggerError>
426
+ readonly setPending: (fire: Fire) => Effect.Effect<void, TriggerError>
427
+ readonly activeRun: (triggerId: string) => Effect.Effect<Option.Option<string>, TriggerError>
428
+ /**
429
+ * Returns the occurrence owned by one active run or launch reservation.
430
+ *
431
+ * `lastFiredAt` cannot answer this: later skipped and buffered occurrences
432
+ * advance that cursor while an older run remains active.
433
+ */
434
+ readonly activeOccurrence: (
435
+ triggerId: string,
436
+ runId: string
437
+ ) => Effect.Effect<Option.Option<number>, TriggerError>
438
+ readonly clearActive: (triggerId: string, runId: string) => Effect.Effect<void, TriggerError>
439
+ /**
440
+ * Reads the fire ledger newest first. See {@link HistoryQuery} for the
441
+ * filters and paging contract; a limit that is not a positive safe integer
442
+ * is refused with `invalid_options`.
443
+ */
444
+ readonly history: (query?: HistoryQuery) => Effect.Effect<HistoryPage, TriggerError>
445
+ /**
446
+ * Deletes settled fire ledger rows older than `olderThan`, answering how
447
+ * many it removed. Nothing else in the store deletes from the ledger, so a
448
+ * host that never calls this keeps one row per occurrence forever.
449
+ *
450
+ * A row is settled when its outcome is `completed`, `failed`, `skipped`, or
451
+ * `superseded`. The occurrence a trigger currently buffers and the row
452
+ * naming its active run are kept whatever their age, so pruning cannot take
453
+ * state the scheduler still reads. `olderThan` that is not a safe integer is
454
+ * refused with `invalid_options`.
455
+ */
456
+ readonly pruneFires: (options: { readonly olderThan: number }) => Effect.Effect<number, TriggerError>
457
+ /**
458
+ * Reads what one trigger holds without expiring anything, so a listing can
459
+ * report a reservation or a buffered occurrence exactly as the row has it.
460
+ */
461
+ readonly inspect: (triggerId: string) => Effect.Effect<Held, TriggerError>
462
+ /** Records that `host` polled the store at the store clock's current time. */
463
+ readonly heartbeat: (host: string) => Effect.Effect<void, TriggerError>
464
+ /** The most recent heartbeat across every host, or `None` when no scheduler has ever polled. */
465
+ readonly lastHeartbeat: () => Effect.Effect<Option.Option<Heartbeat>, TriggerError>
466
+ }
467
+
468
+ /**
469
+ * The {@link Service} tag.
470
+ *
471
+ * @category services
472
+ * @since 0.1.0
473
+ */
474
+ export class TriggerStore extends Context.Service<TriggerStore, Service>()("flows/triggers/TriggerStore") {}
475
+
476
+ const unavailable = (method: string): Effect.Effect<never, TriggerError> =>
477
+ Effect.fail(new TriggerError({ code: "store", message: `${method} is unavailable` }))
478
+
479
+ /**
480
+ * A {@link Service} that fails every method as unavailable, for an
481
+ * environment with no trigger store. Overrides replace individual methods.
482
+ *
483
+ * @category constructors
484
+ * @since 0.1.0
485
+ */
486
+ export const makeNoop = (overrides: Partial<Service> = {}): Service => ({
487
+ register: () => unavailable("register"),
488
+ get: () => unavailable("get"),
489
+ list: () => unavailable("list"),
490
+ listEnabled: () => unavailable("listEnabled"),
491
+ claimFire: () => unavailable("claimFire"),
492
+ claimPending: () => unavailable("claimPending"),
493
+ recordResult: () => unavailable("recordResult"),
494
+ restorePending: () => unavailable("restorePending"),
495
+ setPending: () => unavailable("setPending"),
496
+ activeRun: () => unavailable("activeRun"),
497
+ activeOccurrence: () => unavailable("activeOccurrence"),
498
+ clearActive: () => unavailable("clearActive"),
499
+ history: () => unavailable("history"),
500
+ pruneFires: () => unavailable("pruneFires"),
501
+ inspect: () => unavailable("inspect"),
502
+ heartbeat: () => unavailable("heartbeat"),
503
+ lastHeartbeat: () => unavailable("lastHeartbeat"),
504
+ ...overrides
505
+ })
506
+
507
+ /**
508
+ * Provides {@link makeNoop}.
509
+ *
510
+ * @category layers
511
+ * @since 0.1.0
512
+ */
513
+ export const layerNoop = (overrides: Partial<Service> = {}): Layer.Layer<TriggerStore> =>
514
+ Layer.succeed(TriggerStore)(makeNoop(overrides))