@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,638 @@
1
+ /**
2
+ * SQLite-backed trigger store.
3
+ *
4
+ * @see packages/smithers/agent/triggers/docs/api.md
5
+ *
6
+ * @since 0.1.0
7
+ */
8
+
9
+ import * as Dialect from "@smthrs/database/Dialect"
10
+ import { affectedRows, DurableWriter } from "@smthrs/database/DurableWriter"
11
+ import * as Clock from "effect/Clock"
12
+ import * as Effect from "effect/Effect"
13
+ import * as Layer from "effect/Layer"
14
+ import * as Option from "effect/Option"
15
+ import * as Result from "effect/Result"
16
+ import * as Schema from "effect/Schema"
17
+ import * as SqlClient from "effect/unstable/sql/SqlClient"
18
+ import * as ClaimDecision from "./ClaimDecision.ts"
19
+ import * as Migrations from "./migrations/index.ts"
20
+ import * as Overlap from "./Overlap.ts"
21
+ import * as Schedule from "./Schedule.ts"
22
+ import * as Trigger from "./Trigger.ts"
23
+ import { fromSchemaError, TriggerError } from "./TriggerError.ts"
24
+ import {
25
+ type ClaimFire,
26
+ type FireRecord,
27
+ type Heartbeat,
28
+ historyLimit,
29
+ historyPage,
30
+ isReservation,
31
+ type Listed,
32
+ type Outcome,
33
+ pruneCutoff,
34
+ type Registered,
35
+ reservationId,
36
+ reservationOccurrence,
37
+ resultRefusal,
38
+ type Service,
39
+ TriggerStore
40
+ } from "./TriggerStore.ts"
41
+
42
+ interface Row {
43
+ readonly trigger_id: string
44
+ readonly flow_id: string
45
+ readonly input_json: string
46
+ readonly cron: string
47
+ readonly timezone: string | null
48
+ readonly overlap: string
49
+ readonly catch_up: string
50
+ readonly max_catch_up: number
51
+ readonly enabled: number
52
+ readonly revision: number
53
+ readonly last_fired_at_ms: number | null
54
+ readonly active_run_id: string | null
55
+ readonly pending_at_ms: number | null
56
+ }
57
+
58
+ interface FireRow {
59
+ readonly trigger_id: string
60
+ readonly occurrence_at_ms: number
61
+ readonly outcome: Outcome | null
62
+ readonly run_id: string | null
63
+ readonly error: string | null
64
+ }
65
+
66
+ const fireRecord = (row: FireRow): FireRecord => ({
67
+ triggerId: row.trigger_id,
68
+ occurrence: row.occurrence_at_ms,
69
+ outcome: row.outcome,
70
+ ...(row.run_id === null ? {} : { runId: row.run_id }),
71
+ ...(row.error === null ? {} : { error: row.error })
72
+ })
73
+
74
+ interface ClaimRow {
75
+ readonly enabled: number
76
+ readonly revision: number
77
+ readonly overlap: Registered["overlap"]
78
+ readonly active_run_id: string | null
79
+ readonly active_claimed_at_ms: number | null
80
+ readonly pending_at_ms: number | null
81
+ }
82
+
83
+ const storeError = (message: string, cause?: unknown) =>
84
+ new TriggerError({
85
+ code: "store",
86
+ message,
87
+ ...(cause === undefined ? {} : { cause })
88
+ })
89
+
90
+ const unknownTrigger = (triggerId: string) =>
91
+ new TriggerError({ code: "unknown_trigger", message: `unknown trigger ${triggerId}` })
92
+
93
+ // A stored row is decoded through the same schema `register` accepts, plus the
94
+ // store-owned fields, rather than cast. The table's CHECKs cover the policy
95
+ // literals but not the catch-up bound or the non-empty strings, and a row
96
+ // anyone rewrote must not reach the scheduler as a declaration `register`
97
+ // would have refused.
98
+ const StoredDeclaration = Schema.Struct({
99
+ ...Trigger.Trigger.fields,
100
+ revision: Schema.Int,
101
+ lastFiredAt: Schema.optional(Schema.Number)
102
+ })
103
+ const decodeDeclaration = Schema.decodeUnknownSync(StoredDeclaration)
104
+
105
+ const declaration = (row: Row): Registered =>
106
+ decodeDeclaration({
107
+ id: row.trigger_id,
108
+ flowId: row.flow_id,
109
+ input: JSON.parse(row.input_json),
110
+ cron: row.cron,
111
+ ...(row.timezone === null ? {} : { timezone: row.timezone }),
112
+ overlap: row.overlap,
113
+ catchUp: row.catch_up,
114
+ maxCatchUp: row.max_catch_up,
115
+ enabled: row.enabled === 1,
116
+ revision: row.revision,
117
+ ...(row.last_fired_at_ms === null ? {} : { lastFiredAt: row.last_fired_at_ms })
118
+ })
119
+
120
+ // The offending id belongs in the message: a store failure that only says a
121
+ // row would not decode leaves an operator grepping a shared database for it.
122
+ const decodeFailure = (row: Row, cause: unknown) => storeError(`could not decode trigger row ${row.trigger_id}`, cause)
123
+
124
+ const decode = (row: Row): Effect.Effect<Registered, TriggerError> =>
125
+ Effect.try({ try: () => declaration(row), catch: (cause) => decodeFailure(row, cause) })
126
+
127
+ // A listing decodes row by row. One corrupt `input_json` used to fail the
128
+ // whole read, and because a scheduler tick lists every trigger before it
129
+ // isolates them, that one row stopped every healthy schedule in the store.
130
+ const listedRow = (row: Row): Listed => ({
131
+ triggerId: row.trigger_id,
132
+ trigger: Result.try({ try: () => declaration(row), catch: (cause) => decodeFailure(row, cause) }),
133
+ ...(row.active_run_id === null ? {} : { activeRunId: row.active_run_id }),
134
+ ...(row.pending_at_ms === null ? {} : { pendingAt: row.pending_at_ms })
135
+ })
136
+
137
+ /**
138
+ * Builds a {@link TriggerStore.Service} over the ambient SQL client, with
139
+ * every write going through the durable writer.
140
+ *
141
+ * @category constructors
142
+ * @since 0.1.0
143
+ */
144
+ export const make: Effect.Effect<
145
+ Service,
146
+ TriggerError,
147
+ DurableWriter | SqlClient.SqlClient
148
+ > = Effect.gen(function*() {
149
+ const sql = yield* Effect.service(SqlClient.SqlClient)
150
+ const writer = yield* DurableWriter
151
+ // The migrator raises a schema it cannot apply as a defect, not as a typed
152
+ // failure, so mapping the error channel alone left a defect escaping a
153
+ // constructor whose signature promises `TriggerError`. Defects are caught
154
+ // separately from failures, which leaves interruption alone.
155
+ const migrationFailed = (cause: unknown) => storeError("could not run trigger migrations", cause)
156
+ yield* Migrations.run.pipe(
157
+ Effect.mapError(migrationFailed),
158
+ Effect.catchDefect((defect) => Effect.fail(migrationFailed(defect)))
159
+ )
160
+ // A failure the store itself typed already says which refusal it is, so it
161
+ // travels out unchanged. Re-wrapping it turned `unknown trigger x` into the
162
+ // generic write failure and erased the one code a caller could branch on.
163
+ const asTriggerError = (message: string) => (cause: unknown): TriggerError =>
164
+ cause instanceof TriggerError ? cause : storeError(message, cause)
165
+ const read = <A>(effect: Effect.Effect<A, unknown>): Effect.Effect<A, TriggerError> =>
166
+ effect.pipe(Effect.mapError(asTriggerError("trigger store read failed")))
167
+ const write = <A>(effect: Effect.Effect<A, unknown>): Effect.Effect<A, TriggerError> =>
168
+ writer.write(effect).pipe(Effect.mapError(asTriggerError("trigger store write failed")))
169
+ const requireTrigger = (triggerId: string) =>
170
+ sql<{ readonly trigger_id: string }>`SELECT trigger_id FROM flows_triggers WHERE trigger_id = ${triggerId}`.pipe(
171
+ Effect.flatMap((rows) => rows[0] === undefined ? Effect.fail(unknownTrigger(triggerId)) : Effect.void)
172
+ )
173
+ const get: Service["get"] = (triggerId) =>
174
+ read(sql<Row>`SELECT * FROM flows_triggers WHERE trigger_id = ${triggerId}`).pipe(
175
+ Effect.flatMap((rows) =>
176
+ rows[0] === undefined ? Effect.succeed(Option.none()) : decode(rows[0]).pipe(Effect.map(Option.some))
177
+ )
178
+ )
179
+ const fireSnapshot = (
180
+ row: { readonly outcome: string | null; readonly run_id: string | null } | undefined
181
+ ): ClaimDecision.Fire | undefined =>
182
+ row === undefined ? undefined : {
183
+ outcome: row.outcome as Outcome | null,
184
+ ...(row.run_id === null ? {} : { runId: row.run_id })
185
+ }
186
+ const claimInTransaction = (
187
+ fire: ClaimFire,
188
+ claimedAt: number,
189
+ suppliedRow?: ClaimRow
190
+ ) =>
191
+ Effect.gen(function*() {
192
+ // The declaration is read first and inside the transaction, so the
193
+ // policy applied is the stored one and a caller holding a snapshot from
194
+ // before an edit is refused rather than obeyed.
195
+ const row = suppliedRow ?? (yield* sql<ClaimRow>`
196
+ SELECT enabled, revision, overlap, active_run_id, active_claimed_at_ms, pending_at_ms
197
+ FROM flows_triggers WHERE trigger_id = ${fire.triggerId}
198
+ `)[0]
199
+ if (row === undefined) return yield* Effect.fail(unknownTrigger(fire.triggerId))
200
+ const activeRunId = row.active_run_id ?? undefined
201
+ // The fences run before the insert below, so a refused claim leaves no
202
+ // ledger row behind.
203
+ const refusal = ClaimDecision.refuse({ enabled: row.enabled === 1, revision: row.revision }, fire)
204
+ if (refusal !== undefined) return yield* Effect.fail(refusal)
205
+ const insertResult = yield* sql`
206
+ INSERT INTO flows_trigger_fires (trigger_id, occurrence_at_ms)
207
+ VALUES (${fire.triggerId}, ${fire.occurrence})
208
+ ON CONFLICT (trigger_id, occurrence_at_ms) DO NOTHING
209
+ `.raw
210
+ const inserted = yield* affectedRows(insertResult)
211
+ const existing = inserted === 0
212
+ ? yield* sql<{ readonly outcome: string | null; readonly run_id: string | null }>`
213
+ SELECT outcome, run_id FROM flows_trigger_fires
214
+ WHERE trigger_id = ${fire.triggerId} AND occurrence_at_ms = ${fire.occurrence}
215
+ `
216
+ : []
217
+ // Both the expired-reservation reclaim and the supersede predecessor
218
+ // lookup read the row the reservation holds, so one read serves both.
219
+ const activeOccurrence = activeRunId !== undefined && isReservation(activeRunId)
220
+ ? reservationOccurrence(activeRunId)
221
+ : undefined
222
+ const active = activeOccurrence === undefined
223
+ ? []
224
+ : yield* sql<{ readonly outcome: string | null; readonly run_id: string | null }>`
225
+ SELECT outcome, run_id FROM flows_trigger_fires
226
+ WHERE trigger_id = ${fire.triggerId} AND occurrence_at_ms = ${activeOccurrence}
227
+ `
228
+ const existingFire = fireSnapshot(existing[0])
229
+ const activeFire = fireSnapshot(active[0])
230
+ const decision = ClaimDecision.decideFenced({
231
+ fire,
232
+ claimedAt,
233
+ reservationId: reservationId(fire.triggerId, fire.occurrence, globalThis.crypto.randomUUID()),
234
+ snapshot: {
235
+ revision: row.revision,
236
+ enabled: row.enabled === 1,
237
+ overlap: row.overlap,
238
+ ...(activeRunId === undefined ? {} : { activeRunId }),
239
+ ...(row.active_claimed_at_ms === null ? {} : { activeClaimedAt: row.active_claimed_at_ms }),
240
+ ...(row.pending_at_ms === null ? {} : { pending: row.pending_at_ms }),
241
+ ...(existingFire === undefined ? {} : { existingFire }),
242
+ ...(activeFire === undefined ? {} : { activeFire })
243
+ }
244
+ })
245
+ for (const write of decision.writes) {
246
+ switch (write._tag) {
247
+ case "SetOutcome": {
248
+ yield* write.whileOpen
249
+ ? sql`UPDATE flows_trigger_fires SET outcome = ${write.outcome}
250
+ WHERE trigger_id = ${fire.triggerId} AND occurrence_at_ms = ${write.occurrence}
251
+ AND (outcome IS NULL OR outcome = 'buffered')`
252
+ : sql`UPDATE flows_trigger_fires SET outcome = ${write.outcome}
253
+ WHERE trigger_id = ${fire.triggerId} AND occurrence_at_ms = ${write.occurrence}`
254
+ break
255
+ }
256
+ case "SetRunId": {
257
+ yield* sql`UPDATE flows_trigger_fires SET run_id = ${write.runId}
258
+ WHERE trigger_id = ${fire.triggerId} AND occurrence_at_ms = ${write.occurrence}`
259
+ break
260
+ }
261
+ case "ReleaseReservation": {
262
+ yield* sql`UPDATE flows_triggers
263
+ SET active_run_id = ${write.activeRunId ?? null}, active_claimed_at_ms = NULL,
264
+ pending_at_ms = ${write.pending ?? null}
265
+ WHERE trigger_id = ${fire.triggerId} AND active_run_id = ${write.expected}`
266
+ break
267
+ }
268
+ case "AdvanceCursor": {
269
+ yield* sql`UPDATE flows_triggers
270
+ SET last_fired_at_ms = ${
271
+ Dialect.greatest(sql)
272
+ }(COALESCE(last_fired_at_ms, ${write.occurrence}), ${write.occurrence})
273
+ WHERE trigger_id = ${fire.triggerId}`
274
+ break
275
+ }
276
+ case "SetPending": {
277
+ yield* sql`UPDATE flows_triggers SET pending_at_ms = ${write.occurrence}
278
+ WHERE trigger_id = ${fire.triggerId}`
279
+ break
280
+ }
281
+ case "Reserve": {
282
+ yield* sql`UPDATE flows_triggers
283
+ SET active_run_id = ${write.reservationId}, active_claimed_at_ms = ${write.claimedAt}
284
+ WHERE trigger_id = ${fire.triggerId}`
285
+ break
286
+ }
287
+ }
288
+ }
289
+ return decision.claim
290
+ })
291
+
292
+ return {
293
+ register: (trigger) => {
294
+ const decoded = Schema.decodeUnknownResult(Trigger.Trigger)(trigger)
295
+ if (Result.isFailure(decoded)) {
296
+ return Effect.fail(
297
+ fromSchemaError("invalid_trigger", "Trigger declaration is invalid", decoded.failure)
298
+ )
299
+ }
300
+ const snapshot = decoded.success
301
+ let input: string | undefined
302
+ try {
303
+ input = JSON.stringify(snapshot.input)
304
+ } catch (cause) {
305
+ return Effect.fail(storeError("trigger input is not JSON-serializable", cause))
306
+ }
307
+ // `JSON.stringify` answers `undefined` rather than throwing for an input
308
+ // it cannot represent. Keep this guard even though Schema.Json currently
309
+ // refuses every such input before serialization.
310
+ if (input === undefined) {
311
+ return Effect.fail(
312
+ new TriggerError({
313
+ code: "invalid_trigger",
314
+ message: "trigger input has no JSON representation",
315
+ path: "input"
316
+ })
317
+ )
318
+ }
319
+ // Registration is the last point where an unsatisfiable expression can
320
+ // still be refused. The decoded declaration and its input string were
321
+ // already copied at the call boundary, before this lazy work begins.
322
+ return Effect.suspend(() =>
323
+ Schedule.validate(snapshot).pipe(
324
+ Effect.andThen(
325
+ write(sql`
326
+ INSERT INTO flows_triggers (trigger_id, flow_id, input_json, cron, timezone, overlap, catch_up, max_catch_up, enabled, revision)
327
+ VALUES (${snapshot.id}, ${snapshot.flowId}, ${input}, ${snapshot.cron}, ${
328
+ snapshot.timezone ?? null
329
+ }, ${snapshot.overlap}, ${snapshot.catchUp}, ${snapshot.maxCatchUp}, ${snapshot.enabled ? 1 : 0}, 1)
330
+ ON CONFLICT (trigger_id) DO UPDATE SET flow_id = excluded.flow_id, input_json = excluded.input_json, cron = excluded.cron,
331
+ timezone = excluded.timezone, overlap = excluded.overlap, catch_up = excluded.catch_up, max_catch_up = excluded.max_catch_up,
332
+ enabled = excluded.enabled, revision = flows_triggers.revision + 1
333
+ `.pipe(
334
+ Effect.flatMap(() => get(snapshot.id)),
335
+ Effect.flatMap((registered) =>
336
+ Option.isSome(registered)
337
+ ? Effect.succeed(registered.value)
338
+ : Effect.fail(storeError("registered trigger disappeared"))
339
+ )
340
+ ))
341
+ )
342
+ )
343
+ )
344
+ },
345
+ get,
346
+ list: () =>
347
+ read(sql<Row>`SELECT * FROM flows_triggers ORDER BY trigger_id`).pipe(
348
+ Effect.map((rows) => rows.map(listedRow))
349
+ ),
350
+ listEnabled: () =>
351
+ read(sql<Row>`SELECT * FROM flows_triggers WHERE enabled = 1 ORDER BY trigger_id`).pipe(
352
+ Effect.flatMap((rows) => Effect.all(rows.map(decode)))
353
+ ),
354
+ claimFire: (fire) =>
355
+ Effect.gen(function*() {
356
+ const claimedAt = yield* Clock.currentTimeMillis
357
+ return yield* write(claimInTransaction(fire, claimedAt))
358
+ }),
359
+ claimPending: (fire) =>
360
+ Effect.gen(function*() {
361
+ const claimedAt = yield* Clock.currentTimeMillis
362
+ return yield* write(Effect.gen(function*() {
363
+ const rows = yield* sql<ClaimRow>`
364
+ SELECT enabled, revision, overlap, active_run_id, active_claimed_at_ms, pending_at_ms
365
+ FROM flows_triggers WHERE trigger_id = ${fire.triggerId}
366
+ `
367
+ const row = rows[0]
368
+ if (row === undefined) return yield* Effect.fail(unknownTrigger(fire.triggerId))
369
+ if (row.pending_at_ms === null) return Option.none()
370
+ const occurrence = row.pending_at_ms
371
+ const claim = yield* claimInTransaction(
372
+ {
373
+ triggerId: fire.triggerId,
374
+ occurrence,
375
+ expectedRevision: fire.expectedRevision,
376
+ resumeBuffered: true
377
+ },
378
+ claimedAt,
379
+ row
380
+ )
381
+ if (claim.claimed && claim.action !== "buffer") {
382
+ yield* sql`UPDATE flows_triggers SET pending_at_ms = NULL WHERE trigger_id = ${fire.triggerId}`
383
+ }
384
+ return Option.some({ occurrence, claim })
385
+ }))
386
+ }),
387
+ recordResult: (result) =>
388
+ write(
389
+ Effect.gen(function*() {
390
+ yield* requireTrigger(result.triggerId)
391
+ const rows = yield* sql<FireRow>`SELECT * FROM flows_trigger_fires
392
+ WHERE trigger_id = ${result.triggerId} AND occurrence_at_ms = ${result.occurrence}`
393
+ const held = yield* sql<{ readonly active_run_id: string | null }>`
394
+ SELECT active_run_id FROM flows_triggers WHERE trigger_id = ${result.triggerId}`
395
+ const existing = rows[0] === undefined ? undefined : fireRecord(rows[0])
396
+ const active = held[0]!.active_run_id ?? undefined
397
+ const refusal = resultRefusal(result, existing, active)
398
+ if (refusal !== undefined) return yield* Effect.fail(refusal)
399
+ if (result.outcome !== "launched" && existing?.outcome === result.outcome) return
400
+ const terminal = result.outcome === "completed" || result.outcome === "failed" ||
401
+ result.outcome === "superseded"
402
+ const resultOwner = (existing?.outcome === "launched"
403
+ ? existing.runId
404
+ : result.reservationId ?? result.runId ?? active) ?? null
405
+ if (terminal && result.runId === undefined) {
406
+ // Keep the recorded owner so an unqualified late result can only
407
+ // clear the run launched by this occurrence, never a newer one.
408
+ yield* sql`UPDATE flows_trigger_fires
409
+ SET outcome = ${result.outcome}, error = ${result.error ?? null}
410
+ WHERE trigger_id = ${result.triggerId} AND occurrence_at_ms = ${result.occurrence}`
411
+ } else {
412
+ yield* sql`UPDATE flows_trigger_fires
413
+ SET outcome = ${result.outcome}, run_id = ${result.runId ?? null}, error = ${result.error ?? null}
414
+ WHERE trigger_id = ${result.triggerId} AND occurrence_at_ms = ${result.occurrence}`
415
+ }
416
+ // `last_fired_at_ms` is the cursor catch-up resumes from, so it only
417
+ // ever moves forward. An older run settling after a newer occurrence
418
+ // was skipped used to drag it backwards and replay settled work.
419
+ if (result.outcome === "launched") {
420
+ yield* sql`UPDATE flows_triggers
421
+ SET last_fired_at_ms = ${
422
+ Dialect.greatest(sql)
423
+ }(COALESCE(last_fired_at_ms, ${result.occurrence}), ${result.occurrence}),
424
+ active_run_id = ${result.runId},
425
+ active_claimed_at_ms = NULL
426
+ WHERE trigger_id = ${result.triggerId} AND active_run_id = ${result.reservationId}`
427
+ return
428
+ }
429
+ yield* sql`UPDATE flows_triggers
430
+ SET last_fired_at_ms = ${
431
+ Dialect.greatest(sql)
432
+ }(COALESCE(last_fired_at_ms, ${result.occurrence}), ${result.occurrence}),
433
+ active_run_id = CASE
434
+ WHEN active_run_id = ${resultOwner} THEN NULL
435
+ ELSE active_run_id
436
+ END,
437
+ active_claimed_at_ms = CASE
438
+ WHEN active_run_id = ${resultOwner} THEN NULL
439
+ ELSE active_claimed_at_ms
440
+ END
441
+ WHERE trigger_id = ${result.triggerId}`
442
+ })
443
+ ).pipe(
444
+ Effect.asVoid
445
+ ),
446
+ restorePending: (fire) =>
447
+ write(Effect.gen(function*() {
448
+ yield* requireTrigger(fire.triggerId)
449
+ const rows = yield* sql<{ readonly pending_at_ms: number | null; readonly run_id: string | null }>`
450
+ SELECT t.pending_at_ms, f.run_id FROM flows_triggers t JOIN flows_trigger_fires f
451
+ ON f.trigger_id = t.trigger_id
452
+ WHERE t.trigger_id = ${fire.triggerId} AND t.active_run_id = ${fire.reservationId}
453
+ AND f.occurrence_at_ms = ${fire.occurrence} AND (f.outcome IS NULL OR f.outcome = 'buffered')`
454
+ const row = rows[0]
455
+ if (row === undefined || reservationOccurrence(fire.reservationId) !== fire.occurrence) {
456
+ return yield* Effect.fail(
457
+ new TriggerError({ code: "stale_owner", message: "pending restoration no longer owns reservation" })
458
+ )
459
+ }
460
+ const pending = Overlap.pendingAfter({
461
+ running: false,
462
+ pending: row.pending_at_ms ?? undefined,
463
+ due: fire.occurrence
464
+ })
465
+ const predecessors = row.run_id === null || isReservation(row.run_id) ?
466
+ [] :
467
+ yield* sql<{ readonly run_id: string }>`
468
+ SELECT run_id FROM flows_trigger_fires
469
+ WHERE trigger_id = ${fire.triggerId} AND run_id = ${row.run_id} AND outcome = 'launched'`
470
+ const predecessor = predecessors[0]?.run_id ?? null
471
+ yield* sql`UPDATE flows_triggers
472
+ SET pending_at_ms = ${pending}, active_run_id = ${predecessor}, active_claimed_at_ms = NULL
473
+ WHERE trigger_id = ${fire.triggerId} AND active_run_id = ${fire.reservationId}`
474
+ })).pipe(Effect.asVoid),
475
+ setPending: (fire) =>
476
+ write(Effect.gen(function*() {
477
+ const rows = yield* sql<{ readonly pending_at_ms: number | null }>`
478
+ SELECT pending_at_ms FROM flows_triggers WHERE trigger_id = ${fire.triggerId}
479
+ `
480
+ const row = rows[0]
481
+ if (row === undefined) return yield* Effect.fail(unknownTrigger(fire.triggerId))
482
+ const pending = Overlap.pendingAfter({
483
+ running: true,
484
+ pending: row.pending_at_ms ?? undefined,
485
+ due: fire.occurrence
486
+ })
487
+ yield* sql`UPDATE flows_triggers SET pending_at_ms = ${pending} WHERE trigger_id = ${fire.triggerId}`
488
+ })).pipe(Effect.asVoid),
489
+ activeRun: (triggerId) =>
490
+ Effect.gen(function*() {
491
+ const now = yield* Clock.currentTimeMillis
492
+ return yield* write(Effect.gen(function*() {
493
+ const rows = yield* sql<{
494
+ readonly active_run_id: string | null
495
+ readonly active_claimed_at_ms: number | null
496
+ readonly pending_at_ms: number | null
497
+ }>`SELECT active_run_id, active_claimed_at_ms, pending_at_ms
498
+ FROM flows_triggers WHERE trigger_id = ${triggerId}`
499
+ const row = rows[0]
500
+ if (row === undefined) return yield* Effect.fail(unknownTrigger(triggerId))
501
+ const activeRunId = row.active_run_id ?? undefined
502
+ const occurrence = activeRunId !== undefined && isReservation(activeRunId)
503
+ ? reservationOccurrence(activeRunId)
504
+ : undefined
505
+ const unfinished = occurrence === undefined
506
+ ? []
507
+ : yield* sql<{ readonly outcome: string | null; readonly run_id: string | null }>`
508
+ SELECT outcome, run_id FROM flows_trigger_fires
509
+ WHERE trigger_id = ${triggerId} AND occurrence_at_ms = ${occurrence}
510
+ AND (outcome IS NULL OR outcome = 'buffered')
511
+ `
512
+ const unfinishedFire = fireSnapshot(unfinished[0])
513
+ const lease = ClaimDecision.lease({
514
+ now,
515
+ ...(activeRunId === undefined ? {} : { activeRunId }),
516
+ ...(row.active_claimed_at_ms === null ? {} : { activeClaimedAt: row.active_claimed_at_ms }),
517
+ ...(row.pending_at_ms === null ? {} : { pending: row.pending_at_ms }),
518
+ ...(unfinishedFire === undefined ? {} : { unfinished: unfinishedFire })
519
+ })
520
+ if (lease._tag === "Idle") return Option.none()
521
+ if (lease._tag === "Held") return Option.some(lease.runId)
522
+ yield* lease.pending === undefined
523
+ ? sql`UPDATE flows_triggers SET active_run_id = NULL, active_claimed_at_ms = NULL
524
+ WHERE trigger_id = ${triggerId} AND active_run_id = ${lease.expected}`
525
+ : sql`UPDATE flows_triggers
526
+ SET active_run_id = ${lease.recovered ?? null}, active_claimed_at_ms = NULL,
527
+ pending_at_ms = ${lease.pending}
528
+ WHERE trigger_id = ${triggerId} AND active_run_id = ${lease.expected}`
529
+ return lease.recovered === undefined ? Option.none() : Option.some(lease.recovered)
530
+ }))
531
+ }),
532
+ activeOccurrence: (triggerId, runId) =>
533
+ read(Effect.gen(function*() {
534
+ yield* requireTrigger(triggerId)
535
+ const reserved = reservationOccurrence(runId)
536
+ if (reserved !== undefined) return Option.some(reserved)
537
+ if (isReservation(runId)) return Option.none()
538
+ const rows = yield* sql<{ readonly occurrence_at_ms: number }>`
539
+ SELECT occurrence_at_ms FROM flows_trigger_fires
540
+ WHERE trigger_id = ${triggerId} AND run_id = ${runId} AND outcome = 'launched'
541
+ ORDER BY occurrence_at_ms DESC LIMIT 1
542
+ `
543
+ return rows[0] === undefined ? Option.none() : Option.some(rows[0].occurrence_at_ms)
544
+ })),
545
+ clearActive: (triggerId, runId) =>
546
+ write(sql`UPDATE flows_triggers SET active_run_id = NULL, active_claimed_at_ms = NULL
547
+ WHERE trigger_id = ${triggerId} AND active_run_id = ${runId}`).pipe(
548
+ Effect.asVoid
549
+ ),
550
+ history: (query = {}) =>
551
+ historyLimit(query.limit).pipe(
552
+ Effect.flatMap((limit) =>
553
+ // One statement for every query shape: a null parameter disables
554
+ // its predicate, and SQLite reads `LIMIT -1` as no limit. One row
555
+ // past the limit is fetched so the page knows whether a next page
556
+ // exists without a second count query.
557
+ read(sql<FireRow>`
558
+ SELECT trigger_id, occurrence_at_ms, outcome, run_id, error FROM flows_trigger_fires
559
+ WHERE ${query.triggerId === undefined ? sql`1 = 1` : sql`trigger_id = ${query.triggerId}`}
560
+ AND ${query.runId === undefined ? sql`1 = 1` : sql`run_id = ${query.runId}`}
561
+ AND ${query.outcome === undefined ? sql`1 = 1` : sql`outcome = ${query.outcome}`}
562
+ AND (${query.cursor === undefined ? 1 : 0} = 1
563
+ OR occurrence_at_ms < ${query.cursor?.occurrence ?? null}
564
+ OR (occurrence_at_ms = ${query.cursor?.occurrence ?? null} AND trigger_id < ${
565
+ query.cursor?.triggerId ?? null
566
+ }))
567
+ ORDER BY occurrence_at_ms DESC, trigger_id DESC
568
+ LIMIT ${limit === undefined ? (Dialect.isPostgres(sql) ? null : -1) : limit + 1}
569
+ `).pipe(Effect.map((rows) => historyPage(rows.map(fireRecord), limit)))
570
+ )
571
+ ),
572
+ pruneFires: ({ olderThan }) =>
573
+ pruneCutoff(olderThan).pipe(
574
+ Effect.flatMap((cutoff) =>
575
+ // The trigger row names the state the scheduler still reads: the
576
+ // buffered occurrence and the run or reservation on `active_run_id`.
577
+ // An unsettled row carries no outcome, so the outcome filter already
578
+ // spares a reserved occurrence.
579
+ write(Effect.gen(function*() {
580
+ const deleted = yield* sql`
581
+ DELETE FROM flows_trigger_fires
582
+ WHERE occurrence_at_ms < ${cutoff}
583
+ AND outcome IN ('completed', 'failed', 'skipped', 'superseded')
584
+ AND NOT EXISTS (
585
+ SELECT 1 FROM flows_triggers t
586
+ WHERE t.trigger_id = flows_trigger_fires.trigger_id
587
+ AND (t.pending_at_ms = flows_trigger_fires.occurrence_at_ms
588
+ OR t.active_run_id = flows_trigger_fires.run_id)
589
+ )
590
+ `.raw
591
+ return yield* affectedRows(deleted)
592
+ }))
593
+ )
594
+ ),
595
+ inspect: (triggerId) =>
596
+ read(sql<{ readonly active_run_id: string | null; readonly pending_at_ms: number | null }>`
597
+ SELECT active_run_id, pending_at_ms FROM flows_triggers WHERE trigger_id = ${triggerId}
598
+ `).pipe(
599
+ Effect.flatMap((rows) => {
600
+ const row = rows[0]
601
+ if (row === undefined) return Effect.fail(unknownTrigger(triggerId))
602
+ return Effect.succeed({
603
+ ...(row.active_run_id === null ? {} : { activeRunId: row.active_run_id }),
604
+ ...(row.pending_at_ms === null ? {} : { pendingAt: row.pending_at_ms })
605
+ })
606
+ })
607
+ ),
608
+ heartbeat: (host) =>
609
+ Effect.flatMap(
610
+ Clock.currentTimeMillis,
611
+ (tickedAt) =>
612
+ write(sql`INSERT INTO flows_scheduler_heartbeat (host, ticked_at_ms) VALUES (${host}, ${tickedAt})
613
+ ON CONFLICT (host) DO UPDATE SET ticked_at_ms = excluded.ticked_at_ms`)
614
+ ).pipe(Effect.asVoid),
615
+ lastHeartbeat: () =>
616
+ read(sql<{ readonly host: string; readonly ticked_at_ms: number }>`
617
+ SELECT host, ticked_at_ms FROM flows_scheduler_heartbeat ORDER BY ticked_at_ms DESC, host ASC LIMIT 1
618
+ `).pipe(
619
+ Effect.map((rows) =>
620
+ rows[0] === undefined
621
+ ? Option.none<Heartbeat>()
622
+ : Option.some({ host: rows[0].host, tickedAt: rows[0].ticked_at_ms })
623
+ )
624
+ )
625
+ }
626
+ })
627
+
628
+ /**
629
+ * Provides {@link TriggerStore.TriggerStore} backed by SQL.
630
+ *
631
+ * @category layers
632
+ * @since 0.1.0
633
+ */
634
+ export const layer: Layer.Layer<
635
+ TriggerStore,
636
+ TriggerError,
637
+ DurableWriter | SqlClient.SqlClient
638
+ > = Layer.effect(TriggerStore)(make)