@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,563 @@
1
+ /** @since 0.1.0 */
2
+
3
+ import * as Clock from "effect/Clock"
4
+ import * as Effect from "effect/Effect"
5
+ import * as Layer from "effect/Layer"
6
+ import * as Option from "effect/Option"
7
+ import * as Ref from "effect/Ref"
8
+ import * as Result from "effect/Result"
9
+ import * as Schema from "effect/Schema"
10
+ import * as ClaimDecision from "../ClaimDecision.ts"
11
+ import * as Overlap from "../Overlap.ts"
12
+ import * as Schedule from "../Schedule.ts"
13
+ import * as Trigger from "../Trigger.ts"
14
+ import { fromSchemaError, TriggerError } from "../TriggerError.ts"
15
+ import {
16
+ type Claim,
17
+ type ClaimFire,
18
+ compareNewestFirst,
19
+ type FireRecord,
20
+ type Heartbeat,
21
+ historyLimit,
22
+ historyPage,
23
+ isAfterCursor,
24
+ isReservation,
25
+ type Listed,
26
+ listed,
27
+ type Outcome,
28
+ pruneCutoff,
29
+ type Registered,
30
+ reservationId,
31
+ reservationOccurrence,
32
+ resultRefusal,
33
+ type Service,
34
+ TriggerStore
35
+ } from "../TriggerStore.ts"
36
+
37
+ /**
38
+ * The columns the SQL store keeps on `flows_triggers` and `flows_trigger_fires`,
39
+ * spread across maps so one trigger's state can be replaced without copying the
40
+ * rest.
41
+ *
42
+ * `active` holds one record per trigger with a run or reservation, so a claim,
43
+ * an expiry, a launch and a clear each replace or delete one entry rather than
44
+ * keeping parallel maps in step. A launch keeps the run but drops `claimedAt`,
45
+ * which is how the SQL store spells a reservation that predates the lease
46
+ * column.
47
+ *
48
+ * One invariant lets the readers below index without a fallback, and this
49
+ * module alone writes it: `fireRunIds` names a run only for a fire that a
50
+ * claim or a result attached one to, and every such run was recorded
51
+ * `launched` first, so `runOccurrences` knows the occurrence it belongs to.
52
+ *
53
+ * `fireErrors` and `heartbeats` are the SQL store's `error` column and its
54
+ * `flows_scheduler_heartbeat` table.
55
+ */
56
+ /**
57
+ * A registered declaration as this layer keeps it: the SQL store persists
58
+ * `input` as a JSON string, so holding the same string is what makes every
59
+ * reading an independent value rather than an alias of the caller's object.
60
+ */
61
+ interface Stored extends Omit<Registered, "input"> {
62
+ readonly input: string
63
+ }
64
+
65
+ const registered = (stored: Stored): Registered => ({ ...stored, input: JSON.parse(stored.input) })
66
+
67
+ const byId = (left: Stored, right: Stored): number => left.id < right.id ? -1 : 1
68
+
69
+ /**
70
+ * The run or reservation a trigger holds: the `active_run_id` and
71
+ * `active_claimed_at_ms` columns of one `flows_triggers` row.
72
+ */
73
+ interface Active {
74
+ readonly runId: string
75
+ readonly claimedAt?: number
76
+ }
77
+
78
+ interface State {
79
+ readonly triggers: ReadonlyMap<string, Stored>
80
+ readonly fires: ReadonlyMap<string, Outcome | null>
81
+ readonly fireRunIds: ReadonlyMap<string, string>
82
+ readonly fireErrors: ReadonlyMap<string, string>
83
+ readonly heartbeats: ReadonlyMap<string, number>
84
+ readonly runOccurrences: ReadonlyMap<string, number>
85
+ readonly pending: ReadonlyMap<string, number>
86
+ readonly active: ReadonlyMap<string, Active>
87
+ }
88
+
89
+ const key = (triggerId: string, occurrence: number) => `${triggerId}:${occurrence}`
90
+
91
+ // The occurrence is the numeric tail, so a trigger id holding a colon still
92
+ // splits at the right place.
93
+ const fireRecord = (current: State, fireKey: string, outcome: Outcome | null): FireRecord => {
94
+ const at = fireKey.lastIndexOf(":")
95
+ const runId = current.fireRunIds.get(fireKey)
96
+ const error = current.fireErrors.get(fireKey)
97
+ return {
98
+ triggerId: fireKey.slice(0, at),
99
+ occurrence: Number(fireKey.slice(at + 1)),
100
+ outcome,
101
+ ...(runId === undefined ? {} : { runId }),
102
+ ...(error === undefined ? {} : { error })
103
+ }
104
+ }
105
+ const unknown = (triggerId: string) =>
106
+ new TriggerError({ code: "unknown_trigger", message: `unknown trigger ${triggerId}` })
107
+
108
+ const advanced = (trigger: Stored, occurrence: number): Stored => ({
109
+ ...trigger,
110
+ lastFiredAt: Math.max(trigger.lastFiredAt ?? occurrence, occurrence)
111
+ })
112
+
113
+ type Decision =
114
+ | { readonly _tag: "Failure"; readonly error: TriggerError }
115
+ | { readonly _tag: "Success"; readonly claim: Claim; readonly state: State }
116
+
117
+ const fireSnapshot = (current: State, fireKey: string): ClaimDecision.Fire | undefined => {
118
+ if (!current.fires.has(fireKey)) return undefined
119
+ const runId = current.fireRunIds.get(fireKey)
120
+ return { outcome: current.fires.get(fireKey) ?? null, ...(runId === undefined ? {} : { runId }) }
121
+ }
122
+
123
+ /**
124
+ * Gathers the rows {@link ClaimDecision.decide} reads, then applies the writes
125
+ * it answers. The protocol itself lives in that module, so this layer refuses
126
+ * and reclaims exactly what the SQL store does rather than restating the rules
127
+ * over maps.
128
+ */
129
+ const applyClaim = (
130
+ trigger: Stored,
131
+ fire: ClaimFire,
132
+ current: State,
133
+ claimedAt: number
134
+ ): Decision => {
135
+ const fireKey = key(fire.triggerId, fire.occurrence)
136
+ const existingFire = fireSnapshot(current, fireKey)
137
+ const held = current.active.get(fire.triggerId)
138
+ const activeRunId = held?.runId
139
+ const activeOccurrence = activeRunId !== undefined && isReservation(activeRunId)
140
+ ? reservationOccurrence(activeRunId)
141
+ : undefined
142
+ // The SQL store reads the reservation's row after inserting the claimed
143
+ // occurrence, so a row this claim creates reads back as unsettled.
144
+ const activeFire = activeOccurrence === undefined
145
+ ? undefined
146
+ : activeOccurrence === fire.occurrence
147
+ ? existingFire ?? { outcome: null }
148
+ : fireSnapshot(current, key(fire.triggerId, activeOccurrence))
149
+ const claimedAtMs = held?.claimedAt
150
+ const pendingAt = current.pending.get(fire.triggerId)
151
+ const decision = ClaimDecision.decide({
152
+ fire,
153
+ claimedAt,
154
+ reservationId: reservationId(fire.triggerId, fire.occurrence, globalThis.crypto.randomUUID()),
155
+ snapshot: {
156
+ revision: trigger.revision,
157
+ enabled: trigger.enabled,
158
+ overlap: trigger.overlap,
159
+ ...(activeRunId === undefined ? {} : { activeRunId }),
160
+ ...(claimedAtMs === undefined ? {} : { activeClaimedAt: claimedAtMs }),
161
+ ...(pendingAt === undefined ? {} : { pending: pendingAt }),
162
+ ...(existingFire === undefined ? {} : { existingFire }),
163
+ ...(activeFire === undefined ? {} : { activeFire })
164
+ }
165
+ })
166
+ if (decision._tag === "Refused") return { _tag: "Failure", error: decision.error }
167
+ if (!decision.claim.claimed) return { _tag: "Success", claim: decision.claim, state: current }
168
+ const triggers = new Map(current.triggers)
169
+ const fires = new Map(current.fires)
170
+ const fireRunIds = new Map(current.fireRunIds)
171
+ const pending = new Map(current.pending)
172
+ const active = new Map(current.active)
173
+ if (!fires.has(fireKey)) fires.set(fireKey, null)
174
+ for (const write of decision.writes) {
175
+ switch (write._tag) {
176
+ case "SetOutcome": {
177
+ const target = key(fire.triggerId, write.occurrence)
178
+ // The decision and application are one Ref.modify operation. An
179
+ // active reservation's fire cannot settle between these two steps.
180
+ fires.set(target, write.outcome)
181
+ break
182
+ }
183
+ case "SetRunId": {
184
+ fireRunIds.set(key(fire.triggerId, write.occurrence), write.runId)
185
+ break
186
+ }
187
+ case "ReleaseReservation": {
188
+ // The decision and its writes share this Ref.modify snapshot; the
189
+ // expected reservation cannot change between them.
190
+ if (write.activeRunId === undefined) active.delete(fire.triggerId)
191
+ else active.set(fire.triggerId, { runId: write.activeRunId })
192
+ if (write.pending === undefined) pending.delete(fire.triggerId)
193
+ else pending.set(fire.triggerId, write.pending)
194
+ break
195
+ }
196
+ case "AdvanceCursor": {
197
+ triggers.set(fire.triggerId, advanced(trigger, write.occurrence))
198
+ break
199
+ }
200
+ case "SetPending": {
201
+ pending.set(fire.triggerId, write.occurrence)
202
+ break
203
+ }
204
+ case "Reserve": {
205
+ active.set(fire.triggerId, { runId: write.reservationId, claimedAt: write.claimedAt })
206
+ break
207
+ }
208
+ }
209
+ }
210
+ return {
211
+ _tag: "Success",
212
+ claim: decision.claim,
213
+ state: { ...current, triggers, fires, fireRunIds, pending, active }
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Provides an in-memory {@link TriggerStore} for tests: real claim and
219
+ * overlap semantics, no database.
220
+ *
221
+ * @category layers
222
+ * @since 0.1.0
223
+ */
224
+ export const layer: Layer.Layer<TriggerStore> = Layer.effect(TriggerStore)(Effect.gen(function*() {
225
+ const state = yield* Ref.make<State>({
226
+ triggers: new Map(),
227
+ fires: new Map(),
228
+ fireRunIds: new Map(),
229
+ fireErrors: new Map(),
230
+ heartbeats: new Map(),
231
+ runOccurrences: new Map(),
232
+ pending: new Map(),
233
+ active: new Map()
234
+ })
235
+ const get: Service["get"] = (triggerId) =>
236
+ Ref.get(state).pipe(Effect.map((current) => {
237
+ const trigger = current.triggers.get(triggerId)
238
+ return trigger === undefined ? Option.none() : Option.some(registered(trigger))
239
+ }))
240
+ const requireTrigger = <A>(
241
+ triggerId: string,
242
+ modify: (trigger: Stored, current: State) => readonly [A, State]
243
+ ): Effect.Effect<A, TriggerError> =>
244
+ Ref.modify(state, (current): readonly [Effect.Effect<A, TriggerError>, State] => {
245
+ const trigger = current.triggers.get(triggerId)
246
+ if (trigger === undefined) return [Effect.fail(unknown(triggerId)), current]
247
+ const [value, next] = modify(trigger, current)
248
+ return [Effect.succeed(value), next]
249
+ }).pipe(Effect.flatten)
250
+ const claimFire: Service["claimFire"] = (fire) =>
251
+ Effect.flatMap(
252
+ Clock.currentTimeMillis,
253
+ (claimedAt) =>
254
+ Ref.modify(state, (current): readonly [Effect.Effect<Claim, TriggerError>, State] => {
255
+ const trigger = current.triggers.get(fire.triggerId)
256
+ if (trigger === undefined) return [Effect.fail(unknown(fire.triggerId)), current]
257
+ const decision = applyClaim(trigger, fire, current, claimedAt)
258
+ return decision._tag === "Failure"
259
+ ? [Effect.fail(decision.error), current]
260
+ : [Effect.succeed(decision.claim), decision.state]
261
+ }).pipe(Effect.flatten)
262
+ )
263
+ return TriggerStore.of({
264
+ // Registration decodes and serializes at the call boundary, before the
265
+ // returned Effect runs, so the declaration this layer keeps is the one the
266
+ // caller passed rather than whatever its object became later. The SQL
267
+ // store does the same, and its lazy schedule validation runs here too.
268
+ register: (declaration) => {
269
+ const decoded = Schema.decodeUnknownResult(Trigger.Trigger)(declaration)
270
+ if (Result.isFailure(decoded)) {
271
+ return Effect.fail(fromSchemaError("invalid_trigger", "Trigger declaration is invalid", decoded.failure))
272
+ }
273
+ const snapshot = decoded.success
274
+ let input: string | undefined
275
+ try {
276
+ input = JSON.stringify(snapshot.input)
277
+ } catch (cause) {
278
+ return Effect.fail(
279
+ new TriggerError({ code: "store", message: "trigger input is not JSON-serializable", cause })
280
+ )
281
+ }
282
+ if (input === undefined) {
283
+ return Effect.fail(
284
+ new TriggerError({
285
+ code: "invalid_trigger",
286
+ message: "trigger input has no JSON representation",
287
+ path: "input"
288
+ })
289
+ )
290
+ }
291
+ const serialized = input
292
+ return Effect.suspend(() =>
293
+ Schedule.validate(snapshot).pipe(
294
+ Effect.andThen(Ref.modify(state, (current) => {
295
+ const prior = current.triggers.get(snapshot.id)
296
+ const stored: Stored = {
297
+ ...snapshot,
298
+ input: serialized,
299
+ revision: (prior?.revision ?? 0) + 1,
300
+ ...(prior?.lastFiredAt === undefined ? {} : { lastFiredAt: prior.lastFiredAt })
301
+ }
302
+ return [registered(stored), { ...current, triggers: new Map(current.triggers).set(snapshot.id, stored) }]
303
+ }))
304
+ )
305
+ )
306
+ },
307
+ get,
308
+ list: () =>
309
+ Ref.get(state).pipe(
310
+ Effect.map((current) =>
311
+ // Nothing writes a declaration this layer cannot read back, so every
312
+ // listed row decodes. The SQL store is where a corrupt `input_json`
313
+ // can arrive.
314
+ [...current.triggers.values()].sort(byId).map((stored): Listed => {
315
+ const activeRunId = current.active.get(stored.id)?.runId
316
+ const pendingAt = current.pending.get(stored.id)
317
+ return listed(registered(stored), {
318
+ ...(activeRunId === undefined ? {} : { activeRunId }),
319
+ ...(pendingAt === undefined ? {} : { pendingAt })
320
+ })
321
+ })
322
+ )
323
+ ),
324
+ listEnabled: () =>
325
+ Ref.get(state).pipe(
326
+ Effect.map((current) =>
327
+ [...current.triggers.values()].filter((trigger) => trigger.enabled).sort(byId).map(registered)
328
+ )
329
+ ),
330
+ claimFire,
331
+ claimPending: (fire) =>
332
+ Effect.flatMap(Clock.currentTimeMillis, (claimedAt) =>
333
+ Ref.modify(state, (current): readonly [
334
+ ReturnType<Service["claimPending"]>,
335
+ State
336
+ ] => {
337
+ const trigger = current.triggers.get(fire.triggerId)
338
+ if (trigger === undefined) return [Effect.fail(unknown(fire.triggerId)), current]
339
+ const occurrence = current.pending.get(fire.triggerId)
340
+ if (occurrence === undefined) return [Effect.succeed(Option.none()), current]
341
+ const decision = applyClaim(
342
+ trigger,
343
+ {
344
+ triggerId: fire.triggerId,
345
+ occurrence,
346
+ expectedRevision: fire.expectedRevision,
347
+ resumeBuffered: true
348
+ },
349
+ current,
350
+ claimedAt
351
+ )
352
+ if (decision._tag === "Failure") return [Effect.fail(decision.error), current]
353
+ let next = decision.state
354
+ if (decision.claim.claimed && decision.claim.action !== "buffer") {
355
+ const pending = new Map(decision.state.pending)
356
+ pending.delete(fire.triggerId)
357
+ next = { ...decision.state, pending }
358
+ }
359
+ return [Effect.succeed(Option.some({ occurrence, claim: decision.claim })), next]
360
+ }).pipe(Effect.flatten)),
361
+ recordResult: (result) =>
362
+ requireTrigger(result.triggerId, (trigger, current): readonly [Effect.Effect<void, TriggerError>, State] => {
363
+ const existingOutcome = current.fires.get(key(result.triggerId, result.occurrence))
364
+ const existing = existingOutcome === undefined
365
+ ? undefined
366
+ : fireRecord(current, key(result.triggerId, result.occurrence), existingOutcome)
367
+ const refusal = resultRefusal(result, existing, current.active.get(result.triggerId)?.runId)
368
+ if (refusal !== undefined) return [Effect.fail(refusal), current]
369
+ if (result.outcome !== "launched" && existing?.outcome === result.outcome) return [Effect.void, current]
370
+ const triggers = new Map(current.triggers).set(result.triggerId, advanced(trigger, result.occurrence))
371
+ const active = new Map(current.active)
372
+ const fires = new Map(current.fires)
373
+ const fireRunIds = new Map(current.fireRunIds)
374
+ const fireErrors = new Map(current.fireErrors)
375
+ const runOccurrences = new Map(current.runOccurrences)
376
+ const fireKey = key(result.triggerId, result.occurrence)
377
+ const recordedRunId = current.fireRunIds.get(fireKey)
378
+ const terminal = result.outcome === "completed" ||
379
+ result.outcome === "failed" ||
380
+ result.outcome === "superseded"
381
+ // resultRefusal proved this ledger entry exists and can transition.
382
+ fires.set(fireKey, result.outcome)
383
+ if (result.runId !== undefined) fireRunIds.set(fireKey, result.runId)
384
+ else if (!terminal) fireRunIds.delete(fireKey)
385
+ // The SQL store overwrites the error column on every result.
386
+ if (result.error === undefined) fireErrors.delete(fireKey)
387
+ else fireErrors.set(fireKey, result.error)
388
+ if (result.outcome === "launched") {
389
+ active.set(result.triggerId, { runId: result.runId })
390
+ runOccurrences.set(result.runId, result.occurrence)
391
+ } else if (terminal) {
392
+ const currentRunId = active.get(result.triggerId)?.runId
393
+ const resultOwner = existing?.outcome === "launched"
394
+ ? recordedRunId
395
+ : result.reservationId ?? result.runId ?? currentRunId
396
+ if (currentRunId === resultOwner) active.delete(result.triggerId)
397
+ } else {
398
+ // A changed skip/buffer decision passed the reservation fence above.
399
+ active.delete(result.triggerId)
400
+ }
401
+ return [Effect.void, { ...current, triggers, fires, fireRunIds, fireErrors, runOccurrences, active }]
402
+ }).pipe(Effect.flatten),
403
+ restorePending: (fire) =>
404
+ requireTrigger(fire.triggerId, (_trigger, current): readonly [Effect.Effect<void, TriggerError>, State] => {
405
+ const outcome = current.fires.get(key(fire.triggerId, fire.occurrence))
406
+ if (
407
+ current.active.get(fire.triggerId)?.runId !== fire.reservationId ||
408
+ reservationOccurrence(fire.reservationId) !== fire.occurrence || (outcome !== null && outcome !== "buffered")
409
+ ) {
410
+ return [
411
+ Effect.fail(
412
+ new TriggerError({ code: "stale_owner", message: "pending restoration no longer owns reservation" })
413
+ ),
414
+ current
415
+ ]
416
+ }
417
+ const pending = new Map(current.pending).set(
418
+ fire.triggerId,
419
+ Overlap.pendingAfter({ running: false, pending: current.pending.get(fire.triggerId), due: fire.occurrence })
420
+ )
421
+ const active = new Map(current.active)
422
+ const predecessor = current.fireRunIds.get(key(fire.triggerId, fire.occurrence))
423
+ const predecessorOccurrence = predecessor === undefined ? undefined : current.runOccurrences.get(predecessor)
424
+ if (
425
+ predecessor !== undefined && predecessorOccurrence !== undefined &&
426
+ current.fires.get(key(fire.triggerId, predecessorOccurrence)) === "launched"
427
+ ) {
428
+ active.set(fire.triggerId, { runId: predecessor })
429
+ } else {
430
+ active.delete(fire.triggerId)
431
+ }
432
+ return [Effect.void, { ...current, pending, active }]
433
+ }).pipe(Effect.flatten),
434
+ setPending: (fire) =>
435
+ requireTrigger(fire.triggerId, (_trigger, current) => {
436
+ const pending = new Map(current.pending)
437
+ pending.set(
438
+ fire.triggerId,
439
+ Overlap.pendingAfter({ running: true, pending: pending.get(fire.triggerId), due: fire.occurrence })
440
+ )
441
+ return [undefined, { ...current, pending }]
442
+ }),
443
+ activeRun: (triggerId) =>
444
+ Effect.flatMap(Clock.currentTimeMillis, (now) =>
445
+ requireTrigger(triggerId, (_trigger, current) => {
446
+ const held = current.active.get(triggerId)
447
+ const runId = held?.runId
448
+ const occurrence = runId !== undefined && isReservation(runId) ? reservationOccurrence(runId) : undefined
449
+ const unfinished = occurrence === undefined
450
+ ? undefined
451
+ : fireSnapshot(current, key(triggerId, occurrence))
452
+ const claimedAt = held?.claimedAt
453
+ const pendingAt = current.pending.get(triggerId)
454
+ const lease = ClaimDecision.lease({
455
+ now,
456
+ ...(runId === undefined ? {} : { activeRunId: runId }),
457
+ ...(claimedAt === undefined ? {} : { activeClaimedAt: claimedAt }),
458
+ ...(pendingAt === undefined ? {} : { pending: pendingAt }),
459
+ ...(unfinished === undefined || (unfinished.outcome !== null && unfinished.outcome !== "buffered")
460
+ ? {}
461
+ : { unfinished })
462
+ })
463
+ if (lease._tag === "Idle") return [Option.none(), current]
464
+ if (lease._tag === "Held") return [Option.some(lease.runId), current]
465
+ const active = new Map(current.active)
466
+ const pending = new Map(current.pending)
467
+ if (lease.pending !== undefined) pending.set(triggerId, lease.pending)
468
+ if (lease.recovered === undefined) active.delete(triggerId)
469
+ else active.set(triggerId, { runId: lease.recovered })
470
+ return [
471
+ lease.recovered === undefined ? Option.none() : Option.some(lease.recovered),
472
+ { ...current, active, pending }
473
+ ]
474
+ })),
475
+ activeOccurrence: (triggerId, runId) =>
476
+ requireTrigger(triggerId, (_trigger, current) => {
477
+ const reserved = reservationOccurrence(runId)
478
+ const launched = current.runOccurrences.get(runId)
479
+ const occurrence = reserved ??
480
+ (launched !== undefined && current.fires.get(key(triggerId, launched)) === "launched" ? launched : undefined)
481
+ return [occurrence === undefined ? Option.none() : Option.some(occurrence), current]
482
+ }),
483
+ clearActive: (triggerId, runId) =>
484
+ Ref.update(state, (current) => {
485
+ if (current.active.get(triggerId)?.runId !== runId) return current
486
+ const active = new Map(current.active)
487
+ active.delete(triggerId)
488
+ return { ...current, active }
489
+ }),
490
+ history: (query = {}) =>
491
+ Effect.flatMap(historyLimit(query.limit), (limit) =>
492
+ Ref.get(state).pipe(Effect.map((current) => {
493
+ const records = [...current.fires.entries()]
494
+ .map(([fireKey, outcome]) => fireRecord(current, fireKey, outcome))
495
+ .filter((record) =>
496
+ (query.triggerId === undefined || record.triggerId === query.triggerId) &&
497
+ (query.runId === undefined || record.runId === query.runId) &&
498
+ (query.outcome === undefined || record.outcome === query.outcome) &&
499
+ (query.cursor === undefined || isAfterCursor(record, query.cursor))
500
+ )
501
+ .sort(compareNewestFirst)
502
+ return historyPage(records, limit)
503
+ }))),
504
+ pruneFires: ({ olderThan }) =>
505
+ Effect.flatMap(pruneCutoff(olderThan), (cutoff) =>
506
+ Ref.modify(state, (current) => {
507
+ const fires = new Map(current.fires)
508
+ const fireRunIds = new Map(current.fireRunIds)
509
+ const fireErrors = new Map(current.fireErrors)
510
+ const runOccurrences = new Map(current.runOccurrences)
511
+ let removed = 0
512
+ for (const [fireKey, outcome] of current.fires) {
513
+ if (
514
+ outcome !== "completed" && outcome !== "failed" && outcome !== "skipped" && outcome !== "superseded"
515
+ ) continue
516
+ const record = fireRecord(current, fireKey, outcome)
517
+ if (record.occurrence >= cutoff) continue
518
+ if (current.pending.get(record.triggerId) === record.occurrence) continue
519
+ if (record.runId !== undefined && current.active.get(record.triggerId)?.runId === record.runId) continue
520
+ fires.delete(fireKey)
521
+ fireErrors.delete(fireKey)
522
+ if (record.runId !== undefined) {
523
+ fireRunIds.delete(fireKey)
524
+ if (runOccurrences.get(record.runId) === record.occurrence) runOccurrences.delete(record.runId)
525
+ }
526
+ removed += 1
527
+ }
528
+ return [removed, { ...current, fires, fireRunIds, fireErrors, runOccurrences }]
529
+ })),
530
+ inspect: (triggerId) =>
531
+ requireTrigger(triggerId, (_trigger, current) => {
532
+ const activeRunId = current.active.get(triggerId)?.runId
533
+ const pendingAt = current.pending.get(triggerId)
534
+ return [
535
+ {
536
+ ...(activeRunId === undefined ? {} : { activeRunId }),
537
+ ...(pendingAt === undefined ? {} : { pendingAt })
538
+ },
539
+ current
540
+ ]
541
+ }),
542
+ heartbeat: (host) =>
543
+ Effect.flatMap(
544
+ Clock.currentTimeMillis,
545
+ (tickedAt) =>
546
+ Ref.update(state, (current) => ({ ...current, heartbeats: new Map(current.heartbeats).set(host, tickedAt) }))
547
+ ),
548
+ lastHeartbeat: () =>
549
+ Ref.get(state).pipe(Effect.map((current) => {
550
+ let latest: Heartbeat | undefined
551
+ for (const [host, tickedAt] of current.heartbeats) {
552
+ // Newest wins; equal times fall to the lower host name, as the SQL
553
+ // store's ORDER BY does.
554
+ if (
555
+ latest === undefined || tickedAt > latest.tickedAt || (tickedAt === latest.tickedAt && host < latest.host)
556
+ ) {
557
+ latest = { host, tickedAt }
558
+ }
559
+ }
560
+ return latest === undefined ? Option.none() : Option.some(latest)
561
+ }))
562
+ })
563
+ }))