bunderstack 0.15.2 → 0.17.0-beta.0
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/README.md +25 -138
- package/package.json +22 -14
- package/src/access.ts +24 -1
- package/src/api/api-types.types.ts +106 -0
- package/src/api/builder.ts +52 -0
- package/src/api/context.ts +83 -0
- package/src/api/crud-router.ts +321 -0
- package/src/api/openapi.ts +184 -0
- package/src/api/realtime-router.ts +75 -0
- package/src/api/registry.ts +338 -0
- package/src/api/router.ts +34 -0
- package/src/api/storage-router.ts +224 -0
- package/src/api/types.ts +84 -0
- package/src/auth.ts +5 -0
- package/src/blueprint.ts +88 -105
- package/src/config.ts +73 -77
- package/src/cron.ts +2 -1
- package/src/crud-operations.ts +488 -0
- package/src/dialect.ts +1 -1
- package/src/env.ts +28 -21
- package/src/errors.ts +90 -23
- package/src/handler.ts +16 -44
- package/src/index.ts +283 -294
- package/src/internal-tables-pg.ts +1 -17
- package/src/internal-tables.ts +0 -31
- package/src/jobs/define.ts +75 -21
- package/src/jobs/index.ts +3 -9
- package/src/jobs/queue.ts +14 -6
- package/src/jobs/slots.ts +52 -0
- package/src/jobs/worker.ts +142 -42
- package/src/manifest.ts +84 -93
- package/src/realtime/facade.ts +16 -13
- package/src/realtime/filter.ts +77 -0
- package/src/realtime/heartbeat.ts +80 -0
- package/src/realtime/publisher.ts +46 -0
- package/src/standard-schema.ts +59 -0
- package/src/storage/index.ts +8 -0
- package/src/storage/operations.ts +398 -0
- package/src/crud.ts +0 -408
- package/src/jobs/cron-auth.ts +0 -28
- package/src/jobs/cron-router.ts +0 -135
- package/src/jobs/cron-runner.ts +0 -224
- package/src/jobs/local-cron.ts +0 -78
- package/src/realtime/index.ts +0 -250
- package/src/realtime/redis.ts +0 -228
- package/src/storage/router.ts +0 -531
- package/src/trpc.ts +0 -57
|
@@ -67,20 +67,4 @@ export const bunderstackJobsPg = pgTable(
|
|
|
67
67
|
],
|
|
68
68
|
)
|
|
69
69
|
|
|
70
|
-
|
|
71
|
-
'_bunderstack_cron_runs',
|
|
72
|
-
{
|
|
73
|
-
taskId: text('task_id').notNull(),
|
|
74
|
-
scheduledAt: bigint('scheduled_at', { mode: 'number' }).notNull(),
|
|
75
|
-
status: text('status').notNull(),
|
|
76
|
-
attempts: integer('attempts').notNull().default(0),
|
|
77
|
-
lockedUntil: bigint('locked_until', { mode: 'number' }),
|
|
78
|
-
lastError: text('last_error'),
|
|
79
|
-
startedAt: bigint('started_at', { mode: 'number' }),
|
|
80
|
-
finishedAt: bigint('finished_at', { mode: 'number' }),
|
|
81
|
-
},
|
|
82
|
-
(t) => [
|
|
83
|
-
primaryKey({ columns: [t.taskId, t.scheduledAt] }),
|
|
84
|
-
index('bcr_claim').on(t.status, t.lockedUntil),
|
|
85
|
-
],
|
|
86
|
-
)
|
|
70
|
+
|
package/src/internal-tables.ts
CHANGED
|
@@ -12,7 +12,6 @@ import {
|
|
|
12
12
|
import { detectDialect } from './dialect'
|
|
13
13
|
import {
|
|
14
14
|
bunderstackFilesPg,
|
|
15
|
-
bunderstackCronRunsPg,
|
|
16
15
|
bunderstackIdempotencyPg,
|
|
17
16
|
bunderstackJobsPg,
|
|
18
17
|
} from './internal-tables-pg'
|
|
@@ -74,43 +73,22 @@ export const bunderstackJobs = sqliteTable(
|
|
|
74
73
|
],
|
|
75
74
|
)
|
|
76
75
|
|
|
77
|
-
export const bunderstackCronRuns = sqliteTable(
|
|
78
|
-
'_bunderstack_cron_runs',
|
|
79
|
-
{
|
|
80
|
-
taskId: text('task_id').notNull(),
|
|
81
|
-
scheduledAt: integer('scheduled_at').notNull(),
|
|
82
|
-
status: text('status').notNull(),
|
|
83
|
-
attempts: integer('attempts').notNull().default(0),
|
|
84
|
-
lockedUntil: integer('locked_until'),
|
|
85
|
-
lastError: text('last_error'),
|
|
86
|
-
startedAt: integer('started_at'),
|
|
87
|
-
finishedAt: integer('finished_at'),
|
|
88
|
-
},
|
|
89
|
-
(t) => [
|
|
90
|
-
primaryKey({ columns: [t.taskId, t.scheduledAt] }),
|
|
91
|
-
index('bcr_claim').on(t.status, t.lockedUntil),
|
|
92
|
-
],
|
|
93
|
-
)
|
|
94
|
-
|
|
95
76
|
export const INTERNAL_TABLES = {
|
|
96
77
|
bunderstackFiles,
|
|
97
78
|
bunderstackIdempotency,
|
|
98
79
|
bunderstackJobs,
|
|
99
|
-
bunderstackCronRuns,
|
|
100
80
|
} as const
|
|
101
81
|
|
|
102
82
|
export const INTERNAL_TABLE_NAMES: ReadonlySet<string> = new Set([
|
|
103
83
|
'bunderstack_file_meta',
|
|
104
84
|
'_bunderstack_idempotency',
|
|
105
85
|
'_bunderstack_jobs',
|
|
106
|
-
'_bunderstack_cron_runs',
|
|
107
86
|
])
|
|
108
87
|
|
|
109
88
|
export const INTERNAL_TABLES_PG = {
|
|
110
89
|
bunderstackFiles: bunderstackFilesPg,
|
|
111
90
|
bunderstackIdempotency: bunderstackIdempotencyPg,
|
|
112
91
|
bunderstackJobs: bunderstackJobsPg,
|
|
113
|
-
bunderstackCronRuns: bunderstackCronRunsPg,
|
|
114
92
|
} as const
|
|
115
93
|
|
|
116
94
|
// Both dialect twins count as "ours" for the re-export identity check.
|
|
@@ -121,10 +99,6 @@ const INTERNAL_TABLE_CANDIDATES = new Map<string, readonly unknown[]>([
|
|
|
121
99
|
[bunderstackIdempotency, bunderstackIdempotencyPg],
|
|
122
100
|
],
|
|
123
101
|
[getTableName(bunderstackJobs), [bunderstackJobs, bunderstackJobsPg]],
|
|
124
|
-
[
|
|
125
|
-
getTableName(bunderstackCronRuns),
|
|
126
|
-
[bunderstackCronRuns, bunderstackCronRunsPg],
|
|
127
|
-
],
|
|
128
102
|
])
|
|
129
103
|
|
|
130
104
|
/** Internal file-meta table matching the db's dialect. */
|
|
@@ -142,11 +116,6 @@ export function jobsTableFor(db: unknown) {
|
|
|
142
116
|
return is(db, PgDatabase) ? bunderstackJobsPg : bunderstackJobs
|
|
143
117
|
}
|
|
144
118
|
|
|
145
|
-
/** Internal cron-run table matching the db's dialect. */
|
|
146
|
-
export function cronRunsTableFor(db: unknown) {
|
|
147
|
-
return is(db, PgDatabase) ? bunderstackCronRunsPg : bunderstackCronRuns
|
|
148
|
-
}
|
|
149
|
-
|
|
150
119
|
export function withInternalTables<TSchema extends Record<string, unknown>>(
|
|
151
120
|
schema: TSchema,
|
|
152
121
|
): TSchema & typeof INTERNAL_TABLES {
|
package/src/jobs/define.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
// src/jobs/define.ts — job definition types and the typed builder.
|
|
2
|
-
// `createJobsBuilder`
|
|
2
|
+
// `createJobsBuilder` exists purely to carry
|
|
3
3
|
// TSchema/TEnvResult typing into inline callbacks and extracted files.
|
|
4
|
-
import type {
|
|
4
|
+
import type { StandardSchemaV1 } from '@standard-schema/spec'
|
|
5
5
|
|
|
6
6
|
import type { DbFor } from '../db'
|
|
7
7
|
import type { EmailFacade } from '../email'
|
|
8
8
|
import type { StorageFacade } from '../index'
|
|
9
9
|
|
|
10
10
|
import { parseCron } from './cron'
|
|
11
|
+
import { CRON_PREFIX, type CatchUp } from './slots'
|
|
11
12
|
|
|
12
13
|
export const DEFAULT_RETRIES = 3
|
|
13
14
|
export const DEFAULT_TIMEOUT_MS = 60_000
|
|
@@ -21,8 +22,17 @@ export type EnqueueOptions = {
|
|
|
21
22
|
runAt?: Date | number
|
|
22
23
|
}
|
|
23
24
|
|
|
25
|
+
export type TickResult = {
|
|
26
|
+
/** Rows moved from pending to running this tick. */
|
|
27
|
+
claimed: number
|
|
28
|
+
/** Handlers that completed successfully. */
|
|
29
|
+
ran: number
|
|
30
|
+
/** Handlers that threw, whether or not they will be retried. */
|
|
31
|
+
failed: number
|
|
32
|
+
}
|
|
33
|
+
|
|
24
34
|
/**
|
|
25
|
-
* The untyped runtime facade.
|
|
35
|
+
* The untyped runtime facade. Job handlers and API context expose this shape;
|
|
26
36
|
* `app.jobs` narrows `enqueue` to the declared job names/payloads.
|
|
27
37
|
*/
|
|
28
38
|
export type JobsRuntimeFacade = {
|
|
@@ -32,7 +42,7 @@ export type JobsRuntimeFacade = {
|
|
|
32
42
|
opts?: EnqueueOptions,
|
|
33
43
|
): Promise<{ id: string }>
|
|
34
44
|
/** Run one poll cycle deterministically (tests). `now` defaults to Date.now(). */
|
|
35
|
-
tick(now?: number): Promise<
|
|
45
|
+
tick(now?: number): Promise<TickResult>
|
|
36
46
|
}
|
|
37
47
|
|
|
38
48
|
import type { RealtimeFacade } from '../realtime/facade'
|
|
@@ -60,13 +70,13 @@ export type QueueJobDefinition<
|
|
|
60
70
|
TEnvResult = Record<string, unknown>,
|
|
61
71
|
> = {
|
|
62
72
|
kind: 'job'
|
|
63
|
-
/**
|
|
64
|
-
input?:
|
|
73
|
+
/** Standard Schema payload; parsed at enqueue AND before the handler runs. */
|
|
74
|
+
input?: StandardSchemaV1<unknown, TInput>
|
|
65
75
|
/** Attempts after the first failure. Default 3 (so 4 total attempts). */
|
|
66
76
|
retries?: number
|
|
67
77
|
/** Delay before retry N (1-based). Default exponential: 1s, 2s, 4s, … */
|
|
68
78
|
backoff?: ((attempt: number) => number) | { baseMs?: number; factor?: number }
|
|
69
|
-
/** Max simultaneous `running` rows of this type, enforced
|
|
79
|
+
/** Max simultaneous `running` rows of this type, enforced per worker. */
|
|
70
80
|
concurrency?: number
|
|
71
81
|
/** Lease duration in ms; an expired lease sends the job back to pending. */
|
|
72
82
|
timeout?: number
|
|
@@ -91,10 +101,26 @@ export type CronDefinition<
|
|
|
91
101
|
> = {
|
|
92
102
|
kind: 'cron'
|
|
93
103
|
schedule: TSchedule
|
|
104
|
+
/** Attempts after the first failure. Default 3 (so 4 total attempts). */
|
|
105
|
+
retries?: number
|
|
106
|
+
/** Delay before retry N (1-based). Default exponential: 1s, 2s, 4s, … */
|
|
107
|
+
backoff?: ((attempt: number) => number) | { baseMs?: number; factor?: number }
|
|
108
|
+
/** Lease duration in ms; an expired lease sends the slot back to pending. */
|
|
109
|
+
timeout?: number
|
|
110
|
+
/** How missed slots are handled on wake. Default 'latest'. */
|
|
111
|
+
catchUp?: CatchUp
|
|
112
|
+
/** How far back catch-up looks, in ms. Default 1 hour. */
|
|
113
|
+
catchUpWindow?: number
|
|
94
114
|
handler: (
|
|
95
115
|
invocation: CronInvocation,
|
|
96
116
|
ctx: JobContext<TSchema, TEnvResult>,
|
|
97
117
|
) => Promise<void> | void
|
|
118
|
+
/** Fires once, after the final attempt fails. Errors here are logged, never retried. */
|
|
119
|
+
onFailed?: (
|
|
120
|
+
invocation: CronInvocation,
|
|
121
|
+
error: Error,
|
|
122
|
+
ctx: JobContext<TSchema, TEnvResult>,
|
|
123
|
+
) => Promise<void> | void
|
|
98
124
|
}
|
|
99
125
|
|
|
100
126
|
export type BackgroundDefinition =
|
|
@@ -111,6 +137,10 @@ export type JobDefinition<
|
|
|
111
137
|
|
|
112
138
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
113
139
|
export type AnyJobDefinition = QueueJobDefinition<any, any, any>
|
|
140
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
141
|
+
export type AnyBackgroundDefinition =
|
|
142
|
+
| QueueJobDefinition<any, any, any>
|
|
143
|
+
| CronDefinition<any, any, any>
|
|
114
144
|
export type JobsDefs = BackgroundDefs
|
|
115
145
|
|
|
116
146
|
export type QueueJobKeys<TDefs extends BackgroundDefs> = {
|
|
@@ -129,18 +159,38 @@ export function validateBackgroundDefs(defs: BackgroundDefs): void {
|
|
|
129
159
|
if (typeof def.handler !== 'function') {
|
|
130
160
|
throw new Error(`[bunderstack] background task "${name}" has no handler`)
|
|
131
161
|
}
|
|
132
|
-
if (def.kind === '
|
|
133
|
-
|
|
134
|
-
|
|
162
|
+
if (def.kind === 'job' && name.startsWith(CRON_PREFIX)) {
|
|
163
|
+
throw new Error(
|
|
164
|
+
`[bunderstack] job "${name}": the "${CRON_PREFIX}" prefix is reserved for cron tasks`,
|
|
165
|
+
)
|
|
135
166
|
}
|
|
136
167
|
if (
|
|
137
168
|
def.retries !== undefined &&
|
|
138
169
|
(def.retries < 0 || !Number.isInteger(def.retries))
|
|
139
170
|
) {
|
|
140
171
|
throw new Error(
|
|
141
|
-
`[bunderstack]
|
|
172
|
+
`[bunderstack] background task "${name}": retries must be a non-negative integer`,
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
if (def.timeout !== undefined && def.timeout <= 0) {
|
|
176
|
+
throw new Error(
|
|
177
|
+
`[bunderstack] background task "${name}": timeout must be positive`,
|
|
142
178
|
)
|
|
143
179
|
}
|
|
180
|
+
if (def.kind === 'cron') {
|
|
181
|
+
parseCron(def.schedule)
|
|
182
|
+
if ((def as { concurrency?: number }).concurrency !== undefined) {
|
|
183
|
+
throw new Error(
|
|
184
|
+
`[bunderstack] cron "${name}": concurrency is not supported for cron tasks — slots are already unique`,
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
if (def.catchUpWindow !== undefined && def.catchUpWindow <= 0) {
|
|
188
|
+
throw new Error(
|
|
189
|
+
`[bunderstack] cron "${name}": catchUpWindow must be positive`,
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
continue
|
|
193
|
+
}
|
|
144
194
|
if (
|
|
145
195
|
def.concurrency !== undefined &&
|
|
146
196
|
(def.concurrency < 1 || !Number.isInteger(def.concurrency))
|
|
@@ -149,22 +199,27 @@ export function validateBackgroundDefs(defs: BackgroundDefs): void {
|
|
|
149
199
|
`[bunderstack] job "${name}": concurrency must be a positive integer`,
|
|
150
200
|
)
|
|
151
201
|
}
|
|
152
|
-
if (def.timeout !== undefined && def.timeout <= 0) {
|
|
153
|
-
throw new Error(`[bunderstack] job "${name}": timeout must be positive`)
|
|
154
|
-
}
|
|
155
202
|
}
|
|
156
203
|
}
|
|
157
204
|
|
|
158
205
|
/** @deprecated Use validateBackgroundDefs. */
|
|
159
206
|
export const validateJobsDefs = validateBackgroundDefs
|
|
160
207
|
|
|
161
|
-
/**
|
|
162
|
-
|
|
208
|
+
/**
|
|
209
|
+
* Delay in ms before retry `attempt` (1-based = the attempt that just failed).
|
|
210
|
+
* Jittered by ±20% so a shared outage does not retry every job in lockstep.
|
|
211
|
+
* A caller-supplied backoff function is returned verbatim — the caller owns it.
|
|
212
|
+
*/
|
|
213
|
+
export function backoffMs(
|
|
214
|
+
def: AnyBackgroundDefinition,
|
|
215
|
+
attempt: number,
|
|
216
|
+
): number {
|
|
163
217
|
const b = def.backoff
|
|
164
218
|
if (typeof b === 'function') return b(attempt)
|
|
165
219
|
const baseMs = b?.baseMs ?? 1000
|
|
166
220
|
const factor = b?.factor ?? 2
|
|
167
|
-
|
|
221
|
+
const flat = baseMs * factor ** (attempt - 1)
|
|
222
|
+
return Math.round(flat * (0.8 + Math.random() * 0.4))
|
|
168
223
|
}
|
|
169
224
|
|
|
170
225
|
/**
|
|
@@ -176,7 +231,7 @@ export function createJobsBuilder<
|
|
|
176
231
|
TEnvResult = Record<string, unknown>,
|
|
177
232
|
>() {
|
|
178
233
|
return {
|
|
179
|
-
/** Identity with inference: pins TInput from the
|
|
234
|
+
/** Identity with inference: pins TInput from the schema output. */
|
|
180
235
|
job<TInput = undefined>(
|
|
181
236
|
def: Omit<QueueJobDefinition<TInput, TSchema, TEnvResult>, 'kind'>,
|
|
182
237
|
): QueueJobDefinition<TInput, TSchema, TEnvResult> {
|
|
@@ -204,9 +259,8 @@ export type BunderstackJobsBuilder<
|
|
|
204
259
|
|
|
205
260
|
// Infers TInput from the JobDefinition's own type argument rather than
|
|
206
261
|
// pattern-matching the (optional, so union-with-undefined) `input` property —
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
// can never satisfy a required-property pattern.
|
|
262
|
+
// A required-property pattern fails structurally because `input` is optional,
|
|
263
|
+
// so infer from the definition's own type argument instead.
|
|
210
264
|
type JobInputOf<TDef> =
|
|
211
265
|
TDef extends QueueJobDefinition<infer TInput, any, any> ? TInput : undefined
|
|
212
266
|
|
package/src/jobs/index.ts
CHANGED
|
@@ -25,19 +25,13 @@ export type {
|
|
|
25
25
|
} from './define'
|
|
26
26
|
export { enqueueJob } from './queue'
|
|
27
27
|
export { createJobRunner } from './worker'
|
|
28
|
-
export { runCronSlot, runScheduledSlot } from './cron-runner'
|
|
29
|
-
export type { CronRunResult } from './cron-runner'
|
|
30
|
-
export { buildCronRouter } from './cron-router'
|
|
31
|
-
export { signScheduleRequest, verifyScheduleRequest } from './cron-auth'
|
|
32
28
|
export { startJobWorker } from './runtime'
|
|
33
29
|
export type {
|
|
34
30
|
StartWorkerOptions,
|
|
35
31
|
RunWorkerOptions,
|
|
36
32
|
WorkerHandle,
|
|
37
33
|
} from './runtime'
|
|
38
|
-
export { startLocalCronScheduler } from './local-cron'
|
|
39
|
-
export type {
|
|
40
|
-
LocalCronScheduler,
|
|
41
|
-
LocalCronSchedulerOptions,
|
|
42
|
-
} from './local-cron'
|
|
43
34
|
export { parseCron, cronMatches } from './cron'
|
|
35
|
+
export { slotsDue, floorSlot, CRON_PREFIX, SLOT_MS } from './slots'
|
|
36
|
+
export type { CatchUp } from './slots'
|
|
37
|
+
export type { TickResult } from './define'
|
package/src/jobs/queue.ts
CHANGED
|
@@ -5,7 +5,9 @@ import type { AnyDb } from '../dialect'
|
|
|
5
5
|
import type { EnqueueOptions, JobsDefs } from './define'
|
|
6
6
|
|
|
7
7
|
import { jobsTableFor } from '../internal-tables'
|
|
8
|
+
import { validateStandardSchema } from '../standard-schema'
|
|
8
9
|
import { generate } from '../typeid'
|
|
10
|
+
import { CRON_PREFIX } from './slots'
|
|
9
11
|
|
|
10
12
|
export async function enqueueJob(
|
|
11
13
|
db: AnyDb,
|
|
@@ -15,11 +17,17 @@ export async function enqueueJob(
|
|
|
15
17
|
opts: EnqueueOptions = {},
|
|
16
18
|
): Promise<{ id: string }> {
|
|
17
19
|
const def = defs[name]
|
|
18
|
-
if (!def
|
|
19
|
-
throw new Error(`[bunderstack] unknown
|
|
20
|
+
if (!def) {
|
|
21
|
+
throw new Error(`[bunderstack] unknown background task "${name}"`)
|
|
20
22
|
}
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
+
const isCron = def.kind === 'cron'
|
|
24
|
+
const type = isCron ? `${CRON_PREFIX}${name}` : name
|
|
25
|
+
// Cron slots carry no payload; queue jobs validate theirs at the call site.
|
|
26
|
+
const parsed = isCron
|
|
27
|
+
? null
|
|
28
|
+
: def.input
|
|
29
|
+
? validateStandardSchema(def.input, input, `job "${name}" input`)
|
|
30
|
+
: null
|
|
23
31
|
const t = jobsTableFor(db)
|
|
24
32
|
const now = Date.now()
|
|
25
33
|
const runAt =
|
|
@@ -35,7 +43,7 @@ export async function enqueueJob(
|
|
|
35
43
|
.insert(t)
|
|
36
44
|
.values({
|
|
37
45
|
id,
|
|
38
|
-
type
|
|
46
|
+
type,
|
|
39
47
|
payloadJson: JSON.stringify(parsed),
|
|
40
48
|
status: 'pending',
|
|
41
49
|
attempts: 0,
|
|
@@ -49,7 +57,7 @@ export async function enqueueJob(
|
|
|
49
57
|
const existing = await db
|
|
50
58
|
.select({ id: t.id })
|
|
51
59
|
.from(t)
|
|
52
|
-
.where(and(eq(t.type,
|
|
60
|
+
.where(and(eq(t.type, type), eq(t.dedupeKey, opts.dedupeKey ?? '')))
|
|
53
61
|
.limit(1)
|
|
54
62
|
if (existing[0]) return { id: String(existing[0].id) }
|
|
55
63
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// src/jobs/slots.ts — cron slot enumeration. Pure; no db, no clock reads.
|
|
2
|
+
import { cronMatches, type ParsedCron } from './cron'
|
|
3
|
+
|
|
4
|
+
/** Slot granularity. Every slot timestamp satisfies `slot % SLOT_MS === 0`. */
|
|
5
|
+
export const SLOT_MS = 60_000
|
|
6
|
+
|
|
7
|
+
/** Reserved job-type prefix for cron occurrences. */
|
|
8
|
+
export const CRON_PREFIX = 'cron:'
|
|
9
|
+
|
|
10
|
+
/** How far back either catch-up mode will look. */
|
|
11
|
+
export const DEFAULT_CATCH_UP_WINDOW_MS = 60 * SLOT_MS
|
|
12
|
+
|
|
13
|
+
export type CatchUp = 'latest' | 'all'
|
|
14
|
+
|
|
15
|
+
/** Aligns `ms` down to its containing slot. */
|
|
16
|
+
export function floorSlot(ms: number): number {
|
|
17
|
+
return Math.floor(ms / SLOT_MS) * SLOT_MS
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Slots matching `cron` in the half-open range `(from, to]`, oldest first.
|
|
22
|
+
*
|
|
23
|
+
* `from` is exclusive so a stored watermark is never re-emitted. Both modes are
|
|
24
|
+
* clamped to `catchUpWindowMs` — without it a watermark far in the past would
|
|
25
|
+
* make this iterate unbounded minutes.
|
|
26
|
+
*/
|
|
27
|
+
export function slotsDue(args: {
|
|
28
|
+
cron: ParsedCron
|
|
29
|
+
from: number
|
|
30
|
+
to: number
|
|
31
|
+
catchUp?: CatchUp
|
|
32
|
+
catchUpWindowMs?: number
|
|
33
|
+
}): number[] {
|
|
34
|
+
const catchUp = args.catchUp ?? 'latest'
|
|
35
|
+
const windowMs = args.catchUpWindowMs ?? DEFAULT_CATCH_UP_WINDOW_MS
|
|
36
|
+
const to = floorSlot(args.to)
|
|
37
|
+
const from = Math.max(floorSlot(args.from), to - windowMs)
|
|
38
|
+
if (to <= from) return []
|
|
39
|
+
|
|
40
|
+
if (catchUp === 'latest') {
|
|
41
|
+
for (let s = to; s > from; s -= SLOT_MS) {
|
|
42
|
+
if (cronMatches(args.cron, s)) return [s]
|
|
43
|
+
}
|
|
44
|
+
return []
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const slots: number[] = []
|
|
48
|
+
for (let s = from + SLOT_MS; s <= to; s += SLOT_MS) {
|
|
49
|
+
if (cronMatches(args.cron, s)) slots.push(s)
|
|
50
|
+
}
|
|
51
|
+
return slots
|
|
52
|
+
}
|