@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
package/src/Webhook.ts
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verified webhook ingestion through the authoritative Control boundary.
|
|
3
|
+
*
|
|
4
|
+
* @see packages/smithers/agent/triggers/docs/api.md
|
|
5
|
+
* @since 0.1.0
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as ControlChannels from "@smthrs/control/Channels"
|
|
9
|
+
import type { ControlError } from "@smthrs/control/ControlError"
|
|
10
|
+
import { InvalidInput, Unauthorized } from "@smthrs/control/ControlError"
|
|
11
|
+
import type { Receipt, RunSummary } from "@smthrs/control/ControlSchema"
|
|
12
|
+
import type { CredentialRef } from "@smthrs/control/Credential"
|
|
13
|
+
import * as ControlWebhook from "@smthrs/control/WebhookChannel"
|
|
14
|
+
import * as Effect from "effect/Effect"
|
|
15
|
+
import type * as Redacted from "effect/Redacted"
|
|
16
|
+
import * as Schema from "effect/Schema"
|
|
17
|
+
import type * as Channel from "./Channel.ts"
|
|
18
|
+
import { TriggerError } from "./TriggerError.ts"
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Compares a supplied byte string against the expected one without returning
|
|
22
|
+
* early on a mismatch.
|
|
23
|
+
*
|
|
24
|
+
* The loop runs exactly `expected.length` times, so its iteration count is
|
|
25
|
+
* fixed by the secret side of the comparison and never by the caller's. Reading
|
|
26
|
+
* the longer of the two instead let a caller lengthen its input until the work
|
|
27
|
+
* stopped growing, which reports the expected signature's length. The length
|
|
28
|
+
* difference is folded into the result, so inputs of unequal length always
|
|
29
|
+
* disagree.
|
|
30
|
+
*
|
|
31
|
+
* @category verification
|
|
32
|
+
* @since 0.1.0
|
|
33
|
+
*/
|
|
34
|
+
export const constantTimeEqual = (expected: Uint8Array, supplied: Uint8Array): boolean => {
|
|
35
|
+
let difference = expected.length ^ supplied.length
|
|
36
|
+
for (const [index, byte] of expected.entries()) {
|
|
37
|
+
difference |= byte ^ (supplied[index] ?? 0)
|
|
38
|
+
}
|
|
39
|
+
return difference === 0
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Configuration for a raw-byte signature verifier.
|
|
44
|
+
*
|
|
45
|
+
* `expected` receives a private copy of the request bytes and the redacted
|
|
46
|
+
* credential reference the channel was declared with, and answers with the
|
|
47
|
+
* signature bytes the request must carry in `header`. It returns an Effect so
|
|
48
|
+
* the secret is resolved through the host's resolver per request rather than
|
|
49
|
+
* captured in a closure at declaration time, and so a resolution or HMAC
|
|
50
|
+
* failure arrives as a typed `verification_failed` instead of a defect that
|
|
51
|
+
* kills the fiber.
|
|
52
|
+
*
|
|
53
|
+
* @category models
|
|
54
|
+
* @since 0.1.0
|
|
55
|
+
*/
|
|
56
|
+
export interface SignatureConfig {
|
|
57
|
+
readonly header: string
|
|
58
|
+
readonly expected: (
|
|
59
|
+
body: Uint8Array,
|
|
60
|
+
credential: Redacted.Redacted<CredentialRef>
|
|
61
|
+
) => Effect.Effect<Uint8Array, TriggerError>
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Copies request bytes into memory nobody else holds.
|
|
66
|
+
*
|
|
67
|
+
* `body.slice()` is not enough: a Node `Buffer` is a `Uint8Array` whose
|
|
68
|
+
* `slice` is an alias of `subarray` and answers with a view over the same
|
|
69
|
+
* memory, so a caller that reused its `Buffer` after handing it over changed
|
|
70
|
+
* what got verified, and a verifier that edited the bytes it was handed edited
|
|
71
|
+
* the caller's. `Uint8Array.from` always allocates, and it copies only the
|
|
72
|
+
* viewed range of an offset view and only a snapshot of a `SharedArrayBuffer`.
|
|
73
|
+
*/
|
|
74
|
+
const ownedCopy = (body: Uint8Array): Uint8Array => Uint8Array.from(body)
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Builds a verifier that compares a signature header in constant time.
|
|
78
|
+
*
|
|
79
|
+
* An absent or empty header is refused before `expected` runs, and an
|
|
80
|
+
* `expected` that answers with zero bytes is refused after it. Neither is a
|
|
81
|
+
* comparison the constant-time equality can decide: it agrees on two empty
|
|
82
|
+
* byte strings, so a request carrying no signature at all would have
|
|
83
|
+
* authenticated against a secret that resolved to the empty string. A
|
|
84
|
+
* zero-length expected signature is a misconfigured credential, never a valid
|
|
85
|
+
* one, so the door stays closed instead of opening for everyone.
|
|
86
|
+
*
|
|
87
|
+
* Every refusal carries the same message, `webhook signature in <header> did
|
|
88
|
+
* not verify`, whatever the reason. A failure raised by `expected` is kept in
|
|
89
|
+
* `cause` only: its message names the credential reference or the resolver
|
|
90
|
+
* that could not produce a secret, which is a host detail, and the refusal
|
|
91
|
+
* that answers a bad signature answers a broken resolver the same way.
|
|
92
|
+
*
|
|
93
|
+
* @category constructors
|
|
94
|
+
* @since 0.1.0
|
|
95
|
+
*/
|
|
96
|
+
export const makeSignatureVerifier = (config: SignatureConfig): Channel.Verify => (raw, credential) => {
|
|
97
|
+
const refuse = (cause?: TriggerError) =>
|
|
98
|
+
Effect.fail(
|
|
99
|
+
new TriggerError({
|
|
100
|
+
code: "verification_failed",
|
|
101
|
+
message: `webhook signature in ${config.header} did not verify`,
|
|
102
|
+
...(cause === undefined ? {} : { cause })
|
|
103
|
+
})
|
|
104
|
+
)
|
|
105
|
+
return Effect.suspend(() => {
|
|
106
|
+
const supplied = raw.headers[config.header.toLowerCase()] ?? raw.headers[config.header]
|
|
107
|
+
if (supplied === undefined || supplied.length === 0) {
|
|
108
|
+
return refuse()
|
|
109
|
+
}
|
|
110
|
+
const actual = new TextEncoder().encode(supplied)
|
|
111
|
+
// The verifier gets its own copy: nothing it does to these bytes can reach
|
|
112
|
+
// the buffer that is about to be fingerprinted and decoded.
|
|
113
|
+
return config.expected(ownedCopy(raw.body), credential).pipe(
|
|
114
|
+
Effect.matchEffect({
|
|
115
|
+
onFailure: (error) => refuse(error),
|
|
116
|
+
onSuccess: (expected) =>
|
|
117
|
+
expected.length > 0 && constantTimeEqual(expected, actual)
|
|
118
|
+
? Effect.void
|
|
119
|
+
: refuse()
|
|
120
|
+
})
|
|
121
|
+
)
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Webhook declaration configuration.
|
|
127
|
+
*
|
|
128
|
+
* `credential` is required. It used to default to a reference named after the
|
|
129
|
+
* channel, so a webhook declared without one verified against whatever
|
|
130
|
+
* credential happened to share its name instead of being refused, and two
|
|
131
|
+
* declarations differing only in credential verified identically.
|
|
132
|
+
*
|
|
133
|
+
* @category models
|
|
134
|
+
* @since 0.1.0
|
|
135
|
+
*/
|
|
136
|
+
export interface Config<Payload, Outbound = never> extends Channel.Config<Payload, RunSummary, Outbound> {
|
|
137
|
+
readonly credential: Redacted.Redacted<CredentialRef>
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* A webhook door that can only ingest through `Channels` and `Control`.
|
|
142
|
+
*
|
|
143
|
+
* @category services
|
|
144
|
+
* @since 0.1.0
|
|
145
|
+
*/
|
|
146
|
+
export interface Webhook {
|
|
147
|
+
readonly name: string
|
|
148
|
+
readonly register: Effect.Effect<void, never, ControlChannels.Channels>
|
|
149
|
+
readonly ingest: (
|
|
150
|
+
raw: Channel.RawInbound
|
|
151
|
+
) => Effect.Effect<Receipt, ControlError | TriggerError, ControlChannels.Channels>
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const invalidInput = (issue: string): InvalidInput => new InvalidInput({ issue })
|
|
155
|
+
|
|
156
|
+
const toControlChannel = <Payload, Outbound>(
|
|
157
|
+
config: Config<Payload, Outbound>
|
|
158
|
+
): ControlChannels.Channel<Payload> => {
|
|
159
|
+
const channel = ControlWebhook.make({
|
|
160
|
+
name: config.name,
|
|
161
|
+
schema: config.schema,
|
|
162
|
+
credential: config.credential,
|
|
163
|
+
// The message is fixed here rather than forwarded from the verifier: a
|
|
164
|
+
// custom `verify` may say why it refused, and that reason travels toward
|
|
165
|
+
// the unauthenticated sender on the same error that answers a bad
|
|
166
|
+
// signature. `Unauthorized` carries no cause, so nothing else survives
|
|
167
|
+
// the crossing.
|
|
168
|
+
verify: (raw, credential) =>
|
|
169
|
+
config.verify(raw, credential).pipe(
|
|
170
|
+
Effect.mapError(() => new Unauthorized({ message: `webhook ${config.name} did not verify the request` }))
|
|
171
|
+
),
|
|
172
|
+
map: (payload) => {
|
|
173
|
+
const inbound = config.inbound(payload)
|
|
174
|
+
if ("start" in inbound) {
|
|
175
|
+
return Effect.succeed({
|
|
176
|
+
_tag: "Start" as const,
|
|
177
|
+
flowId: inbound.start.flowId,
|
|
178
|
+
input: inbound.start.input
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
return Schema.decodeUnknownEffect(Schema.Json)(inbound.signal.value).pipe(
|
|
182
|
+
Effect.map(
|
|
183
|
+
(value) => ({
|
|
184
|
+
_tag: "Signal" as const,
|
|
185
|
+
runId: inbound.signal.runId,
|
|
186
|
+
signal: {
|
|
187
|
+
name: inbound.signal.stepId,
|
|
188
|
+
payload: value
|
|
189
|
+
}
|
|
190
|
+
})
|
|
191
|
+
),
|
|
192
|
+
Effect.mapError((error) => invalidInput(String(error)))
|
|
193
|
+
)
|
|
194
|
+
},
|
|
195
|
+
project: (run) => {
|
|
196
|
+
if (config.outbound === undefined) {
|
|
197
|
+
return {
|
|
198
|
+
cursor: String(run.updatedAt),
|
|
199
|
+
operation: "noop" as const,
|
|
200
|
+
message: null
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
cursor: String(run.updatedAt),
|
|
205
|
+
operation: "post" as const,
|
|
206
|
+
message: config.outbound(run)
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
})
|
|
210
|
+
return channel
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Builds a webhook door whose only dispatch path is the Control channel
|
|
215
|
+
* coordinator.
|
|
216
|
+
*
|
|
217
|
+
* Verification occurs inside `Channels.ingest` before the adapter's JSON or
|
|
218
|
+
* schema decoder and before any Control operation.
|
|
219
|
+
*
|
|
220
|
+
* `ingest` does not register: a channel is registered once, deliberately,
|
|
221
|
+
* through {@link Webhook.register}, so traffic to an unregistered channel is
|
|
222
|
+
* reported as unavailable rather than silently self-registering the door it
|
|
223
|
+
* arrived at.
|
|
224
|
+
*
|
|
225
|
+
* `ingest` also copies `body`, `headers`, and `idempotencyKey` before anything
|
|
226
|
+
* reads them. Verification, delivery fingerprinting, and decoding then all see
|
|
227
|
+
* one private snapshot, so a verifier that edits the bytes it was handed, or a
|
|
228
|
+
* caller that mutates its own object between building this Effect and running
|
|
229
|
+
* it, cannot authenticate one payload and have another decoded. A
|
|
230
|
+
* `SharedArrayBuffer`-backed view is copied out of shared memory by the same
|
|
231
|
+
* step.
|
|
232
|
+
*
|
|
233
|
+
* @category constructors
|
|
234
|
+
* @since 0.1.0
|
|
235
|
+
*/
|
|
236
|
+
export const make = <Payload, Outbound = never>(
|
|
237
|
+
config: Config<Payload, Outbound>
|
|
238
|
+
): Webhook => {
|
|
239
|
+
const channel = toControlChannel(config)
|
|
240
|
+
const register = Effect.flatMap(ControlChannels.Channels, (channels) => channels.register(channel))
|
|
241
|
+
return {
|
|
242
|
+
name: config.name,
|
|
243
|
+
register,
|
|
244
|
+
ingest: (raw) => {
|
|
245
|
+
// Copied here rather than inside the Effect: the copy has to happen when
|
|
246
|
+
// the caller hands the request over, not when the returned Effect
|
|
247
|
+
// eventually runs, or a caller that reuses its own buffer in between
|
|
248
|
+
// changes what gets authenticated.
|
|
249
|
+
const snapshot: Channel.RawInbound = {
|
|
250
|
+
body: ownedCopy(raw.body),
|
|
251
|
+
headers: { ...raw.headers },
|
|
252
|
+
idempotencyKey: raw.idempotencyKey
|
|
253
|
+
}
|
|
254
|
+
return Effect.gen(function*() {
|
|
255
|
+
const channels = yield* ControlChannels.Channels
|
|
256
|
+
return yield* channels.ingest({ channel: config.name, raw: snapshot })
|
|
257
|
+
}).pipe(
|
|
258
|
+
Effect.mapError((error) =>
|
|
259
|
+
error instanceof Unauthorized
|
|
260
|
+
? new TriggerError({
|
|
261
|
+
code: "verification_failed",
|
|
262
|
+
message: `webhook ${config.name} did not verify the request`,
|
|
263
|
+
cause: error
|
|
264
|
+
})
|
|
265
|
+
: error
|
|
266
|
+
)
|
|
267
|
+
)
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/triggers` — durable schedules and verified inbound channels.
|
|
3
|
+
*
|
|
4
|
+
* @since 0.1.0
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** @category errors @since 0.1.0 */
|
|
8
|
+
export * as TriggerError from "./TriggerError.ts"
|
|
9
|
+
/** @category schedules @since 0.1.0 */
|
|
10
|
+
export * as Cron from "./Cron.ts"
|
|
11
|
+
/** @category declarations @since 0.1.0 */
|
|
12
|
+
export * as Trigger from "./Trigger.ts"
|
|
13
|
+
/** @category declarations @since 0.1.0 */
|
|
14
|
+
export * as Schedule from "./Schedule.ts"
|
|
15
|
+
/** @category policies @since 0.1.0 */
|
|
16
|
+
export * as Overlap from "./Overlap.ts"
|
|
17
|
+
/** @category policies @since 0.1.0 */
|
|
18
|
+
export * as CatchUp from "./CatchUp.ts"
|
|
19
|
+
/** @category services @since 0.1.0 */
|
|
20
|
+
export * as TriggerStore from "./TriggerStore.ts"
|
|
21
|
+
/** @category services @since 0.1.0 */
|
|
22
|
+
export * as SqlTriggerStore from "./SqlTriggerStore.ts"
|
|
23
|
+
/** @category scheduling @since 0.1.0 */
|
|
24
|
+
export * as Scheduler from "./Scheduler.ts"
|
|
25
|
+
/** @category services @since 1.0.0-rc.0 */
|
|
26
|
+
export * as DispatchReader from "./DispatchReader.ts"
|
|
27
|
+
/** @category channels @since 0.1.0 */
|
|
28
|
+
export * as Channel from "./Channel.ts"
|
|
29
|
+
/** @category webhooks @since 0.1.0 */
|
|
30
|
+
export * as Webhook from "./Webhook.ts"
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What one scheduler process knows about the run each trigger owns.
|
|
3
|
+
*
|
|
4
|
+
* @since 1.0.0-rc.0
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as Effect from "effect/Effect"
|
|
8
|
+
import type * as Fiber from "effect/Fiber"
|
|
9
|
+
import * as Ref from "effect/Ref"
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* What this process knows about the run one trigger currently owns: which
|
|
13
|
+
* occurrence claimed it, the reservation or run id the store holds for it, and
|
|
14
|
+
* the monitor fiber when this process is the one watching it.
|
|
15
|
+
*
|
|
16
|
+
* `runId` is always known. A claim that hands out work names the reservation it
|
|
17
|
+
* wrote, and a recovered entry is read back from the store, so an entry with
|
|
18
|
+
* nothing to release cannot be constructed.
|
|
19
|
+
*
|
|
20
|
+
* @private
|
|
21
|
+
* @since 1.0.0-rc.0
|
|
22
|
+
*/
|
|
23
|
+
export interface Active {
|
|
24
|
+
readonly occurrence: number
|
|
25
|
+
readonly runId: string
|
|
26
|
+
readonly fiber?: Fiber.Fiber<void> | undefined
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The entries of one scheduler, keyed by trigger id.
|
|
31
|
+
*
|
|
32
|
+
* `take` is the only unfenced write: an occurrence that claimed the trigger,
|
|
33
|
+
* or a run recovered from the store, replaces whatever entry was there. Every
|
|
34
|
+
* later write is fenced on the occurrence that took the entry, so one guard
|
|
35
|
+
* states the rule once: a launch that has been superseded, or whose run
|
|
36
|
+
* already settled, no longer owns the entry and must not write to it. Spelling
|
|
37
|
+
* the fence out at each site is how the three copies of it drifted apart.
|
|
38
|
+
*
|
|
39
|
+
* @private
|
|
40
|
+
* @since 1.0.0-rc.0
|
|
41
|
+
*/
|
|
42
|
+
export interface ActiveRuns {
|
|
43
|
+
readonly get: (triggerId: string) => Effect.Effect<Active | undefined>
|
|
44
|
+
readonly take: (triggerId: string, entry: Active) => Effect.Effect<void>
|
|
45
|
+
readonly update: (
|
|
46
|
+
triggerId: string,
|
|
47
|
+
occurrence: number,
|
|
48
|
+
change: (entry: Active) => Active
|
|
49
|
+
) => Effect.Effect<void>
|
|
50
|
+
readonly remove: (triggerId: string, occurrence: number) => Effect.Effect<void>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Allocates an empty set of entries.
|
|
55
|
+
*
|
|
56
|
+
* @private
|
|
57
|
+
* @since 1.0.0-rc.0
|
|
58
|
+
*/
|
|
59
|
+
export const make: Effect.Effect<ActiveRuns> = Effect.map(
|
|
60
|
+
Ref.make<ReadonlyMap<string, Active>>(new Map()),
|
|
61
|
+
(entries) => {
|
|
62
|
+
const fenced = (
|
|
63
|
+
triggerId: string,
|
|
64
|
+
occurrence: number,
|
|
65
|
+
change: (entry: Active) => Active | undefined
|
|
66
|
+
): Effect.Effect<void> =>
|
|
67
|
+
Ref.update(entries, (current) => {
|
|
68
|
+
const entry = current.get(triggerId)
|
|
69
|
+
if (entry?.occurrence !== occurrence) return current
|
|
70
|
+
const next = new Map(current)
|
|
71
|
+
const updated = change(entry)
|
|
72
|
+
if (updated === undefined) next.delete(triggerId)
|
|
73
|
+
else next.set(triggerId, updated)
|
|
74
|
+
return next
|
|
75
|
+
})
|
|
76
|
+
return {
|
|
77
|
+
get: (triggerId) => Effect.map(Ref.get(entries), (current) => current.get(triggerId)),
|
|
78
|
+
take: (triggerId, entry) => Ref.update(entries, (current) => new Map(current).set(triggerId, entry)),
|
|
79
|
+
update: fenced,
|
|
80
|
+
remove: (triggerId, occurrence) => fenced(triggerId, occurrence, () => undefined)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which occurrences one scheduler tick owes a trigger.
|
|
3
|
+
*
|
|
4
|
+
* @since 1.0.0-rc.0
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as Effect from "effect/Effect"
|
|
8
|
+
import * as CatchUp from "../CatchUp.ts"
|
|
9
|
+
import * as Cron from "../Cron.ts"
|
|
10
|
+
import type { TriggerError } from "../TriggerError.ts"
|
|
11
|
+
import type { Registered } from "../TriggerStore.ts"
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The occurrences to claim, oldest first, and the watermark that stands once
|
|
15
|
+
* every one of them has been dispatched.
|
|
16
|
+
*
|
|
17
|
+
* @private
|
|
18
|
+
* @since 1.0.0-rc.0
|
|
19
|
+
*/
|
|
20
|
+
export interface Due {
|
|
21
|
+
readonly occurrences: ReadonlyArray<number>
|
|
22
|
+
readonly watermark: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// A bound the declaration cannot honour is a statement about how much history
|
|
26
|
+
// to replay, not a reason to stop scheduling: the backlog beyond the bound is
|
|
27
|
+
// abandoned, loudly, and the current occurrence still fires.
|
|
28
|
+
const withinBound = (
|
|
29
|
+
triggerId: string,
|
|
30
|
+
owed: Effect.Effect<ReadonlyArray<Date>, TriggerError>
|
|
31
|
+
): Effect.Effect<ReadonlyArray<Date>, TriggerError> =>
|
|
32
|
+
Effect.catch(owed, (error) =>
|
|
33
|
+
error.code === "catch_up_bound_exceeded"
|
|
34
|
+
? Effect.as(
|
|
35
|
+
Effect.annotateLogs(
|
|
36
|
+
Effect.logWarning("A trigger abandoned catch-up work beyond its bound", error),
|
|
37
|
+
{ triggerId }
|
|
38
|
+
),
|
|
39
|
+
[] as ReadonlyArray<Date>
|
|
40
|
+
)
|
|
41
|
+
: Effect.fail(error))
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Computes what a trigger owes at `now`, given the watermark this process has
|
|
45
|
+
* already dispatched through, or `undefined` on first sight of the trigger.
|
|
46
|
+
*
|
|
47
|
+
* The answer depends on nothing but its arguments. The watermark is the
|
|
48
|
+
* caller's to keep: it only moves forward, and only past occurrences the
|
|
49
|
+
* caller finished dispatching.
|
|
50
|
+
*
|
|
51
|
+
* @private
|
|
52
|
+
* @since 1.0.0-rc.0
|
|
53
|
+
*/
|
|
54
|
+
export const compute = (
|
|
55
|
+
trigger: Registered,
|
|
56
|
+
now: number,
|
|
57
|
+
observed: number | undefined
|
|
58
|
+
): Effect.Effect<Due, TriggerError> =>
|
|
59
|
+
Effect.gen(function*() {
|
|
60
|
+
const cron = yield* Cron.parse(trigger.cron, trigger.timezone)
|
|
61
|
+
const current = (yield* Cron.previousAtOrBefore(cron, new Date(now))).getTime()
|
|
62
|
+
if (observed === undefined) {
|
|
63
|
+
// First sight of this trigger in this process. A trigger that has never
|
|
64
|
+
// fired starts from here rather than from whatever occurrence last
|
|
65
|
+
// passed: registering a weekly trigger on a Sunday evening owes nothing
|
|
66
|
+
// for the Monday six days gone, which is what `catchUp` says.
|
|
67
|
+
if (trigger.lastFiredAt === undefined) return { occurrences: [], watermark: current }
|
|
68
|
+
if (current <= trigger.lastFiredAt) return { occurrences: [], watermark: current }
|
|
69
|
+
const owed = yield* withinBound(
|
|
70
|
+
trigger.id,
|
|
71
|
+
CatchUp.occurrences(
|
|
72
|
+
trigger.catchUp,
|
|
73
|
+
trigger.maxCatchUp,
|
|
74
|
+
new Date(trigger.lastFiredAt),
|
|
75
|
+
new Date(now),
|
|
76
|
+
cron
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
// The current boundary is owed whatever the policy says about the
|
|
80
|
+
// backlog, exactly as on every later poll. Under `none`, or once the
|
|
81
|
+
// bound abandoned the backlog, a restart used to skip it.
|
|
82
|
+
const occurrences = owed.map((occurrence) => occurrence.getTime())
|
|
83
|
+
if (occurrences.at(-1) !== current) occurrences.push(current)
|
|
84
|
+
return { occurrences, watermark: current }
|
|
85
|
+
}
|
|
86
|
+
if (current <= observed) return { occurrences: [], watermark: observed }
|
|
87
|
+
const backlog = yield* withinBound(
|
|
88
|
+
trigger.id,
|
|
89
|
+
CatchUp.occurrences(
|
|
90
|
+
trigger.catchUp,
|
|
91
|
+
trigger.maxCatchUp,
|
|
92
|
+
new Date(observed),
|
|
93
|
+
new Date(current - 1),
|
|
94
|
+
cron
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
return {
|
|
98
|
+
occurrences: [...backlog.map((occurrence) => occurrence.getTime()), current],
|
|
99
|
+
watermark: current
|
|
100
|
+
}
|
|
101
|
+
})
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wall-clock cron occurrences in a time zone, daylight saving included.
|
|
3
|
+
*
|
|
4
|
+
* A cron expression names wall-clock times. Searching those times with a UTC
|
|
5
|
+
* cron is exact, so the zone only matters when a wall time is turned into an
|
|
6
|
+
* instant:
|
|
7
|
+
*
|
|
8
|
+
* - A wall time that exists once is that instant.
|
|
9
|
+
* - A wall time inside a spring-forward gap fires at the instant it would have
|
|
10
|
+
* had on the old offset: a daily 02:30 fires at 03:30 daylight time.
|
|
11
|
+
* - A wall time repeated by fall back fires once, at its first instant. An
|
|
12
|
+
* expression whose hour field is every hour fires in both passes instead, so
|
|
13
|
+
* an hourly or minutely schedule keeps running through the repeated hour.
|
|
14
|
+
*
|
|
15
|
+
* Instants are whole seconds in UTC, so millisecond zeroing never reads the
|
|
16
|
+
* host's zone.
|
|
17
|
+
*
|
|
18
|
+
* @since 1.0.0-rc.1
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import * as EffectCron from "effect/Cron"
|
|
22
|
+
import * as DateTime from "effect/DateTime"
|
|
23
|
+
import * as Option from "effect/Option"
|
|
24
|
+
|
|
25
|
+
const day = 86_400_000
|
|
26
|
+
const second = 1_000
|
|
27
|
+
|
|
28
|
+
/** The zone's wall clock minus UTC at an instant, in milliseconds. */
|
|
29
|
+
type Offset = (instant: number) => number
|
|
30
|
+
|
|
31
|
+
const formatters = new Map<string, Intl.DateTimeFormat>()
|
|
32
|
+
|
|
33
|
+
const formatter = (timeZone: string): Intl.DateTimeFormat => {
|
|
34
|
+
let format = formatters.get(timeZone)
|
|
35
|
+
if (format === undefined) {
|
|
36
|
+
format = new Intl.DateTimeFormat("en-US", {
|
|
37
|
+
timeZone,
|
|
38
|
+
year: "numeric",
|
|
39
|
+
month: "numeric",
|
|
40
|
+
day: "numeric",
|
|
41
|
+
hour: "numeric",
|
|
42
|
+
minute: "numeric",
|
|
43
|
+
second: "numeric",
|
|
44
|
+
hourCycle: "h23"
|
|
45
|
+
})
|
|
46
|
+
formatters.set(timeZone, format)
|
|
47
|
+
}
|
|
48
|
+
return format
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const named = (timeZone: string): Offset => (instant) => {
|
|
52
|
+
const whole = Math.floor(instant / second) * second
|
|
53
|
+
const fields = { year: 0, month: 1, day: 1, hour: 0, minute: 0, second: 0 }
|
|
54
|
+
for (const part of formatter(timeZone).formatToParts(whole)) {
|
|
55
|
+
if (part.type in fields) fields[part.type as keyof typeof fields] = Number(part.value)
|
|
56
|
+
}
|
|
57
|
+
const wall = Date.UTC(fields.year, fields.month - 1, fields.day, fields.hour, fields.minute, fields.second)
|
|
58
|
+
return wall - whole
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Read on every call, so a host whose `TZ` changes is followed.
|
|
62
|
+
const host: Offset = (instant) => -new Date(instant).getTimezoneOffset() * 60_000
|
|
63
|
+
|
|
64
|
+
const offsetOf = (zone: Option.Option<DateTime.TimeZone>): Offset => {
|
|
65
|
+
if (Option.isNone(zone)) return host
|
|
66
|
+
const value = zone.value
|
|
67
|
+
if (DateTime.isTimeZoneOffset(value)) return () => value.offset
|
|
68
|
+
return named(value.id)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A cron compiled for wall-clock search.
|
|
73
|
+
*
|
|
74
|
+
* @private
|
|
75
|
+
* @since 1.0.0-rc.1
|
|
76
|
+
*/
|
|
77
|
+
export interface WallCron {
|
|
78
|
+
readonly wall: EffectCron.Cron
|
|
79
|
+
readonly offset: Offset
|
|
80
|
+
readonly everyHour: boolean
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const compiled = new WeakMap<EffectCron.Cron, WallCron>()
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Compiles an Effect cron once: its fields as a UTC cron over wall-clock
|
|
87
|
+
* times, and its zone's offset.
|
|
88
|
+
*
|
|
89
|
+
* @private
|
|
90
|
+
* @since 1.0.0-rc.1
|
|
91
|
+
*/
|
|
92
|
+
export const compile = (cron: EffectCron.Cron): WallCron => {
|
|
93
|
+
let wallCron = compiled.get(cron)
|
|
94
|
+
if (wallCron === undefined) {
|
|
95
|
+
wallCron = {
|
|
96
|
+
wall: EffectCron.make({
|
|
97
|
+
seconds: cron.seconds,
|
|
98
|
+
minutes: cron.minutes,
|
|
99
|
+
hours: cron.hours,
|
|
100
|
+
days: cron.days,
|
|
101
|
+
months: cron.months,
|
|
102
|
+
weekdays: cron.weekdays,
|
|
103
|
+
// Whether day of month and weekday must both match. Effect keeps the
|
|
104
|
+
// flag off its public type, but `make` takes it and every cron has it.
|
|
105
|
+
and: (cron as EffectCron.Cron & { readonly and: boolean }).and,
|
|
106
|
+
tz: DateTime.zoneMakeNamedUnsafe("UTC")
|
|
107
|
+
}),
|
|
108
|
+
offset: offsetOf(cron.tz),
|
|
109
|
+
everyHour: cron.hours.size === 0 || cron.hours.size === 24
|
|
110
|
+
}
|
|
111
|
+
compiled.set(cron, wallCron)
|
|
112
|
+
}
|
|
113
|
+
return wallCron
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The offsets in force from a day before an instant to a day after it. */
|
|
117
|
+
const near = (cron: WallCron, instant: number): ReadonlyArray<number> => [
|
|
118
|
+
cron.offset(instant - day),
|
|
119
|
+
cron.offset(instant),
|
|
120
|
+
cron.offset(instant + day)
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
/** The instants one matching wall time fires at, earliest first. */
|
|
124
|
+
const resolve = (cron: WallCron, wall: number): ReadonlyArray<number> => {
|
|
125
|
+
const before = cron.offset(wall - day)
|
|
126
|
+
const after = cron.offset(wall + day)
|
|
127
|
+
const candidates = before === after ? [before] : [before, after]
|
|
128
|
+
const valid = candidates.map((offset) => wall - offset).filter((instant) => cron.offset(instant) === wall - instant)
|
|
129
|
+
// No offset reads this wall time back: it is inside a gap.
|
|
130
|
+
if (valid.length === 0) return [wall - before]
|
|
131
|
+
valid.sort((left, right) => left - right)
|
|
132
|
+
return cron.everyHour ? valid : valid.slice(0, 1)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The first occurrence strictly after `from`. Throws when the wall-clock search
|
|
137
|
+
* is exhausted.
|
|
138
|
+
*
|
|
139
|
+
* @private
|
|
140
|
+
* @since 1.0.0-rc.1
|
|
141
|
+
*/
|
|
142
|
+
export const next = (cron: WallCron, from: number): number => {
|
|
143
|
+
// Every wall time that can fire after `from` lies after `from` plus the
|
|
144
|
+
// smallest nearby offset.
|
|
145
|
+
let wall = from + Math.min(...near(cron, from))
|
|
146
|
+
let best = Number.POSITIVE_INFINITY
|
|
147
|
+
for (;;) {
|
|
148
|
+
wall = EffectCron.next(cron.wall, wall).getTime()
|
|
149
|
+
if (wall - Math.max(...near(cron, wall)) >= best) return best
|
|
150
|
+
for (const instant of resolve(cron, wall)) if (instant > from && instant < best) best = instant
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The latest occurrence at or before `at`, in whole seconds. Throws when the
|
|
156
|
+
* wall-clock search is exhausted.
|
|
157
|
+
*
|
|
158
|
+
* @private
|
|
159
|
+
* @since 1.0.0-rc.1
|
|
160
|
+
*/
|
|
161
|
+
export const previousAtOrBefore = (cron: WallCron, at: number): number => {
|
|
162
|
+
const until = Math.floor(at / second) * second
|
|
163
|
+
// Every wall time that can fire at or before `until` lies at or before
|
|
164
|
+
// `until` plus the largest nearby offset.
|
|
165
|
+
let wall = until + Math.max(...near(cron, until)) + second
|
|
166
|
+
let best = Number.NEGATIVE_INFINITY
|
|
167
|
+
for (;;) {
|
|
168
|
+
wall = EffectCron.prev(cron.wall, wall).getTime()
|
|
169
|
+
if (wall - Math.min(...near(cron, wall)) <= best) return best
|
|
170
|
+
for (const instant of resolve(cron, wall)) if (instant <= until && instant > best) best = instant
|
|
171
|
+
}
|
|
172
|
+
}
|