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