@smthrs/triggers 0.0.0-stage → 1.0.0-rc.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (226) hide show
  1. package/CHANGELOG.md +219 -0
  2. package/LICENSE +21 -0
  3. package/README.md +109 -2
  4. package/dist/cjs/CatchUp.d.ts +25 -0
  5. package/dist/cjs/CatchUp.d.ts.map +1 -0
  6. package/dist/cjs/CatchUp.js +72 -0
  7. package/dist/cjs/CatchUp.js.map +7 -0
  8. package/dist/cjs/Channel.d.ts +108 -0
  9. package/dist/cjs/Channel.d.ts.map +1 -0
  10. package/dist/cjs/Channel.js +25 -0
  11. package/dist/cjs/Channel.js.map +7 -0
  12. package/dist/cjs/ClaimDecision.d.ts +183 -0
  13. package/dist/cjs/ClaimDecision.d.ts.map +1 -0
  14. package/dist/cjs/ClaimDecision.js +144 -0
  15. package/dist/cjs/ClaimDecision.js.map +7 -0
  16. package/dist/cjs/Cron.d.ts +91 -0
  17. package/dist/cjs/Cron.d.ts.map +1 -0
  18. package/dist/cjs/Cron.js +108 -0
  19. package/dist/cjs/Cron.js.map +7 -0
  20. package/dist/cjs/DispatchReader.d.ts +77 -0
  21. package/dist/cjs/DispatchReader.d.ts.map +1 -0
  22. package/dist/cjs/DispatchReader.js +121 -0
  23. package/dist/cjs/DispatchReader.js.map +7 -0
  24. package/dist/cjs/Overlap.d.ts +40 -0
  25. package/dist/cjs/Overlap.d.ts.map +1 -0
  26. package/dist/cjs/Overlap.js +37 -0
  27. package/dist/cjs/Overlap.js.map +7 -0
  28. package/dist/cjs/Schedule.d.ts +96 -0
  29. package/dist/cjs/Schedule.d.ts.map +1 -0
  30. package/dist/cjs/Schedule.js +61 -0
  31. package/dist/cjs/Schedule.js.map +7 -0
  32. package/dist/cjs/Scheduler.d.ts +248 -0
  33. package/dist/cjs/Scheduler.d.ts.map +1 -0
  34. package/dist/cjs/Scheduler.js +647 -0
  35. package/dist/cjs/Scheduler.js.map +7 -0
  36. package/dist/cjs/SqlTriggerStore.d.ts +29 -0
  37. package/dist/cjs/SqlTriggerStore.d.ts.map +1 -0
  38. package/dist/cjs/SqlTriggerStore.js +483 -0
  39. package/dist/cjs/SqlTriggerStore.js.map +7 -0
  40. package/dist/cjs/Trigger.d.ts +81 -0
  41. package/dist/cjs/Trigger.d.ts.map +1 -0
  42. package/dist/cjs/Trigger.js +54 -0
  43. package/dist/cjs/Trigger.js.map +7 -0
  44. package/dist/cjs/TriggerError.d.ts +58 -0
  45. package/dist/cjs/TriggerError.d.ts.map +1 -0
  46. package/dist/cjs/TriggerError.js +83 -0
  47. package/dist/cjs/TriggerError.js.map +7 -0
  48. package/dist/cjs/TriggerStore.d.ts +411 -0
  49. package/dist/cjs/TriggerStore.d.ts.map +1 -0
  50. package/dist/cjs/TriggerStore.js +127 -0
  51. package/dist/cjs/TriggerStore.js.map +7 -0
  52. package/dist/cjs/Webhook.d.ts +118 -0
  53. package/dist/cjs/Webhook.d.ts.map +1 -0
  54. package/dist/cjs/Webhook.js +153 -0
  55. package/dist/cjs/Webhook.js.map +7 -0
  56. package/dist/cjs/index.d.ts +30 -0
  57. package/dist/cjs/index.d.ts.map +1 -0
  58. package/dist/cjs/index.js +57 -0
  59. package/dist/cjs/index.js.map +7 -0
  60. package/dist/cjs/internal/ActiveRuns.d.ts +51 -0
  61. package/dist/cjs/internal/ActiveRuns.d.ts.map +1 -0
  62. package/dist/cjs/internal/ActiveRuns.js +56 -0
  63. package/dist/cjs/internal/ActiveRuns.js.map +7 -0
  64. package/dist/cjs/internal/DueOccurrences.d.ts +32 -0
  65. package/dist/cjs/internal/DueOccurrences.d.ts.map +1 -0
  66. package/dist/cjs/internal/DueOccurrences.js +80 -0
  67. package/dist/cjs/internal/DueOccurrences.js.map +7 -0
  68. package/dist/cjs/internal/WallClock.d.ts +59 -0
  69. package/dist/cjs/internal/WallClock.d.ts.map +1 -0
  70. package/dist/cjs/internal/WallClock.js +132 -0
  71. package/dist/cjs/internal/WallClock.js.map +7 -0
  72. package/dist/cjs/migrations/0001_triggers.d.ts +15 -0
  73. package/dist/cjs/migrations/0001_triggers.d.ts.map +1 -0
  74. package/dist/cjs/migrations/0001_triggers.js +64 -0
  75. package/dist/cjs/migrations/0001_triggers.js.map +7 -0
  76. package/dist/cjs/migrations/0002_reservation_lease.d.ts +15 -0
  77. package/dist/cjs/migrations/0002_reservation_lease.d.ts.map +1 -0
  78. package/dist/cjs/migrations/0002_reservation_lease.js +41 -0
  79. package/dist/cjs/migrations/0002_reservation_lease.js.map +7 -0
  80. package/dist/cjs/migrations/0003_heartbeat.d.ts +16 -0
  81. package/dist/cjs/migrations/0003_heartbeat.d.ts.map +1 -0
  82. package/dist/cjs/migrations/0003_heartbeat.js +44 -0
  83. package/dist/cjs/migrations/0003_heartbeat.js.map +7 -0
  84. package/dist/cjs/migrations/0004_fire_run_index.d.ts +17 -0
  85. package/dist/cjs/migrations/0004_fire_run_index.d.ts.map +1 -0
  86. package/dist/cjs/migrations/0004_fire_run_index.js +40 -0
  87. package/dist/cjs/migrations/0004_fire_run_index.js.map +7 -0
  88. package/dist/cjs/migrations/index.d.ts +33 -0
  89. package/dist/cjs/migrations/index.d.ts.map +1 -0
  90. package/dist/cjs/migrations/index.js +50 -0
  91. package/dist/cjs/migrations/index.js.map +7 -0
  92. package/dist/cjs/package.json +1 -0
  93. package/dist/cjs/test/TestTriggers.d.ts +12 -0
  94. package/dist/cjs/test/TestTriggers.d.ts.map +1 -0
  95. package/dist/cjs/test/TestTriggers.js +421 -0
  96. package/dist/cjs/test/TestTriggers.js.map +7 -0
  97. package/dist/esm/CatchUp.d.ts +25 -0
  98. package/dist/esm/CatchUp.d.ts.map +1 -0
  99. package/dist/esm/CatchUp.js +52 -0
  100. package/dist/esm/CatchUp.js.map +1 -0
  101. package/dist/esm/Channel.d.ts +108 -0
  102. package/dist/esm/Channel.d.ts.map +1 -0
  103. package/dist/esm/Channel.js +19 -0
  104. package/dist/esm/Channel.js.map +1 -0
  105. package/dist/esm/ClaimDecision.d.ts +183 -0
  106. package/dist/esm/ClaimDecision.d.ts.map +1 -0
  107. package/dist/esm/ClaimDecision.js +172 -0
  108. package/dist/esm/ClaimDecision.js.map +1 -0
  109. package/dist/esm/Cron.d.ts +91 -0
  110. package/dist/esm/Cron.d.ts.map +1 -0
  111. package/dist/esm/Cron.js +132 -0
  112. package/dist/esm/Cron.js.map +1 -0
  113. package/dist/esm/DispatchReader.d.ts +77 -0
  114. package/dist/esm/DispatchReader.d.ts.map +1 -0
  115. package/dist/esm/DispatchReader.js +152 -0
  116. package/dist/esm/DispatchReader.js.map +1 -0
  117. package/dist/esm/Overlap.d.ts +40 -0
  118. package/dist/esm/Overlap.d.ts.map +1 -0
  119. package/dist/esm/Overlap.js +31 -0
  120. package/dist/esm/Overlap.js.map +1 -0
  121. package/dist/esm/Schedule.d.ts +96 -0
  122. package/dist/esm/Schedule.d.ts.map +1 -0
  123. package/dist/esm/Schedule.js +73 -0
  124. package/dist/esm/Schedule.js.map +1 -0
  125. package/dist/esm/Scheduler.d.ts +248 -0
  126. package/dist/esm/Scheduler.d.ts.map +1 -0
  127. package/dist/esm/Scheduler.js +734 -0
  128. package/dist/esm/Scheduler.js.map +1 -0
  129. package/dist/esm/SqlTriggerStore.d.ts +29 -0
  130. package/dist/esm/SqlTriggerStore.d.ts.map +1 -0
  131. package/dist/esm/SqlTriggerStore.js +483 -0
  132. package/dist/esm/SqlTriggerStore.js.map +1 -0
  133. package/dist/esm/Trigger.d.ts +81 -0
  134. package/dist/esm/Trigger.d.ts.map +1 -0
  135. package/dist/esm/Trigger.js +56 -0
  136. package/dist/esm/Trigger.js.map +1 -0
  137. package/dist/esm/TriggerError.d.ts +58 -0
  138. package/dist/esm/TriggerError.d.ts.map +1 -0
  139. package/dist/esm/TriggerError.js +110 -0
  140. package/dist/esm/TriggerError.js.map +1 -0
  141. package/dist/esm/TriggerStore.d.ts +411 -0
  142. package/dist/esm/TriggerStore.d.ts.map +1 -0
  143. package/dist/esm/TriggerStore.js +209 -0
  144. package/dist/esm/TriggerStore.js.map +1 -0
  145. package/dist/esm/Webhook.d.ts +118 -0
  146. package/dist/esm/Webhook.d.ts.map +1 -0
  147. package/dist/esm/Webhook.js +186 -0
  148. package/dist/esm/Webhook.js.map +1 -0
  149. package/dist/esm/index.d.ts +30 -0
  150. package/dist/esm/index.d.ts.map +1 -0
  151. package/dist/esm/index.js +30 -0
  152. package/dist/esm/index.js.map +1 -0
  153. package/dist/esm/internal/ActiveRuns.d.ts +51 -0
  154. package/dist/esm/internal/ActiveRuns.d.ts.map +1 -0
  155. package/dist/esm/internal/ActiveRuns.js +34 -0
  156. package/dist/esm/internal/ActiveRuns.js.map +1 -0
  157. package/dist/esm/internal/DueOccurrences.d.ts +32 -0
  158. package/dist/esm/internal/DueOccurrences.d.ts.map +1 -0
  159. package/dist/esm/internal/DueOccurrences.js +55 -0
  160. package/dist/esm/internal/DueOccurrences.js.map +1 -0
  161. package/dist/esm/internal/WallClock.d.ts +59 -0
  162. package/dist/esm/internal/WallClock.d.ts.map +1 -0
  163. package/dist/esm/internal/WallClock.js +155 -0
  164. package/dist/esm/internal/WallClock.js.map +1 -0
  165. package/dist/esm/migrations/0001_triggers.d.ts +15 -0
  166. package/dist/esm/migrations/0001_triggers.d.ts.map +1 -0
  167. package/dist/esm/migrations/0001_triggers.js +42 -0
  168. package/dist/esm/migrations/0001_triggers.js.map +1 -0
  169. package/dist/esm/migrations/0002_reservation_lease.d.ts +15 -0
  170. package/dist/esm/migrations/0002_reservation_lease.d.ts.map +1 -0
  171. package/dist/esm/migrations/0002_reservation_lease.js +19 -0
  172. package/dist/esm/migrations/0002_reservation_lease.js.map +1 -0
  173. package/dist/esm/migrations/0003_heartbeat.d.ts +16 -0
  174. package/dist/esm/migrations/0003_heartbeat.d.ts.map +1 -0
  175. package/dist/esm/migrations/0003_heartbeat.js +23 -0
  176. package/dist/esm/migrations/0003_heartbeat.js.map +1 -0
  177. package/dist/esm/migrations/0004_fire_run_index.d.ts +17 -0
  178. package/dist/esm/migrations/0004_fire_run_index.d.ts.map +1 -0
  179. package/dist/esm/migrations/0004_fire_run_index.js +20 -0
  180. package/dist/esm/migrations/0004_fire_run_index.js.map +1 -0
  181. package/dist/esm/migrations/index.d.ts +33 -0
  182. package/dist/esm/migrations/index.d.ts.map +1 -0
  183. package/dist/esm/migrations/index.js +37 -0
  184. package/dist/esm/migrations/index.js.map +1 -0
  185. package/dist/esm/test/TestTriggers.d.ts +12 -0
  186. package/dist/esm/test/TestTriggers.d.ts.map +1 -0
  187. package/dist/esm/test/TestTriggers.js +439 -0
  188. package/dist/esm/test/TestTriggers.js.map +1 -0
  189. package/docs/README.md +140 -0
  190. package/docs/api.md +1186 -0
  191. package/docs/concepts/channels.md +103 -0
  192. package/docs/concepts/claim-protocol.md +229 -0
  193. package/docs/concepts/policies.md +124 -0
  194. package/docs/concepts/schedules.md +134 -0
  195. package/docs/guides/choose-a-policy.md +113 -0
  196. package/docs/guides/declare-a-trigger.md +136 -0
  197. package/docs/guides/ingest-a-webhook.md +164 -0
  198. package/docs/guides/run-the-scheduler.md +173 -0
  199. package/docs/guides/testing.md +157 -0
  200. package/docs/installation.md +81 -0
  201. package/docs/quickstart.md +163 -0
  202. package/docs/troubleshooting.md +281 -0
  203. package/package.json +241 -3
  204. package/src/CatchUp.ts +73 -0
  205. package/src/Channel.ts +121 -0
  206. package/src/ClaimDecision.ts +303 -0
  207. package/src/Cron.ts +173 -0
  208. package/src/DispatchReader.ts +179 -0
  209. package/src/Overlap.ts +53 -0
  210. package/src/Schedule.ts +112 -0
  211. package/src/Scheduler.ts +1154 -0
  212. package/src/SqlTriggerStore.ts +638 -0
  213. package/src/Trigger.ts +84 -0
  214. package/src/TriggerError.ts +129 -0
  215. package/src/TriggerStore.ts +514 -0
  216. package/src/Webhook.ts +270 -0
  217. package/src/index.ts +30 -0
  218. package/src/internal/ActiveRuns.ts +83 -0
  219. package/src/internal/DueOccurrences.ts +101 -0
  220. package/src/internal/WallClock.ts +172 -0
  221. package/src/migrations/0001_triggers.ts +43 -0
  222. package/src/migrations/0002_reservation_lease.ts +20 -0
  223. package/src/migrations/0003_heartbeat.ts +24 -0
  224. package/src/migrations/0004_fire_run_index.ts +21 -0
  225. package/src/migrations/index.ts +40 -0
  226. package/src/test/TestTriggers.ts +563 -0
@@ -0,0 +1,157 @@
1
+ ---
2
+ title: "Test trigger code"
3
+ description: "Test schedules and triggers deterministically: the in-memory store, the recording runner, a test clock instead of wall time, and the noop store that proves a composition reaches nothing."
4
+ sidebar:
5
+ order: 5
6
+ ---
7
+
8
+ Nothing in this package reads wall time or reaches a network directly. Time is
9
+ the Effect `Clock`, launching is the `Runner` port, and persistence is the
10
+ `TriggerStore` contract. A test replaces those three and runs the production
11
+ logic.
12
+
13
+ ## Swap the store for the in-memory one
14
+
15
+ `TestTriggers.layer` is a `TriggerStore` with real claim and overlap semantics
16
+ and no database:
17
+
18
+ ```ts
19
+ import * as TestTriggers from "@smthrs/triggers/test/TestTriggers"
20
+ import * as Layer from "effect/Layer"
21
+
22
+ const store = TestTriggers.layer
23
+ ```
24
+
25
+ It is not a kinder set of rules. Both stores apply one shared claim decision, so
26
+ they return the same refusal codes in the same order, hold the same 5-minute
27
+ reservation lease from `TriggerStore.reservationLeaseMs`, reclaim an expired
28
+ reservation the same way, and follow the same watermark rules. Registration
29
+ validates the declaration and serializes its input at the call boundary in both,
30
+ `list` and `listEnabled` order by id in both, and every reading is an
31
+ independent value that cannot be mutated back into stored state.
32
+
33
+ Two things it cannot stand in for. It holds one process's state behind a `Ref`,
34
+ so it never reports a `store` failure from a write and never shows contention
35
+ between connections. It applies no migrations and keeps no rows, so a test about
36
+ a schema or a column shape has nothing to read.
37
+
38
+ Use `SqlTriggerStore.layer` over an in-memory SQLite database when the thing
39
+ under test is a SQL behavior, such as migration application or a row shape.
40
+
41
+ ## Record launches instead of performing them
42
+
43
+ `Scheduler.layerNoopRunner` answers `inspect` with `"completed"` and `cancel`
44
+ with nothing, so launched work settles immediately. Override only what the test
45
+ needs to observe:
46
+
47
+ ```ts
48
+ import * as Scheduler from "@smthrs/triggers/Scheduler"
49
+ import * as Effect from "effect/Effect"
50
+
51
+ const launched: Array<Scheduler.StartInput> = []
52
+
53
+ const runner = Scheduler.layerNoopRunner({
54
+ start: (input) =>
55
+ Effect.sync(() => {
56
+ launched.push(input)
57
+ return input.idempotencyKey
58
+ })
59
+ })
60
+ ```
61
+
62
+ Assert on `idempotencyKey`. It is `<triggerId>:<occurrence ISO instant>`, so it
63
+ pins which boundary fired, not merely that something fired.
64
+
65
+ To exercise a run that stays alive, keep a set of active run ids and answer
66
+ `inspect` from it: `"active"` while the id is in the set, and the state you
67
+ want recorded once it leaves. To exercise supersede, record what `cancel` was
68
+ called with.
69
+
70
+ ## Move the clock instead of waiting
71
+
72
+ ```ts
73
+ import * as Scheduler from "@smthrs/triggers/Scheduler"
74
+ import * as Effect from "effect/Effect"
75
+ import { TestClock } from "effect/testing"
76
+
77
+ const program = Effect.scoped(Effect.gen(function*() {
78
+ const scheduler = yield* Scheduler.make()
79
+ yield* TestClock.setTime(Date.parse("2026-03-01T03:00:00.000Z"))
80
+ yield* scheduler.runOnce
81
+ yield* TestClock.setTime(Date.parse("2026-03-02T03:00:00.000Z"))
82
+ yield* scheduler.runOnce
83
+ yield* Effect.yieldNow
84
+ }))
85
+ ```
86
+
87
+ Build the scheduler with `Scheduler.make` rather than `Scheduler.layer`, so no
88
+ supervisor fiber competes with the test for ticks, and the test decides when
89
+ each tick happens.
90
+
91
+ Two habits keep such a test honest:
92
+
93
+ - Yield after a tick that launches. The launch monitor runs in a forked fiber,
94
+ so the terminal result is recorded after `runOnce` returns.
95
+ - Remember that the first tick a process performs on a trigger establishes a
96
+ watermark and fires nothing. A test that expects a launch needs a second tick,
97
+ or a trigger seeded with a `lastFiredAt` through a recorded result.
98
+
99
+ ## Observe what the store recorded
100
+
101
+ Wrap the store layer to capture `recordResult` calls, which is where every
102
+ scheduler decision lands:
103
+
104
+ ```ts
105
+ import * as TriggerStore from "@smthrs/triggers/TriggerStore"
106
+ import * as Effect from "effect/Effect"
107
+ import * as Layer from "effect/Layer"
108
+
109
+ const recording = (results: Array<TriggerStore.Result>) =>
110
+ Layer.effect(
111
+ TriggerStore.TriggerStore,
112
+ Effect.gen(function*() {
113
+ const store = yield* TriggerStore.TriggerStore
114
+ return TriggerStore.TriggerStore.of({
115
+ ...store,
116
+ recordResult: (result) =>
117
+ Effect.sync(() => {
118
+ results.push(result)
119
+ }).pipe(Effect.andThen(store.recordResult(result)))
120
+ })
121
+ })
122
+ ).pipe(Layer.provide(TestTriggers.layer))
123
+ ```
124
+
125
+ The outcomes tell the policies apart: `skipped`, `buffered`, and `superseded`
126
+ are the three overlap decisions, and `launched` followed by `completed` is a run
127
+ that went all the way.
128
+
129
+ ## Prove a composition reaches nothing
130
+
131
+ `TriggerStore.layerNoop` fails every method with `store` and a message naming
132
+ the method. Use it when a composition needs the service in its type but must
133
+ never touch it, and override the one method a test expects to be reached. A
134
+ scheduler tick polls `list`, so that is the override a tick needs:
135
+
136
+ ```ts
137
+ import * as TriggerStore from "@smthrs/triggers/TriggerStore"
138
+ import * as Effect from "effect/Effect"
139
+
140
+ const absent = TriggerStore.layerNoop({ list: () => Effect.succeed([]) })
141
+ ```
142
+
143
+ `Scheduler.layerNoop` is the matching inert scheduler, and
144
+ `Scheduler.makeNoopRunner` the matching inert launcher.
145
+
146
+ ## Test the pure parts directly
147
+
148
+ `Overlap.decide`, `Overlap.pendingAfter`, and `CatchUp.occurrences` are pure
149
+ functions over explicit state. A policy question is answerable without a store,
150
+ a clock, or a scheduler at all, and that is the cheapest place to pin a policy
151
+ expectation:
152
+
153
+ ```ts
154
+ import * as Overlap from "@smthrs/triggers/Overlap"
155
+
156
+ Overlap.decide("supersede", { running: true, due: 7_200_000 }) // "supersede"
157
+ ```
@@ -0,0 +1,81 @@
1
+ ---
2
+ title: "Installation"
3
+ description: "How to get @smthrs/triggers, what it requires at runtime, the import forms it publishes, and the packages a running host adds on top."
4
+ sidebar:
5
+ order: 1
6
+ ---
7
+
8
+ ## Get the package
9
+
10
+ Not on npm yet; see [Installation](https://github.com/smithersai/smithers/blob/main/packages/smithers/flows/flow/docs/installation.md#use-the-libraries).
11
+
12
+ The installed manifest resolves to the synchronized RC:
13
+
14
+ ```json
15
+ {
16
+ "dependencies": {
17
+ "@smthrs/triggers": "1.0.0-rc.1"
18
+ }
19
+ }
20
+ ```
21
+
22
+ ## Requirements
23
+
24
+ - Node.js 26.4.0 or later.
25
+ - [`effect`](https://effect.website) 4.0.0-rc.115, which supplies the `Effect`,
26
+ `Schema`, `Clock`, and SQL client types this package's signatures use.
27
+ - [`@smthrs/control`](/api/control), the authoritative launch boundary. The
28
+ scheduler's Control-backed runner and every channel dispatch go through it.
29
+ - [`@smthrs/database`](/api/database), for the SQL client and the durable
30
+ writer that `SqlTriggerStore` writes through.
31
+
32
+ All three are ordinary dependencies of the package and install with it.
33
+
34
+ ## Import forms
35
+
36
+ The root entry point re-exports every module as a namespace:
37
+
38
+ ```ts
39
+ import { Cron, DispatchReader, Scheduler, SqlTriggerStore, Trigger, TriggerStore, Webhook } from "@smthrs/triggers"
40
+ ```
41
+
42
+ Each module is also importable from its own subpath, which is the form these
43
+ docs use:
44
+
45
+ ```ts
46
+ import * as Scheduler from "@smthrs/triggers/Scheduler"
47
+ import * as TriggerStore from "@smthrs/triggers/TriggerStore"
48
+ ```
49
+
50
+ The in-memory store for tests lives at its own subpath:
51
+
52
+ ```ts
53
+ import * as TestTriggers from "@smthrs/triggers/test/TestTriggers"
54
+ ```
55
+
56
+ Three subpath forms are not public and are blocked in the package's export map:
57
+ `@smthrs/triggers/migrations/*`, `@smthrs/triggers/internal/*`, and
58
+ `@smthrs/triggers/*/index`. `@smthrs/triggers/package.json` is exported.
59
+
60
+ Migrations are internal on purpose. `SqlTriggerStore.layer` applies
61
+ `0001_triggers` and then `0002_reservation_lease` when it builds, so a host
62
+ never runs them itself.
63
+
64
+ ## What a running host adds
65
+
66
+ The package declares triggers and channels; it does not open a database or
67
+ reach a control plane. A host that actually fires triggers composes three more
68
+ layers:
69
+
70
+ - A SQL client and a durable writer, from
71
+ [`@smthrs/database`](/api/database). `NodeDatabase.layer({ filename })` plus
72
+ `DurableWriter.layer()` is the Node pairing.
73
+ - `SqlTriggerStore.layer`, which turns those two into a `TriggerStore`.
74
+ - A `Scheduler.Runner`. `Scheduler.layerControlRunner` is the production one
75
+ and requires `Control.Control` from [`@smthrs/control`](/api/control).
76
+
77
+ The composition is in [Run the scheduler in a host](./guides/run-the-scheduler.md).
78
+
79
+ ## Next step
80
+
81
+ Register a trigger and watch it launch in the [Quickstart](./quickstart.md).
@@ -0,0 +1,163 @@
1
+ ---
2
+ title: "Quickstart"
3
+ description: "Register a durable trigger and watch the scheduler launch it: a SQLite store, a recording runner, and a test clock that crosses two schedule boundaries in milliseconds."
4
+ sidebar:
5
+ order: 2
6
+ ---
7
+
8
+ This quickstart takes one trigger from a declaration to a launched run. The
9
+ store is the production SQLite one and the scheduler is the production one;
10
+ only two things are substituted, and both are seams the package already has:
11
+ the `Runner` records launches instead of reaching a control plane, and the
12
+ Effect `Clock` is a test clock, so a nightly schedule crosses two boundaries in
13
+ milliseconds instead of two days.
14
+
15
+ By the end you will have a trigger row that remembers when it last fired, and
16
+ one launch carrying an idempotency key derived from the occurrence.
17
+
18
+ ## Prerequisites
19
+
20
+ - Node.js 26.4.0 or later.
21
+ - A TypeScript project that depends on `@smthrs/triggers` and
22
+ `@smthrs/database`. See [Installation](./installation.md).
23
+
24
+ ## Compose the store, the runner, and the clock
25
+
26
+ Create `quickstart.ts`. `SqlTriggerStore.layer` needs a SQL client and a
27
+ durable writer, and it runs its own migrations when it builds:
28
+
29
+ ```ts
30
+ import * as DurableWriter from "@smthrs/database/DurableWriter"
31
+ import * as NodeDatabase from "@smthrs/database/node/NodeDatabase"
32
+ import * as Scheduler from "@smthrs/triggers/Scheduler"
33
+ import * as SqlTriggerStore from "@smthrs/triggers/SqlTriggerStore"
34
+ import * as TriggerStore from "@smthrs/triggers/TriggerStore"
35
+ import * as Effect from "effect/Effect"
36
+ import * as Layer from "effect/Layer"
37
+ import { TestClock } from "effect/testing"
38
+
39
+ const launched: Array<Scheduler.StartInput> = []
40
+
41
+ const database = Layer.provideMerge(
42
+ DurableWriter.layer(),
43
+ NodeDatabase.layer({ filename: ":memory:" })
44
+ )
45
+
46
+ /** The launcher seam: record the launch and answer with a run id. */
47
+ const runner = Scheduler.layerNoopRunner({
48
+ start: (input) =>
49
+ Effect.sync(() => {
50
+ launched.push(input)
51
+ return input.idempotencyKey
52
+ })
53
+ })
54
+
55
+ const layer = Layer.mergeAll(
56
+ SqlTriggerStore.layer.pipe(Layer.provide(database)),
57
+ runner,
58
+ TestClock.layer()
59
+ )
60
+ ```
61
+
62
+ `Scheduler.layerNoopRunner` answers `inspect` with `"completed"` and `cancel`
63
+ with nothing, so a launch settles the moment it starts. The override replaces only
64
+ `start`.
65
+
66
+ ## Register the trigger and run two ticks
67
+
68
+ `Scheduler.make` builds a scheduler with no supervisor fiber, so the program
69
+ decides when a tick happens. That is what makes the walkthrough deterministic:
70
+
71
+ ```ts
72
+ const program = Effect.scoped(Effect.gen(function*() {
73
+ const triggers = yield* TriggerStore.TriggerStore
74
+ yield* triggers.register({
75
+ id: "nightly-report",
76
+ flowId: "reports/nightly",
77
+ input: { channel: "#ops" },
78
+ cron: "0 3 * * *",
79
+ timezone: "UTC",
80
+ overlap: "skip",
81
+ catchUp: "one",
82
+ maxCatchUp: 1,
83
+ enabled: true
84
+ })
85
+
86
+ const scheduler = yield* Scheduler.make()
87
+
88
+ // First sight of this trigger: the tick records where the clock is and
89
+ // fires nothing, because a trigger that has never fired owes no history.
90
+ yield* TestClock.setTime(Date.parse("2026-03-01T03:00:00.000Z"))
91
+ yield* scheduler.runOnce
92
+
93
+ // A boundary has passed since that watermark, so this tick launches it.
94
+ yield* TestClock.setTime(Date.parse("2026-03-02T03:00:00.000Z"))
95
+ yield* scheduler.runOnce
96
+ yield* Effect.yieldNow
97
+
98
+ return yield* triggers.get("nightly-report")
99
+ }))
100
+
101
+ const registered = await Effect.runPromise(program.pipe(Effect.provide(layer)))
102
+ console.log(launched)
103
+ console.log(registered)
104
+ ```
105
+
106
+ Run the file with your TypeScript runner. The recorded launch and the stored
107
+ row look like this:
108
+
109
+ ```text
110
+ [
111
+ {
112
+ flowId: 'reports/nightly',
113
+ input: { channel: '#ops' },
114
+ idempotencyKey: 'nightly-report:2026-03-02T03:00:00.000Z'
115
+ }
116
+ ]
117
+ {
118
+ _id: 'Option',
119
+ _tag: 'Some',
120
+ value: {
121
+ id: 'nightly-report',
122
+ flowId: 'reports/nightly',
123
+ input: { channel: '#ops' },
124
+ cron: '0 3 * * *',
125
+ timezone: 'UTC',
126
+ overlap: 'skip',
127
+ catchUp: 'one',
128
+ maxCatchUp: 1,
129
+ enabled: true,
130
+ revision: 1,
131
+ lastFiredAt: 1772420400000
132
+ }
133
+ }
134
+ ```
135
+
136
+ ## What just happened
137
+
138
+ Four facts in that output are the package working.
139
+
140
+ - **The first tick fired nothing.** A trigger registered on Sunday evening does
141
+ not owe Monday morning six days ago. With no `lastFiredAt`, the first tick a
142
+ process sees establishes a watermark at the latest boundary and starts from
143
+ the next one.
144
+ - **The idempotency key is the occurrence, not the moment.** It is
145
+ `<triggerId>:<occurrence ISO instant>`, computed from the schedule boundary
146
+ with milliseconds zeroed. Two hosts that both notice the same boundary derive
147
+ the same key, so a duplicate launch is the same launch.
148
+ - **`revision` is 1.** Every registration of the same id bumps the revision, and
149
+ a claim carries the revision it was computed from. That is the fence that
150
+ stops a stale snapshot from firing a trigger somebody just edited.
151
+ - **`lastFiredAt` is the occurrence that fired**, not the wall time the host
152
+ noticed it. It is the cursor catch-up resumes from, and it only ever moves
153
+ forward, so an older run settling after a newer occurrence was skipped cannot
154
+ drag it backwards and replay settled work.
155
+
156
+ ## Next steps
157
+
158
+ - [Run the scheduler in a host](./guides/run-the-scheduler.md): the same
159
+ composition with a supervisor fiber and the real Control-backed runner.
160
+ - [Choose an overlap and catch-up policy](./guides/choose-a-policy.md): what
161
+ `skip` and `one` decided here, and when to pick something else.
162
+ - [The claim protocol](./concepts/claim-protocol.md): how hosts coordinate
163
+ claims and runners deduplicate repeated launch attempts.
@@ -0,0 +1,281 @@
1
+ ---
2
+ title: "Troubleshooting"
3
+ description: "Every failure code @smthrs/triggers reports, plus the behaviors most often mistaken for bugs: a first tick that fires nothing, a buffer that seems stuck, and a catch-up backlog that was abandoned."
4
+ ---
5
+
6
+ Every failure this package reports is a `TriggerError` carrying a stable `code`.
7
+ Branch on the code; never parse the message. `TriggerError.path` names the
8
+ offending field when the failure could locate one.
9
+
10
+ Find your code below, or read the last section if nothing failed and the trigger
11
+ still did not do what you expected.
12
+
13
+ ## invalid_cron
14
+
15
+ **What happened.** Effect's cron parser rejected the expression or the timezone.
16
+
17
+ **What to change.** Fix the expression. The message is the parser's own, which
18
+ names the field it choked on. A timezone must be an IANA name such as
19
+ `America/New_York`.
20
+
21
+ ## unsatisfiable_cron
22
+
23
+ **What happened.** The expression parses and the calendar never satisfies it.
24
+ `0 0 30 2 *` is February 30; `0 0 31 4 *` is April 31. The occurrence search
25
+ exhausted its bound looking for a date that does not exist.
26
+
27
+ **What to change.** Fix the expression. The refusal arrives at declaration time,
28
+ from `Trigger.make`, `Schedule.make`, or `SqlTriggerStore.register`, rather than
29
+ at the tick that would have fired the trigger. That is deliberate: the
30
+ satisfiability probe is the same search every tick performs, so an expression
31
+ that survives declaration is one the scheduler can keep answering.
32
+
33
+ ## invalid_trigger
34
+
35
+ **What happened.** The declaration did not decode. Either a field is missing or
36
+ the wrong type, or the `input` has no JSON representation.
37
+
38
+ **What to change.** Read `TriggerError.path`, which names the offending field.
39
+ `id`, `flowId`, and `cron` must be non-empty strings; `maxCatchUp` must be an
40
+ integer between 0 and 1000; `input` must be JSON, which excludes `undefined`,
41
+ `NaN`, a `Date`, and a function.
42
+
43
+ Enumerable getters are accepted when their values are JSON. They are evaluated
44
+ during decoding and again during SQL serialization. SQL registration takes an
45
+ eager serialized snapshot before returning its Effect; later changes to the
46
+ getter's source do not change that snapshot. Use plain JSON values for stable
47
+ input, since a getter can produce different values or throw on a later read.
48
+
49
+ ## invalid_schedule
50
+
51
+ **What happened.** The same thing, for a declaration decoded through
52
+ `Schedule.make` rather than `Trigger.make`.
53
+
54
+ **What to change.** Read `TriggerError.path` and fix the field.
55
+
56
+ ## invalid_options
57
+
58
+ **What happened.** One of two contracts was violated.
59
+
60
+ - A cron occurrence limit was not a non-negative safe integer. `path` is
61
+ `"limit"`, and the message repeats the value it received, including `NaN` and
62
+ `Infinity`.
63
+ - A scheduler interval or deadline was not a finite, positive Effect duration.
64
+ `path` is `"pollInterval"`, `"runPollInterval"`, `"startTimeout"`,
65
+ `"inspectTimeout"`, or `"cancelTimeout"`.
66
+ - A scheduler `concurrency` was not a positive integer. `path` is
67
+ `"concurrency"`.
68
+ - A `TriggerStore.history` limit was not a positive safe integer. `path` is
69
+ `"limit"`. Zero is refused here because a zero-row page can never carry a
70
+ cursor.
71
+
72
+ **What to change.** Pass a real count, or a real duration. Zero and infinity are
73
+ both refused, because zero polls a CPU-tight loop and infinity never completes,
74
+ and `Duration.fromInput` accepts both.
75
+
76
+ ## catch_up_bound_exceeded
77
+
78
+ **What happened.** One of three things, and the message tells them apart.
79
+
80
+ - `maxCatchUp must be a non-negative safe integer, received ...`: the bound
81
+ itself is unusable. This is checked before any policy branch, so it fires even
82
+ under `catchUp: "none"`.
83
+ - `missed N occurrences; maxCatchUp is M`: the trigger owes more than it is
84
+ allowed to replay.
85
+ - `interval contains more than 1000 occurrences`: an unbounded
86
+ `Cron.occurrencesBetween` was asked for a window it will not materialize.
87
+
88
+ **What to change.** For an unusable bound, fix the declaration. For a breached
89
+ bound, raise `maxCatchUp`, or accept the abandonment. For an unbounded search,
90
+ pass a `limit`.
91
+
92
+ The scheduler logs a warning annotated with the trigger id and abandons the
93
+ backlog when catch-up exceeds its bound. It still dispatches the current
94
+ occurrence subject to overlap, on the first poll after a restart and on every
95
+ later poll.
96
+
97
+ The trap worth naming: `maxCatchUp` defaults to 0, and a declaration that sets
98
+ `catchUp: "one"` without raising it owes one occurrence it is not allowed to
99
+ replay. Whenever `catchUp` is not `none`, set `maxCatchUp` to at least 1. See
100
+ [Choose an overlap and catch-up policy](./guides/choose-a-policy.md).
101
+
102
+ ## unknown_trigger
103
+
104
+ **What happened.** An operation named a trigger id with no row behind it. Every
105
+ method addressing one trigger reports this, except `clearActive`.
106
+
107
+ **What to change.** Register the trigger first. If the id is right, check that
108
+ you are pointed at the database you think you are: `SqlTriggerStore.layer`
109
+ creates its tables on the SQL client it is given, so a second in-memory database
110
+ looks exactly like a missing trigger.
111
+
112
+ ## trigger_disabled
113
+
114
+ **What happened.** A claim read the trigger row inside its transaction and found
115
+ `enabled` false.
116
+
117
+ **What to change.** Nothing, usually. This is the correct refusal for a
118
+ scheduler that computed an occurrence before somebody disabled the trigger. Note
119
+ that the check is on the stored row, not on the caller's snapshot, so a claim
120
+ built from a stale copy is refused rather than obeyed.
121
+
122
+ ## revision_mismatch
123
+
124
+ **What happened.** The claim's `expectedRevision` differs from the revision on
125
+ the row. Somebody re-registered the trigger between the read and the claim.
126
+
127
+ **What to change.** Re-read the trigger and decide again. The scheduler already
128
+ does this: it refreshes once and recomputes what is due from the refreshed
129
+ declaration rather than retrying the occurrence the old one produced, which is
130
+ enough because the next tick reads again anyway.
131
+
132
+ ## verification_failed
133
+
134
+ **What happened.** A webhook request did not authenticate. Either the signature
135
+ did not match, the header was absent or empty, `SignatureConfig.expected`
136
+ returned zero bytes, or the credential could not be resolved. Nothing was
137
+ decoded and no Control operation ran. The message does not say which: a
138
+ refusal from `Webhook.ingest` always reads `webhook <name> did not verify the
139
+ request`, and one from the signature verifier itself always reads
140
+ `webhook signature in <header> did not verify`. A failure raised by `expected`
141
+ is the verifier refusal's `cause`, so read it there, on the host side, rather
142
+ than expecting it in the message a sender receives.
143
+
144
+ **What to change.** Check three things in order.
145
+
146
+ 1. The header name in `SignatureConfig.header`. The verifier looks it up first
147
+ in lowercase and then exactly as written, so a casing mismatch is not the
148
+ cause, but a wrong name is.
149
+ 2. The bytes `SignatureConfig.expected` returns. They are compared against the
150
+ UTF-8 encoding of the header value, so an implementation that returns raw
151
+ HMAC bytes where the provider sends hex will never match. Return the encoded
152
+ form the provider actually sends. A zero-length result is refused outright,
153
+ because it means the secret resolved to the empty string rather than that
154
+ every request is valid.
155
+ 3. The credential. `Webhook.Config.credential` is required, and a failure inside
156
+ `expected` while resolving it surfaces here as a typed failure rather than as
157
+ a defect.
158
+
159
+ ## runner
160
+
161
+ **What happened.** The scheduler could not plan, launch, inspect, or cancel a
162
+ run, or a plan never got approved.
163
+
164
+ **What to change.** Read the message.
165
+
166
+ - `Control plan <id> is still parked awaiting approval after 8 attempts`: the
167
+ flow needs an approval nobody gave. The runner re-offers the same idempotent
168
+ request with a delay that doubles from one second, so it gives up a little
169
+ over two minutes in. Approve the plan, or stop scheduling a flow that requires
170
+ an interactive approval.
171
+ - `Control rejected the scheduled run: ...`: Control answered `Conflict`. The
172
+ message is Control's.
173
+ - `Control <tag> receipt did not include a run id`: an accepted launch came back
174
+ without the id the scheduler needs to monitor it.
175
+ - `Control could not ...`: the underlying Control call failed. The cause carries
176
+ the original error.
177
+
178
+ A run inspection error retains the active owner and overlap protection. The
179
+ monitor logs the cause and retries three times with doubling delays starting
180
+ at `runPollInterval`, capped at one minute. Exhaustion detaches the monitor;
181
+ subsequent ticks inspect the retained owner again. Restore Control access to
182
+ resume completion detection. The default run poll interval is fifteen seconds.
183
+ An inspection outage does not record a failed run or cancel it.
184
+
185
+ ## runner_timeout
186
+
187
+ **What happened.** One `Runner.start`, `inspect`, or `cancel` call exceeded
188
+ `startTimeout`, `inspectTimeout`, or `cancelTimeout`. The defaults are four
189
+ minutes, thirty seconds, and thirty seconds. The call was interrupted.
190
+
191
+ **What to change.** Nothing is lost. A start that timed out leaves the
192
+ occurrence pending, so the next tick retries it under the same `idempotencyKey`
193
+ and a runtime that did start the run answers with the same run id. An
194
+ inspection that timed out is retried like any inspection failure. A
195
+ cancellation that timed out restores the prior run as active and queues the
196
+ replacement. If the runtime is healthy and merely slow, raise the deadline in
197
+ `Scheduler.Options`; a parked plan needs a `startTimeout` above the two minutes
198
+ `parkedAttempts` allows.
199
+
200
+ ## store
201
+
202
+ **What happened.** A persistence operation failed, or a `TriggerStore.makeNoop`
203
+ method was called.
204
+
205
+ **What to change.** Read the message.
206
+
207
+ - `<method> is unavailable`: the composition provided `TriggerStore.layerNoop`
208
+ and something reached a method it did not override. Provide a real store, or
209
+ override that method.
210
+ - `could not run trigger migrations`: the database refused the schema. Check
211
+ that the SQL client points at a writable database.
212
+ - `could not decode trigger row`: a row's `input_json` did not parse, or the
213
+ row fails the declaration schema `register` enforces (an empty id, flow, or
214
+ cron, or a `maxCatchUp` above the limit). Something outside this package
215
+ wrote the row; the cause names the field.
216
+ - `trigger input is not JSON-serializable`: the input contained a cycle.
217
+ - `trigger store read failed` or `trigger store write failed`: the underlying
218
+ SQL call failed, and its error is the cause.
219
+
220
+ ## The trigger did not fire, and nothing failed
221
+
222
+ First ask whether anything is polling. `store.lastHeartbeat()` answers `None`
223
+ when no scheduler has ever ticked against this store, and a `TriggerSummary`
224
+ from `Control.list` carries no `schedulerLastTickMs` in the same case. A
225
+ heartbeat older than `pollInterval` by a wide margin means the scheduler's scope
226
+ closed. Then, four behaviors are correct and surprising.
227
+
228
+ **A trigger with no durable cursor fires nothing on its first tick.** A trigger with no
229
+ `lastFiredAt` establishes a watermark at the latest boundary on first sight and
230
+ starts from the next one. Registering a weekly trigger on a Sunday evening does
231
+ not fire it for the Monday six days gone. Run a second tick after a boundary has
232
+ passed, or seed a `lastFiredAt` by recording a result.
233
+
234
+ **A boundary was skipped while a run was in flight.** That is `overlap: "skip"`,
235
+ the default. The occurrence is recorded as `skipped` and the cursor advances.
236
+ Choose `buffer-one` if one coalesced follow-up is enough.
237
+
238
+ **A buffered occurrence has not run yet.** The buffer drains on the next tick
239
+ after the active run settles, and it holds exactly one occurrence: a run that
240
+ overran four boundaries leaves one pending occurrence, the newest.
241
+ `store.activeRun(triggerId)` tells you whether something is still holding the
242
+ trigger.
243
+
244
+ **A backlog disappeared after downtime.** Either `catchUp` is `none`, which
245
+ replays no missed boundary, or the backlog exceeded `maxCatchUp` and was
246
+ abandoned with a warning annotated with the trigger id. Check the logs for
247
+ `A trigger abandoned catch-up work beyond its bound`. The current occurrence
248
+ still fires in both cases.
249
+
250
+ ## The trigger fired twice
251
+
252
+ Check that both launches carry the same `idempotencyKey`. The key is
253
+ `<triggerId>:<occurrence ISO instant>`, so two hosts noticing the same boundary
254
+ produce the same key. Two different keys identify different occurrences or
255
+ trigger ids.
256
+
257
+ Scheduled dispatch makes at-least-once launch attempts. `RunnerService.start`
258
+ must durably deduplicate by `idempotencyKey` and return the same run identity on
259
+ replay, including across host restarts.
260
+ The Control-backed adapter forwards this key to Control. Custom runners must
261
+ provide the same durable deduplication contract.
262
+
263
+ The same database can issue another launch-capable claim after lease expiry or
264
+ launch compensation. A launch may have been accepted before its `launched`
265
+ result was persisted; a crash or result-write failure in that window causes a
266
+ retry. An expired lease can also overlap an earlier in-flight attempt. Repeated
267
+ `start` calls with one key are expected during recovery. If they produce
268
+ different run identities, check the runner's durable idempotency records and
269
+ whether the hosts share that deduplication state.
270
+
271
+ ## A run is stuck holding the trigger
272
+
273
+ A trigger whose `activeRun` never clears is holding either a run the runner
274
+ still calls active, or a launch reservation from a process that died. A
275
+ reservation looks like `trigger-reservation:<triggerId>:<occurrence>`, and
276
+ `TriggerStore.isReservation` says so.
277
+
278
+ A reservation releases itself when its 5-minute lease expires, at which point
279
+ the store restores the unfinished occurrence to pending work. Wait out the
280
+ lease. A real run id that never clears is a run the control plane still reports
281
+ as live, which is a question for the run, not for the trigger.