@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.
- 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,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one claim decision both trigger stores apply.
|
|
3
|
+
*
|
|
4
|
+
* A claim is a pure function of the rows it reads: the trigger declaration,
|
|
5
|
+
* the fire row at the occurrence being claimed, and the fire row the active
|
|
6
|
+
* reservation holds. Each store gathers that snapshot in its own storage,
|
|
7
|
+
* calls {@link decide}, and applies the writes it answers. Writing the fences,
|
|
8
|
+
* the expired-reservation reclaim, and the supersede predecessor substitution
|
|
9
|
+
* once is what keeps `SqlTriggerStore` and the published in-memory store from
|
|
10
|
+
* drifting apart.
|
|
11
|
+
*
|
|
12
|
+
* @since 1.0.0-rc.0
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import * as Overlap from "./Overlap.ts"
|
|
16
|
+
import type { Overlap as Policy } from "./Trigger.ts"
|
|
17
|
+
import { TriggerError } from "./TriggerError.ts"
|
|
18
|
+
import {
|
|
19
|
+
type Claim,
|
|
20
|
+
type ClaimFire,
|
|
21
|
+
isReservation,
|
|
22
|
+
type Outcome,
|
|
23
|
+
reservationLeaseMs,
|
|
24
|
+
reservationOccurrence
|
|
25
|
+
} from "./TriggerStore.ts"
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A fire row as the decision reads it. `outcome` is `null` for a claimed but
|
|
29
|
+
* unsettled occurrence, matching the nullable column.
|
|
30
|
+
*
|
|
31
|
+
* @category models
|
|
32
|
+
* @since 1.0.0-rc.0
|
|
33
|
+
*/
|
|
34
|
+
export interface Fire {
|
|
35
|
+
readonly outcome: Outcome | null
|
|
36
|
+
readonly runId?: string | undefined
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Everything the decision reads, gathered by the store before it runs.
|
|
41
|
+
*
|
|
42
|
+
* `existingFire` is absent when this claim is what created the row.
|
|
43
|
+
* `activeFire` is the row at `reservationOccurrence(activeRunId)`, read after
|
|
44
|
+
* that insert, and is needed only while `activeRunId` is a reservation.
|
|
45
|
+
*
|
|
46
|
+
* @category models
|
|
47
|
+
* @since 1.0.0-rc.0
|
|
48
|
+
*/
|
|
49
|
+
export interface Snapshot {
|
|
50
|
+
readonly revision: number
|
|
51
|
+
readonly enabled: boolean
|
|
52
|
+
readonly overlap: Policy
|
|
53
|
+
readonly activeRunId?: string | undefined
|
|
54
|
+
readonly activeClaimedAt?: number | undefined
|
|
55
|
+
readonly pending?: number | undefined
|
|
56
|
+
readonly existingFire?: Fire | undefined
|
|
57
|
+
readonly activeFire?: Fire | undefined
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* One durable edit a claim owes, in the order the store must apply them.
|
|
62
|
+
*
|
|
63
|
+
* `SetOutcome` with `whileOpen` may only settle a fire that is still `null` or
|
|
64
|
+
* `buffered`; the SQL store spells that as a `WHERE` clause.
|
|
65
|
+
* `ReleaseReservation` may only land while `expected` still owns the trigger.
|
|
66
|
+
*
|
|
67
|
+
* @category models
|
|
68
|
+
* @since 1.0.0-rc.0
|
|
69
|
+
*/
|
|
70
|
+
export type Write =
|
|
71
|
+
| {
|
|
72
|
+
readonly _tag: "SetOutcome"
|
|
73
|
+
readonly occurrence: number
|
|
74
|
+
readonly outcome: Outcome
|
|
75
|
+
readonly whileOpen: boolean
|
|
76
|
+
}
|
|
77
|
+
| { readonly _tag: "SetRunId"; readonly occurrence: number; readonly runId: string }
|
|
78
|
+
| {
|
|
79
|
+
readonly _tag: "ReleaseReservation"
|
|
80
|
+
readonly expected: string
|
|
81
|
+
readonly activeRunId?: string | undefined
|
|
82
|
+
readonly pending?: number | undefined
|
|
83
|
+
}
|
|
84
|
+
| { readonly _tag: "AdvanceCursor"; readonly occurrence: number }
|
|
85
|
+
| { readonly _tag: "SetPending"; readonly occurrence: number }
|
|
86
|
+
| { readonly _tag: "Reserve"; readonly reservationId: string; readonly claimedAt: number }
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* What a claim decided: a refusal the store fails with, or a claim outcome and
|
|
90
|
+
* the writes that make it durable. An unclaimed occurrence answers
|
|
91
|
+
* `{ claimed: false }` with no writes.
|
|
92
|
+
*
|
|
93
|
+
* @category models
|
|
94
|
+
* @since 1.0.0-rc.0
|
|
95
|
+
*/
|
|
96
|
+
export type Decision =
|
|
97
|
+
| { readonly _tag: "Refused"; readonly error: TriggerError }
|
|
98
|
+
| { readonly _tag: "Decided"; readonly claim: Claim; readonly writes: ReadonlyArray<Write> }
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The claim request, the rows it reads, the reservation id the store minted
|
|
102
|
+
* for it, and the store clock reading it was claimed at.
|
|
103
|
+
*
|
|
104
|
+
* @category models
|
|
105
|
+
* @since 1.0.0-rc.0
|
|
106
|
+
*/
|
|
107
|
+
export interface Input {
|
|
108
|
+
readonly fire: ClaimFire
|
|
109
|
+
readonly snapshot: Snapshot
|
|
110
|
+
readonly reservationId: string
|
|
111
|
+
readonly claimedAt: number
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
type AdmittedDecision = Extract<Decision, { readonly _tag: "Decided" }>
|
|
115
|
+
|
|
116
|
+
const decided = (claim: Claim, writes: ReadonlyArray<Write>): AdmittedDecision => ({ _tag: "Decided", claim, writes })
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* What a reader finds behind a trigger's active owner: nothing, a live owner,
|
|
120
|
+
* or an expired reservation to release.
|
|
121
|
+
*
|
|
122
|
+
* `expected` is the reservation the release must still find in place.
|
|
123
|
+
* `recovered` is the launched predecessor the release hands back, and
|
|
124
|
+
* `pending` is the occurrence it re-arms; both are absent when the reservation
|
|
125
|
+
* covered no unsettled fire.
|
|
126
|
+
*
|
|
127
|
+
* @category models
|
|
128
|
+
* @since 1.0.0-rc.0
|
|
129
|
+
*/
|
|
130
|
+
export type Lease =
|
|
131
|
+
| { readonly _tag: "Idle" }
|
|
132
|
+
| { readonly _tag: "Held"; readonly runId: string }
|
|
133
|
+
| {
|
|
134
|
+
readonly _tag: "Expired"
|
|
135
|
+
readonly expected: string
|
|
136
|
+
readonly recovered?: string | undefined
|
|
137
|
+
readonly pending?: number | undefined
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Applies the reservation lease to a trigger's active owner.
|
|
142
|
+
*
|
|
143
|
+
* `unfinished` is the fire row at `reservationOccurrence(activeRunId)` while
|
|
144
|
+
* that row is still `null` or `buffered`, and is absent otherwise. A process
|
|
145
|
+
* that died after claiming an occurrence but before launching it left the
|
|
146
|
+
* reservation; the expired lease releases it and re-arms both ordinary and
|
|
147
|
+
* buffered work.
|
|
148
|
+
*
|
|
149
|
+
* @category decision
|
|
150
|
+
* @since 1.0.0-rc.0
|
|
151
|
+
*/
|
|
152
|
+
export const lease = (input: {
|
|
153
|
+
readonly activeRunId?: string | undefined
|
|
154
|
+
readonly activeClaimedAt?: number | undefined
|
|
155
|
+
readonly pending?: number | undefined
|
|
156
|
+
readonly now: number
|
|
157
|
+
readonly unfinished?: Fire | undefined
|
|
158
|
+
}): Lease => {
|
|
159
|
+
const { activeClaimedAt, activeRunId, now, pending, unfinished } = input
|
|
160
|
+
if (activeRunId === undefined) return { _tag: "Idle" }
|
|
161
|
+
if (!isReservation(activeRunId) || (activeClaimedAt !== undefined && activeClaimedAt > now - reservationLeaseMs)) {
|
|
162
|
+
return { _tag: "Held", runId: activeRunId }
|
|
163
|
+
}
|
|
164
|
+
const occurrence = reservationOccurrence(activeRunId)
|
|
165
|
+
if (occurrence === undefined || unfinished === undefined) return { _tag: "Expired", expected: activeRunId }
|
|
166
|
+
const predecessor = unfinished.runId
|
|
167
|
+
return {
|
|
168
|
+
_tag: "Expired",
|
|
169
|
+
expected: activeRunId,
|
|
170
|
+
pending: Overlap.pendingAfter({ running: false, pending, due: occurrence }),
|
|
171
|
+
...(predecessor !== undefined && !isReservation(predecessor) ? { recovered: predecessor } : {})
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The refusals a claim owes before it reads any fire row, in the order the
|
|
177
|
+
* stores apply them. A store checks these first so a refused claim leaves the
|
|
178
|
+
* ledger untouched; {@link decide} re-applies them so a store that only calls
|
|
179
|
+
* it is still fenced.
|
|
180
|
+
*
|
|
181
|
+
* @category decision
|
|
182
|
+
* @since 1.0.0-rc.0
|
|
183
|
+
*/
|
|
184
|
+
export const refuse = (
|
|
185
|
+
snapshot: Pick<Snapshot, "enabled" | "revision">,
|
|
186
|
+
fire: ClaimFire
|
|
187
|
+
): TriggerError | undefined => {
|
|
188
|
+
if (snapshot.revision !== fire.expectedRevision) {
|
|
189
|
+
return new TriggerError({
|
|
190
|
+
code: "revision_mismatch",
|
|
191
|
+
message: `trigger ${fire.triggerId} is at revision ${snapshot.revision}, not the claimed ${fire.expectedRevision}`
|
|
192
|
+
})
|
|
193
|
+
}
|
|
194
|
+
if (!snapshot.enabled) {
|
|
195
|
+
return new TriggerError({ code: "trigger_disabled", message: `trigger ${fire.triggerId} is disabled` })
|
|
196
|
+
}
|
|
197
|
+
return undefined
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Applies the claim protocol to a snapshot.
|
|
202
|
+
*
|
|
203
|
+
* @category decision
|
|
204
|
+
* @since 1.0.0-rc.0
|
|
205
|
+
*/
|
|
206
|
+
export const decide = (input: Input): Decision => {
|
|
207
|
+
const refusal = refuse(input.snapshot, input.fire)
|
|
208
|
+
return refusal === undefined ? decideFenced(input) : { _tag: "Refused", error: refusal }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Decides over a snapshot already admitted by {@link refuse} in the same
|
|
213
|
+
* transaction. This avoids checking the same immutable revision twice.
|
|
214
|
+
* @category decision
|
|
215
|
+
* @since 1.0.0-rc.0
|
|
216
|
+
* @private
|
|
217
|
+
*/
|
|
218
|
+
export const decideFenced = ({ claimedAt, fire, reservationId, snapshot }: Input): AdmittedDecision => {
|
|
219
|
+
let activeRunId = snapshot.activeRunId
|
|
220
|
+
let pending = snapshot.pending
|
|
221
|
+
// A reservation with no claim timestamp predates the lease column. Nothing
|
|
222
|
+
// writes that shape now, so treating it as expired is the only way such a
|
|
223
|
+
// row is ever reclaimed.
|
|
224
|
+
const expiredReservation = activeRunId !== undefined && isReservation(activeRunId) &&
|
|
225
|
+
(snapshot.activeClaimedAt === undefined || snapshot.activeClaimedAt <= claimedAt - reservationLeaseMs)
|
|
226
|
+
? activeRunId
|
|
227
|
+
: undefined
|
|
228
|
+
const existing = snapshot.existingFire
|
|
229
|
+
if (existing !== undefined) {
|
|
230
|
+
const resumableBuffer = fire.resumeBuffered === true && existing.outcome === "buffered"
|
|
231
|
+
const resumableReservation = existing.outcome === null &&
|
|
232
|
+
(activeRunId === undefined ||
|
|
233
|
+
(reservationOccurrence(activeRunId) === fire.occurrence && expiredReservation !== undefined))
|
|
234
|
+
const resumableSupersede = fire.resumeBuffered === true && existing.outcome === null &&
|
|
235
|
+
snapshot.overlap === "supersede" && activeRunId !== undefined && existing.runId === activeRunId
|
|
236
|
+
if (!resumableBuffer && !resumableReservation && !resumableSupersede) return decided({ claimed: false }, [])
|
|
237
|
+
}
|
|
238
|
+
const writes: Array<Write> = []
|
|
239
|
+
if (expiredReservation !== undefined) {
|
|
240
|
+
const expiredOccurrence = reservationOccurrence(expiredReservation)
|
|
241
|
+
const expiredFire = expiredOccurrence === undefined ? undefined : snapshot.activeFire
|
|
242
|
+
if (
|
|
243
|
+
expiredOccurrence !== undefined && expiredFire !== undefined &&
|
|
244
|
+
(expiredFire.outcome === null || expiredFire.outcome === "buffered")
|
|
245
|
+
) {
|
|
246
|
+
if (snapshot.overlap === "supersede") {
|
|
247
|
+
const predecessor = expiredFire.runId
|
|
248
|
+
activeRunId = predecessor !== undefined && !isReservation(predecessor) ? predecessor : undefined
|
|
249
|
+
if (expiredOccurrence !== fire.occurrence) {
|
|
250
|
+
writes.push({ _tag: "SetOutcome", occurrence: expiredOccurrence, outcome: "superseded", whileOpen: false })
|
|
251
|
+
}
|
|
252
|
+
} else {
|
|
253
|
+
activeRunId = undefined
|
|
254
|
+
if (expiredOccurrence !== fire.occurrence) {
|
|
255
|
+
pending = Overlap.pendingAfter({ running: false, pending, due: expiredOccurrence })
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
} else {
|
|
259
|
+
activeRunId = undefined
|
|
260
|
+
}
|
|
261
|
+
writes.push({ _tag: "ReleaseReservation", expected: expiredReservation, activeRunId, pending })
|
|
262
|
+
}
|
|
263
|
+
const state: Overlap.State = { running: activeRunId !== undefined, pending, due: fire.occurrence }
|
|
264
|
+
const action = Overlap.decide(snapshot.overlap, state)
|
|
265
|
+
// A skip or buffer is complete inside the claim transaction. A fire or
|
|
266
|
+
// supersede is only reserved here; its cursor advances when `recordResult`
|
|
267
|
+
// makes the launched run durable.
|
|
268
|
+
if (action === "skip" || action === "buffer") {
|
|
269
|
+
writes.push({
|
|
270
|
+
_tag: "SetOutcome",
|
|
271
|
+
occurrence: fire.occurrence,
|
|
272
|
+
outcome: action === "skip" ? "skipped" : "buffered",
|
|
273
|
+
whileOpen: false
|
|
274
|
+
})
|
|
275
|
+
writes.push({ _tag: "AdvanceCursor", occurrence: fire.occurrence })
|
|
276
|
+
if (action === "buffer") writes.push({ _tag: "SetPending", occurrence: Overlap.pendingAfter(state) })
|
|
277
|
+
return decided({ claimed: true, action }, writes)
|
|
278
|
+
}
|
|
279
|
+
let supersededRunId = activeRunId
|
|
280
|
+
if (action === "supersede" && activeRunId !== undefined) {
|
|
281
|
+
if (isReservation(activeRunId)) {
|
|
282
|
+
const activeOccurrence = reservationOccurrence(activeRunId)
|
|
283
|
+
if (activeOccurrence !== undefined) {
|
|
284
|
+
const predecessor = snapshot.activeFire?.runId
|
|
285
|
+
if (predecessor !== undefined && !isReservation(predecessor)) supersededRunId = predecessor
|
|
286
|
+
writes.push({ _tag: "SetOutcome", occurrence: activeOccurrence, outcome: "superseded", whileOpen: true })
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
if (supersededRunId !== undefined && !isReservation(supersededRunId)) {
|
|
290
|
+
writes.push({ _tag: "SetRunId", occurrence: fire.occurrence, runId: supersededRunId })
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
writes.push({ _tag: "Reserve", reservationId, claimedAt })
|
|
294
|
+
return decided(
|
|
295
|
+
{
|
|
296
|
+
claimed: true,
|
|
297
|
+
action,
|
|
298
|
+
reservationId,
|
|
299
|
+
...(supersededRunId === undefined ? {} : { activeRunId: supersededRunId })
|
|
300
|
+
},
|
|
301
|
+
writes
|
|
302
|
+
)
|
|
303
|
+
}
|
package/src/Cron.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed wrappers around Effect Cron.
|
|
3
|
+
*
|
|
4
|
+
* Effect's field parser only range-checks each field, so an expression like
|
|
5
|
+
* `0 0 30 2 *` parses and then never matches a calendar date. Its occurrence
|
|
6
|
+
* search answers that by exhausting its bound and throwing, which inside
|
|
7
|
+
* `Effect.gen` is a defect rather than a typed failure: the fiber that owns the
|
|
8
|
+
* search dies instead of reporting. Every search here is wrapped so the
|
|
9
|
+
* exhausted bound arrives as `unsatisfiable_cron`, and {@link parse} probes for
|
|
10
|
+
* one occurrence so an unsatisfiable expression is refused where it is
|
|
11
|
+
* declared rather than at the tick that would have fired it.
|
|
12
|
+
*
|
|
13
|
+
* A satisfiable expression carries the opposite hazard: `* * * * *` across a
|
|
14
|
+
* year is 525,600 occurrences, and materializing them costs seconds and
|
|
15
|
+
* hundreds of megabytes. {@link occurrencesBetween} therefore always searches
|
|
16
|
+
* under a finite cap and reports when an unstated limit would exceed
|
|
17
|
+
* {@link maxOccurrences}.
|
|
18
|
+
*
|
|
19
|
+
* A timezone makes the expression a wall-clock schedule. A wall time that
|
|
20
|
+
* spring forward skips fires at the instant it would have had on the old
|
|
21
|
+
* offset, so a daily 02:30 fires at 03:30 daylight time. A wall time that fall
|
|
22
|
+
* back repeats fires once, at its first instant, unless the hour field is every
|
|
23
|
+
* hour, in which case both passes fire. Occurrences are whole seconds in UTC,
|
|
24
|
+
* whatever the host's zone.
|
|
25
|
+
*
|
|
26
|
+
* @since 0.1.0
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import * as Clock from "effect/Clock"
|
|
30
|
+
import * as EffectCron from "effect/Cron"
|
|
31
|
+
import * as Effect from "effect/Effect"
|
|
32
|
+
import * as Result from "effect/Result"
|
|
33
|
+
import * as WallClock from "./internal/WallClock.ts"
|
|
34
|
+
import { TriggerError } from "./TriggerError.ts"
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A parsed cron expression, kept beside the text it came from so the
|
|
38
|
+
* declaration can be round-tripped.
|
|
39
|
+
*
|
|
40
|
+
* @category models
|
|
41
|
+
* @since 0.1.0
|
|
42
|
+
*/
|
|
43
|
+
export interface Cron {
|
|
44
|
+
readonly expression: string
|
|
45
|
+
readonly timezone?: string | undefined
|
|
46
|
+
readonly value: EffectCron.Cron
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const exhausted = (cron: Cron, search: string, cause: unknown): TriggerError =>
|
|
50
|
+
new TriggerError({
|
|
51
|
+
code: "unsatisfiable_cron",
|
|
52
|
+
message: `cron expression '${cron.expression}' has no ${search} occurrence`,
|
|
53
|
+
cause
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
const attempt = <A>(cron: Cron, search: string, find: () => A): Effect.Effect<A, TriggerError> =>
|
|
57
|
+
Effect.try({ try: find, catch: (cause) => exhausted(cron, search, cause) })
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The first occurrence strictly after `from`.
|
|
61
|
+
*
|
|
62
|
+
* @category getters
|
|
63
|
+
* @since 0.1.0
|
|
64
|
+
*/
|
|
65
|
+
export const next = (cron: Cron, from: Date): Effect.Effect<Date, TriggerError> =>
|
|
66
|
+
attempt(cron, "next", () => new Date(WallClock.next(WallClock.compile(cron.value), from.getTime())))
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Returns the latest occurrence at or before `at`.
|
|
70
|
+
*
|
|
71
|
+
* @category getters
|
|
72
|
+
* @since 0.1.0
|
|
73
|
+
*/
|
|
74
|
+
export const previousAtOrBefore = (cron: Cron, at: Date): Effect.Effect<Date, TriggerError> =>
|
|
75
|
+
attempt(
|
|
76
|
+
cron,
|
|
77
|
+
"previous",
|
|
78
|
+
() => new Date(WallClock.previousAtOrBefore(WallClock.compile(cron.value), at.getTime()))
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The greatest number of occurrences one search returns when its caller states
|
|
83
|
+
* no limit of its own.
|
|
84
|
+
*
|
|
85
|
+
* @category constants
|
|
86
|
+
* @since 0.1.0
|
|
87
|
+
*/
|
|
88
|
+
export const maxOccurrences = 1000
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The occurrences in `(from, to]`, in order.
|
|
92
|
+
*
|
|
93
|
+
* A stated `limit` caps the result silently and must be a non-negative safe
|
|
94
|
+
* integer. With no stated limit, the search fails with
|
|
95
|
+
* `catch_up_bound_exceeded` when the interval holds more than
|
|
96
|
+
* {@link maxOccurrences}. A caller with its own bound can pass one more than
|
|
97
|
+
* that bound and compare the length, the way `CatchUp.occurrences` does.
|
|
98
|
+
*
|
|
99
|
+
* @category sequencing
|
|
100
|
+
* @since 0.1.0
|
|
101
|
+
*/
|
|
102
|
+
export const occurrencesBetween = (
|
|
103
|
+
cron: Cron,
|
|
104
|
+
from: Date,
|
|
105
|
+
to: Date,
|
|
106
|
+
limit?: number | undefined
|
|
107
|
+
): Effect.Effect<ReadonlyArray<Date>, TriggerError> => {
|
|
108
|
+
if (limit !== undefined && (!Number.isSafeInteger(limit) || limit < 0)) {
|
|
109
|
+
return Effect.fail(
|
|
110
|
+
new TriggerError({
|
|
111
|
+
code: "invalid_options",
|
|
112
|
+
message: `occurrence limit must be a non-negative safe integer, received ${limit}`,
|
|
113
|
+
path: "limit"
|
|
114
|
+
})
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
const searchLimit = limit ?? maxOccurrences + 1
|
|
118
|
+
if (searchLimit === 0) return Effect.succeed([])
|
|
119
|
+
const search = attempt(cron, "interval", () => {
|
|
120
|
+
const wall = WallClock.compile(cron.value)
|
|
121
|
+
const occurrences: Array<Date> = []
|
|
122
|
+
let cursor = from.getTime()
|
|
123
|
+
while (occurrences.length < searchLimit) {
|
|
124
|
+
cursor = WallClock.next(wall, cursor)
|
|
125
|
+
if (cursor > to.getTime()) break
|
|
126
|
+
occurrences.push(new Date(cursor))
|
|
127
|
+
}
|
|
128
|
+
return occurrences
|
|
129
|
+
})
|
|
130
|
+
if (limit !== undefined) return search
|
|
131
|
+
return search.pipe(
|
|
132
|
+
Effect.flatMap((occurrences) =>
|
|
133
|
+
occurrences.length > maxOccurrences
|
|
134
|
+
? Effect.fail(
|
|
135
|
+
new TriggerError({
|
|
136
|
+
code: "catch_up_bound_exceeded",
|
|
137
|
+
message: `interval contains more than ${maxOccurrences} occurrences; maxOccurrences is ${maxOccurrences}`
|
|
138
|
+
})
|
|
139
|
+
)
|
|
140
|
+
: Effect.succeed(occurrences)
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Parses a cron expression in an optional timezone, reporting a malformed one
|
|
147
|
+
* as `invalid_cron` and one the calendar never satisfies as
|
|
148
|
+
* `unsatisfiable_cron`.
|
|
149
|
+
*
|
|
150
|
+
* The satisfiability probe searches forward from the current instant, which is
|
|
151
|
+
* the same search every tick performs, so an expression that survives parsing
|
|
152
|
+
* is one the scheduler can keep answering.
|
|
153
|
+
*
|
|
154
|
+
* @category constructors
|
|
155
|
+
* @since 0.1.0
|
|
156
|
+
*/
|
|
157
|
+
export const parse = (expression: string, timezone?: string): Effect.Effect<Cron, TriggerError> =>
|
|
158
|
+
Effect.gen(function*() {
|
|
159
|
+
const parsed = EffectCron.parse(expression, timezone)
|
|
160
|
+
if (Result.isFailure(parsed)) {
|
|
161
|
+
return yield* Effect.fail(
|
|
162
|
+
new TriggerError({ code: "invalid_cron", message: parsed.failure.message, cause: parsed.failure })
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
const cron: Cron = {
|
|
166
|
+
expression,
|
|
167
|
+
...(timezone === undefined ? {} : { timezone }),
|
|
168
|
+
value: parsed.success
|
|
169
|
+
}
|
|
170
|
+
const now = yield* Clock.currentTimeMillis
|
|
171
|
+
yield* next(cron, new Date(now))
|
|
172
|
+
return cron
|
|
173
|
+
})
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `@smthrs/control` `DispatchReader` port served from a `TriggerStore`.
|
|
3
|
+
*
|
|
4
|
+
* `Control.list` answers `{ _tag: "triggers" }` and `{ _tag: "fires" }` through
|
|
5
|
+
* the port declared in `@smthrs/control/DispatchReader`. The port lives there
|
|
6
|
+
* because this package depends on control (the scheduler launches runs through
|
|
7
|
+
* it); the adapter lives here because only this package can read the store.
|
|
8
|
+
* The host composes the two: {@link layer} over the same store the scheduler
|
|
9
|
+
* writes.
|
|
10
|
+
*
|
|
11
|
+
* @see packages/smithers/agent/triggers/docs/api.md
|
|
12
|
+
*
|
|
13
|
+
* @since 1.0.0-rc.0
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { ControlError } from "@smthrs/control/ControlError"
|
|
17
|
+
import { PersistenceError } from "@smthrs/control/ControlError"
|
|
18
|
+
import { defaultPageSize, type FireSummary, type TriggerSummary } from "@smthrs/control/ControlSchema"
|
|
19
|
+
import * as Port from "@smthrs/control/DispatchReader"
|
|
20
|
+
import * as Clock from "effect/Clock"
|
|
21
|
+
import * as Effect from "effect/Effect"
|
|
22
|
+
import * as Layer from "effect/Layer"
|
|
23
|
+
import * as Option from "effect/Option"
|
|
24
|
+
import * as Result from "effect/Result"
|
|
25
|
+
import * as Cron from "./Cron.ts"
|
|
26
|
+
import type { TriggerError } from "./TriggerError.ts"
|
|
27
|
+
import { type FireRecord, type Held, isReservation, type Registered, TriggerStore } from "./TriggerStore.ts"
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* How many upcoming occurrences a trigger summary carries.
|
|
31
|
+
*
|
|
32
|
+
* @category constants
|
|
33
|
+
* @since 1.0.0-rc.0
|
|
34
|
+
*/
|
|
35
|
+
export const nextOccurrenceCount = 5
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* How many ledger rows one `fires` page needs: every row before the page's
|
|
39
|
+
* offset cursor, the page, and one more so `Control.list` can tell a next page
|
|
40
|
+
* exists. A cursor Control will refuse reads nothing extra: it answers
|
|
41
|
+
* `undefined`, and the refusal comes from Control.
|
|
42
|
+
*/
|
|
43
|
+
const firesNeeded = (request: Port.FiresRequest): number | undefined => {
|
|
44
|
+
const start = request.cursor === undefined ? 0 : Number(request.cursor)
|
|
45
|
+
const size = request.limit ?? defaultPageSize
|
|
46
|
+
return Number.isSafeInteger(start) && start >= 0 && Number.isSafeInteger(size) && size >= 1
|
|
47
|
+
? start + size + 1
|
|
48
|
+
: undefined
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const persistence = (operation: string) => (error: TriggerError): ControlError =>
|
|
52
|
+
new PersistenceError({ operation, message: error.message, cause: error })
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The next {@link nextOccurrenceCount} occurrences strictly after `now`, as
|
|
56
|
+
* epoch milliseconds in ascending order.
|
|
57
|
+
*
|
|
58
|
+
* @category getters
|
|
59
|
+
* @since 1.0.0-rc.0
|
|
60
|
+
*/
|
|
61
|
+
export const nextOccurrences = (
|
|
62
|
+
trigger: Pick<Registered, "cron" | "timezone">,
|
|
63
|
+
now: number
|
|
64
|
+
): Effect.Effect<ReadonlyArray<number>, TriggerError> =>
|
|
65
|
+
Effect.gen(function*() {
|
|
66
|
+
const cron = yield* Cron.parse(trigger.cron, trigger.timezone)
|
|
67
|
+
const occurrences: Array<number> = []
|
|
68
|
+
let from = new Date(now)
|
|
69
|
+
while (occurrences.length < nextOccurrenceCount) {
|
|
70
|
+
from = yield* Cron.next(cron, from)
|
|
71
|
+
occurrences.push(from.getTime())
|
|
72
|
+
}
|
|
73
|
+
return occurrences
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Maps one stored trigger and what it holds to the control plane's summary.
|
|
78
|
+
*
|
|
79
|
+
* A launch reservation is not a run the runtime knows about, so it is
|
|
80
|
+
* reported as no active run; the ledger still shows the claimed occurrence.
|
|
81
|
+
*
|
|
82
|
+
* @category constructors
|
|
83
|
+
* @since 1.0.0-rc.0
|
|
84
|
+
*/
|
|
85
|
+
export const toTriggerSummary = (
|
|
86
|
+
trigger: Registered,
|
|
87
|
+
held: Held,
|
|
88
|
+
nextOccurrencesMs: ReadonlyArray<number>,
|
|
89
|
+
schedulerLastTickMs: Option.Option<number>
|
|
90
|
+
): TriggerSummary => ({
|
|
91
|
+
triggerId: trigger.id,
|
|
92
|
+
flowId: trigger.flowId,
|
|
93
|
+
input: trigger.input,
|
|
94
|
+
cron: trigger.cron,
|
|
95
|
+
...(trigger.timezone === undefined ? {} : { timezone: trigger.timezone }),
|
|
96
|
+
overlap: trigger.overlap,
|
|
97
|
+
catchUp: trigger.catchUp,
|
|
98
|
+
maxCatchUp: trigger.maxCatchUp,
|
|
99
|
+
enabled: trigger.enabled,
|
|
100
|
+
revision: trigger.revision,
|
|
101
|
+
...(trigger.lastFiredAt === undefined ? {} : { lastFiredAtMs: trigger.lastFiredAt }),
|
|
102
|
+
...(held.pendingAt === undefined ? {} : { pendingAtMs: held.pendingAt }),
|
|
103
|
+
...(held.activeRunId === undefined || isReservation(held.activeRunId) ? {} : { activeRunId: held.activeRunId }),
|
|
104
|
+
nextOccurrencesMs,
|
|
105
|
+
...(Option.isSome(schedulerLastTickMs) ? { schedulerLastTickMs: schedulerLastTickMs.value } : {})
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Maps one ledger row to the control plane's fire summary.
|
|
110
|
+
*
|
|
111
|
+
* @category constructors
|
|
112
|
+
* @since 1.0.0-rc.0
|
|
113
|
+
*/
|
|
114
|
+
export const toFireSummary = (record: FireRecord): FireSummary => ({
|
|
115
|
+
triggerId: record.triggerId,
|
|
116
|
+
occurrenceAtMs: record.occurrence,
|
|
117
|
+
outcome: record.outcome,
|
|
118
|
+
...(record.runId === undefined ? {} : { runId: record.runId }),
|
|
119
|
+
...(record.error === undefined ? {} : { error: record.error })
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Builds the port over the ambient {@link TriggerStore}.
|
|
124
|
+
*
|
|
125
|
+
* `list` answers every trigger the store holds, taking each row's held state
|
|
126
|
+
* from the listing itself; `fires` pushes the request's filters into the
|
|
127
|
+
* ledger query and answers the matching rows newest first, reading only as
|
|
128
|
+
* many as the requested page needs.
|
|
129
|
+
* `Control.list` applies the filters again and pages. A store failure is a
|
|
130
|
+
* `PersistenceError` naming the listing that failed.
|
|
131
|
+
*
|
|
132
|
+
* @category constructors
|
|
133
|
+
* @since 1.0.0-rc.0
|
|
134
|
+
*/
|
|
135
|
+
export const make: Effect.Effect<Port.Service, never, TriggerStore> = Effect.gen(function*() {
|
|
136
|
+
const store = yield* TriggerStore
|
|
137
|
+
return Port.make({
|
|
138
|
+
list: Effect.fn("DispatchReader.list")(() =>
|
|
139
|
+
Effect.gen(function*() {
|
|
140
|
+
const now = yield* Clock.currentTimeMillis
|
|
141
|
+
const heartbeat = yield* store.lastHeartbeat()
|
|
142
|
+
const lastTick = Option.map(heartbeat, (beat) => beat.tickedAt)
|
|
143
|
+
const listing = yield* store.list()
|
|
144
|
+
return yield* Effect.forEach(listing, (row) =>
|
|
145
|
+
Effect.gen(function*() {
|
|
146
|
+
// A row the store could not decode fails the listing rather than
|
|
147
|
+
// being reported as a trigger with invented fields; the failure
|
|
148
|
+
// names the row. The listing already carries what `inspect` reads,
|
|
149
|
+
// so a summary costs no second read per trigger.
|
|
150
|
+
if (Result.isFailure(row.trigger)) return yield* Effect.fail(row.trigger.failure)
|
|
151
|
+
const trigger = row.trigger.success
|
|
152
|
+
const upcoming = yield* nextOccurrences(trigger, now)
|
|
153
|
+
return toTriggerSummary(trigger, row, upcoming, lastTick)
|
|
154
|
+
}))
|
|
155
|
+
}).pipe(Effect.mapError(persistence("triggers")))
|
|
156
|
+
),
|
|
157
|
+
fires: Effect.fn("DispatchReader.fires")((request) =>
|
|
158
|
+
store.history({
|
|
159
|
+
triggerId: request.filters?.triggerId,
|
|
160
|
+
runId: request.filters?.runId,
|
|
161
|
+
outcome: request.filters?.outcome,
|
|
162
|
+
limit: firesNeeded(request)
|
|
163
|
+
}).pipe(
|
|
164
|
+
Effect.map((page) => page.items.map(toFireSummary)),
|
|
165
|
+
Effect.mapError(persistence("fires"))
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
})
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Provides the control plane's `DispatchReader` from the ambient
|
|
173
|
+
* {@link TriggerStore}, so `Control.list` answers `triggers` and `fires` from
|
|
174
|
+
* the store the scheduler writes.
|
|
175
|
+
*
|
|
176
|
+
* @category layers
|
|
177
|
+
* @since 1.0.0-rc.0
|
|
178
|
+
*/
|
|
179
|
+
export const layer: Layer.Layer<Port.DispatchReader, never, TriggerStore> = Layer.effect(Port.DispatchReader)(make)
|