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