@strav/queue 0.4.31 → 1.0.0-alpha.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,135 @@
1
+ /**
2
+ * `JobRegistry` — runtime catalog of every registered `Job` class.
3
+ *
4
+ * Apps register jobs explicitly (`register(JobClass)` / `registerAll
5
+ * ([...])`) or via `discover(pattern)`, which uses `Bun.Glob` to scan
6
+ * files + dynamically `import()` each, registering every exported
7
+ * class that satisfies {@link isJobClass}. Same shape as
8
+ * `SchemaRegistry.discover` in `@strav/database`.
9
+ *
10
+ * The registry rejects two different classes claiming the same
11
+ * `jobName` (a wire-protocol collision — the Worker can't disambiguate
12
+ * which class to instantiate). Re-imports of the same class via a
13
+ * barrel are deduped by identity.
14
+ */
15
+
16
+ import { ConfigError } from '@strav/kernel'
17
+ import { Job, type JobClass } from './job.ts'
18
+
19
+ export class JobRegistry {
20
+ private readonly byName = new Map<string, JobClass>()
21
+
22
+ /** Register one Job class. Throws when `jobName` is empty or already taken. */
23
+ register(jobClass: JobClass): this {
24
+ if (!jobClass.jobName) {
25
+ throw new ConfigError(
26
+ `JobRegistry: ${jobClass.name || 'anonymous job'} must declare a non-empty \`static jobName\` — that's the wire identifier the Worker dispatches against.`,
27
+ )
28
+ }
29
+ const existing = this.byName.get(jobClass.jobName)
30
+ if (existing === jobClass) return this
31
+ if (existing) {
32
+ throw new ConfigError(
33
+ `JobRegistry: jobName "${jobClass.jobName}" is already registered to a different class (${existing.name} vs ${jobClass.name}). Pick a different jobName — the Worker uses it to route serialized payloads back to a class.`,
34
+ )
35
+ }
36
+ this.byName.set(jobClass.jobName, jobClass)
37
+ return this
38
+ }
39
+
40
+ /** Register several. Order matters only insofar as register() throws on conflict. */
41
+ registerAll(jobClasses: readonly JobClass[]): this {
42
+ for (const cls of jobClasses) this.register(cls)
43
+ return this
44
+ }
45
+
46
+ /**
47
+ * Auto-discover Job classes by glob pattern. For each matched file:
48
+ * 1. dynamically `import()` it,
49
+ * 2. iterate every exported value,
50
+ * 3. register every one that satisfies {@link isJobClass}.
51
+ *
52
+ * `pattern` is a `Bun.Glob`-compatible string (or array). `cwd`
53
+ * defaults to `process.cwd()` (typically the repo root). Returns
54
+ * `this` for chaining.
55
+ *
56
+ * Re-exports of the same class via multiple files dedupe by object
57
+ * identity — typical barrel patterns work. Two DIFFERENT classes
58
+ * sharing a `jobName` still throw `ConfigError`.
59
+ *
60
+ * Files exporting no Job classes (helpers, type-only re-exports) are
61
+ * silently skipped.
62
+ */
63
+ async discover(pattern: string | string[], options: { cwd?: string } = {}): Promise<this> {
64
+ const patterns = Array.isArray(pattern) ? pattern : [pattern]
65
+ const cwd = options.cwd ?? process.cwd()
66
+ const files = new Set<string>()
67
+ for (const p of patterns) {
68
+ const glob = new Bun.Glob(p)
69
+ for await (const file of glob.scan({ cwd, absolute: true })) {
70
+ files.add(file)
71
+ }
72
+ }
73
+ for (const file of files) {
74
+ const mod = (await import(file)) as Record<string, unknown>
75
+ for (const value of Object.values(mod)) {
76
+ if (!isJobClass(value)) continue
77
+ this.register(value)
78
+ }
79
+ }
80
+ return this
81
+ }
82
+
83
+ /** Resolve by `jobName`. Returns `undefined` for unknown names. */
84
+ get(jobName: string): JobClass | undefined {
85
+ return this.byName.get(jobName)
86
+ }
87
+
88
+ /** Throwing variant — use when "this job must exist" is a hard precondition. */
89
+ getOrFail(jobName: string): JobClass {
90
+ const cls = this.byName.get(jobName)
91
+ if (!cls) {
92
+ throw new ConfigError(
93
+ `JobRegistry: no Job is registered under "${jobName}". ` +
94
+ 'Either the dispatcher serialized an unknown class, or the Worker is running against a different registry than the dispatcher used.',
95
+ )
96
+ }
97
+ return cls
98
+ }
99
+
100
+ /** True when a Job with this `jobName` is registered. */
101
+ has(jobName: string): boolean {
102
+ return this.byName.has(jobName)
103
+ }
104
+
105
+ /** Every registered class, in insertion order. */
106
+ all(): readonly JobClass[] {
107
+ return [...this.byName.values()]
108
+ }
109
+
110
+ /** Test helper: wipe the registry. */
111
+ clear(): void {
112
+ this.byName.clear()
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Type-guard: a value looks like a Job class — a function whose
118
+ * prototype extends `Job` and which declares a non-empty `jobName`.
119
+ * Used by `discover()` to filter exported values; exported so apps can
120
+ * hand-roll their own discovery loops.
121
+ *
122
+ * The prototype check is conservative — a class that doesn't extend
123
+ * `Job` (even one that happens to look duck-typed) is rejected. That's
124
+ * intentional: the `failed?` hook + future Worker extensions key off
125
+ * `instanceof Job`, so the registry's identity guarantee has to match.
126
+ */
127
+ export function isJobClass(value: unknown): value is JobClass {
128
+ if (typeof value !== 'function') return false
129
+ // The Job constructor itself isn't registerable (it's abstract); only
130
+ // subclasses are.
131
+ if (value === Job) return false
132
+ if (!(value.prototype instanceof Job)) return false
133
+ const jobName = (value as { jobName?: unknown }).jobName
134
+ return typeof jobName === 'string' && jobName.length > 0
135
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * `strav_jobs` schema — the backing table for `DatabaseQueue`.
3
+ *
4
+ * Naming convention: framework-managed tables that apps register +
5
+ * migrate use the `strav_*` prefix (no leading underscore — those are
6
+ * reserved for raw-DDL tables created by the runner / DDL emitter:
7
+ * `_strav_migrations`, `_strav_tenant_sequences`). Apps register
8
+ * `jobSchema` with their SchemaRegistry and `generateMigration` picks
9
+ * it up like any other schema.
10
+ *
11
+ * Columns:
12
+ * - `id` (char(26), ULID PK) — globally unique across queues.
13
+ * - `queue` (varchar(64)) — named queue. Default `'default'`. Workers
14
+ * poll one queue at a time, so this is the routing key.
15
+ * - `job_name` (varchar(128)) — `JobRegistry` lookup key. The Worker
16
+ * deserializes the row by mapping this back to a `JobClass`.
17
+ * - `payload` (jsonb) — the `JSON.stringify`'d job payload.
18
+ * - `attempts` (integer, default 0) — count of attempts so far. The
19
+ * Worker increments before running `handle()`.
20
+ * - `max_attempts` (integer, default 3) — total retries allowed.
21
+ * `attempts >= max_attempts` after a failed attempt → terminal
22
+ * failure, moves to `failed_jobs` (V1 just leaves it; failed-jobs
23
+ * handling is its own slice).
24
+ * - `available_at` (timestamptz) — the earliest time a Worker may
25
+ * pick this row up. `now()` for immediate dispatch; `dispatchLater`
26
+ * pushes this into the future.
27
+ * - `reserved_at` (timestamptz, nullable) — when a Worker locked
28
+ * the row via `SELECT FOR UPDATE SKIP LOCKED`. NULL means
29
+ * unreserved.
30
+ * - `created_at` / `updated_at` — provided by `t.timestamps()`.
31
+ *
32
+ * Not `tenanted: true` — the queue is system-level. Multi-tenant apps
33
+ * that want per-tenant queues can clone this schema with a tenant_id
34
+ * FK + RLS, but that's a follow-up.
35
+ */
36
+
37
+ import { Archetype, defineSchema } from '@strav/database'
38
+
39
+ export const jobSchema = defineSchema('strav_jobs', Archetype.Entity, (t) => {
40
+ t.id()
41
+ t.string('queue').max(64).default('default')
42
+ t.string('job_name').max(128)
43
+ t.json<unknown>('payload')
44
+ t.integer('attempts').default(0)
45
+ t.integer('max_attempts').default(3)
46
+ t.timestamp('available_at')
47
+ t.timestamp('reserved_at').nullable()
48
+ t.timestamps()
49
+ })
package/src/queue.ts ADDED
@@ -0,0 +1,69 @@
1
+ /**
2
+ * `Queue` — the contract every backend implements.
3
+ *
4
+ * V1 ships two: `SyncQueue` (in-process, no persistence — for tests +
5
+ * single-process dev) and `DatabaseQueue` (Postgres-backed, the
6
+ * production driver). Both implement this interface; apps depend on
7
+ * the abstract `Queue` symbol via DI.
8
+ *
9
+ * Method semantics:
10
+ * - `dispatch(JobClass, payload, opts?)` — enqueue for a Worker to
11
+ * pick up. Returns the assigned `jobId` (ULID).
12
+ * - `dispatchLater(at, JobClass, payload, opts?)` — same as dispatch
13
+ * but the row isn't picked up until `at`. `at` is either a `Date`
14
+ * (absolute) or a number of seconds from now (relative).
15
+ * - `dispatchSync(JobClass, payload)` — instantiate + run `handle()`
16
+ * synchronously in the caller's process. No persistence, no
17
+ * retries, no Worker required. Useful for tests, for dev mode,
18
+ * and for callers that genuinely want the work done inline (rare
19
+ * in production — most jobs exist precisely to defer work).
20
+ */
21
+
22
+ import type { JobClass, PayloadOf } from './job.ts'
23
+
24
+ export interface DispatchOptions {
25
+ /** Named queue to dispatch onto. Default: the JobClass's `static queue` or `'default'`. */
26
+ queue?: string
27
+ /**
28
+ * Override total attempts (including the first). Default: the
29
+ * JobClass's `static maxAttempts`, or the driver default (typically 3).
30
+ */
31
+ attempts?: number
32
+ }
33
+
34
+ export interface DispatchLaterOptions extends DispatchOptions {
35
+ // No additional options today; the slot exists so future drivers can
36
+ // grow it (e.g. `priority` or `deduplicationKey`) without breaking
37
+ // the call sites.
38
+ }
39
+
40
+ export interface Queue {
41
+ /** Enqueue immediately. Returns the assigned job id (ULID). */
42
+ dispatch<TJob extends JobClass>(
43
+ jobClass: TJob,
44
+ payload: PayloadOf<TJob>,
45
+ opts?: DispatchOptions,
46
+ ): Promise<string>
47
+
48
+ /**
49
+ * Enqueue with a delay. `at` is either a `Date` (absolute wall-clock
50
+ * time) or a positive number of seconds from now. Returns the
51
+ * assigned job id (ULID).
52
+ *
53
+ * `Date` values in the past are clamped to "now" — i.e. the job
54
+ * becomes immediately eligible. Negative numbers throw.
55
+ */
56
+ dispatchLater<TJob extends JobClass>(
57
+ at: Date | number,
58
+ jobClass: TJob,
59
+ payload: PayloadOf<TJob>,
60
+ opts?: DispatchLaterOptions,
61
+ ): Promise<string>
62
+
63
+ /**
64
+ * Run the job synchronously in the caller's process. No persistence,
65
+ * no retries — if `handle()` throws, the throw propagates. Returns
66
+ * when `handle()` resolves.
67
+ */
68
+ dispatchSync<TJob extends JobClass>(jobClass: TJob, payload: PayloadOf<TJob>): Promise<void>
69
+ }
@@ -0,0 +1,271 @@
1
+ /**
2
+ * `Scheduler` — recurring job dispatch on a cron cadence.
3
+ *
4
+ * Two surfaces:
5
+ * - `.schedule(opts)` registers an entry (`{ job, cron, name?,
6
+ * payload?, oneServer? }`). Returns `this` for chaining.
7
+ * - `.tick(now)` processes every registered entry whose cron matches
8
+ * `now`. Dispatches via the wired `Queue`. Exposed for tests +
9
+ * one-shot CLI use.
10
+ * - `.run(signal)` is the long-running loop — calls `tick()` at each
11
+ * minute boundary, sleeps abort-aware between ticks.
12
+ *
13
+ * `oneServer: true` entries use `TenantManager.withLock` to acquire a
14
+ * fleet-wide advisory lock named after the entry. Inside the lock the
15
+ * dispatcher checks `strav_scheduler_runs.last_run_at` — if the
16
+ * current tick boundary already has a run recorded, this server skips
17
+ * (another server won). Otherwise dispatch + UPSERT atomically.
18
+ *
19
+ * `withLock` is built on `UnitOfWork.run`, which means
20
+ * `DatabaseQueue.dispatch` inside it auto-routes through the same
21
+ * transaction — the queue row INSERT + the run-tracking UPSERT commit
22
+ * together. A throw before COMMIT drops both. Exactly the
23
+ * queue-until-commit semantic from M2.
24
+ *
25
+ * Cron matching is UTC-based (see {@link CronExpression}). Apps that
26
+ * want local-time scheduling shift their schedule expressions; the
27
+ * Scheduler doesn't take a timezone option.
28
+ */
29
+
30
+ import type { TenantManager } from '@strav/database'
31
+ import { type Logger, ulid } from '@strav/kernel'
32
+ import type { CronExpression } from './cron.ts'
33
+ import type { JobClass } from './job.ts'
34
+ import type { Queue } from './queue.ts'
35
+
36
+ export interface ScheduleOptions {
37
+ /** Job to dispatch when the cron matches. */
38
+ job: JobClass
39
+ /** Payload handed to the Job. Default `undefined` → empty payload. */
40
+ payload?: unknown
41
+ /** Cron expression that gates the dispatch. */
42
+ cron: CronExpression
43
+ /**
44
+ * Identifier used for the advisory lock key + the
45
+ * `strav_scheduler_runs` row. Defaults to `job.jobName`.
46
+ * Specify when one job class is scheduled multiple times with
47
+ * different cadences / payloads, so each has its own lock + row.
48
+ */
49
+ name?: string
50
+ /**
51
+ * When `true`, only one server in the fleet dispatches per tick
52
+ * (advisory lock + run-tracking row). When `false` (default), every
53
+ * server dispatches independently — fine for jobs whose work is
54
+ * itself idempotent or which want fan-out semantics.
55
+ */
56
+ oneServer?: boolean
57
+ }
58
+
59
+ export interface SchedulerOptions {
60
+ /** Queue used to dispatch each entry's job. */
61
+ queue: Queue
62
+ /**
63
+ * TenantManager — used for its `withLock` + UoW combo on
64
+ * `oneServer` entries. `Scheduler` doesn't itself do tenancy; this
65
+ * is the most ergonomic primitive for "advisory lock + transaction
66
+ * + ambient ALS so dispatch joins the same tx."
67
+ */
68
+ tenants: TenantManager
69
+ /**
70
+ * Optional fallback executor used when an `oneServer: false` entry's
71
+ * dispatch raises. Only used to log the failure; the Queue itself
72
+ * already routes the SQL. Default: no-op logger.
73
+ */
74
+ logger?: Logger
75
+ }
76
+
77
+ interface ScheduledEntry {
78
+ name: string
79
+ cron: CronExpression
80
+ job: JobClass
81
+ payload: unknown
82
+ oneServer: boolean
83
+ }
84
+
85
+ export class Scheduler {
86
+ private readonly queue: Queue
87
+ private readonly tenants: TenantManager
88
+ private readonly logger: Logger
89
+ private readonly entries: ScheduledEntry[] = []
90
+
91
+ constructor(opts: SchedulerOptions) {
92
+ this.queue = opts.queue
93
+ this.tenants = opts.tenants
94
+ this.logger = opts.logger ?? createNoopLogger()
95
+ }
96
+
97
+ /** Register a recurring dispatch. Returns `this` for chaining. */
98
+ schedule(options: ScheduleOptions): this {
99
+ this.entries.push({
100
+ name: options.name ?? options.job.jobName,
101
+ cron: options.cron,
102
+ job: options.job,
103
+ payload: options.payload,
104
+ oneServer: options.oneServer ?? false,
105
+ })
106
+ return this
107
+ }
108
+
109
+ /** Every registered entry — exposed for inspection / tests. */
110
+ all(): readonly ScheduledEntry[] {
111
+ return [...this.entries]
112
+ }
113
+
114
+ /**
115
+ * Force-dispatch one named entry once, regardless of its cron expression.
116
+ * Useful for `bun strav scheduler:run <name>` — run an entry on demand
117
+ * without waiting for the next tick boundary.
118
+ *
119
+ * Honors `oneServer`: when set, the lock + run-tracking row still apply,
120
+ * so two `scheduler:run` invocations against the same name from
121
+ * different machines don't double-dispatch. The "tick boundary" used
122
+ * for run-tracking is `now` floored to the minute, the same convention
123
+ * `tick()` uses.
124
+ *
125
+ * Throws when `name` doesn't match any registered entry — silent failure
126
+ * here would be a footgun in a deploy script.
127
+ */
128
+ async runEntry(name: string, now: Date = new Date()): Promise<void> {
129
+ const entry = this.entries.find((e) => e.name === name)
130
+ if (!entry) {
131
+ throw new Error(
132
+ `Scheduler.runEntry("${name}"): no schedule with that name registered. ` +
133
+ `Known names: ${this.entries.map((e) => e.name).join(', ') || '(none)'}`,
134
+ )
135
+ }
136
+ if (entry.oneServer) {
137
+ await this.dispatchOneServer(entry, floorToMinute(now))
138
+ } else {
139
+ await this.queue.dispatch(entry.job, entry.payload as never)
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Process every entry against `now`. The tick boundary is `now`
145
+ * floored to the start of its minute (seconds + millis cleared) —
146
+ * cron matches against that, and `oneServer` run-tracking writes
147
+ * that value into `strav_scheduler_runs.last_run_at`.
148
+ */
149
+ async tick(now: Date = new Date()): Promise<void> {
150
+ const boundary = floorToMinute(now)
151
+ for (const entry of this.entries) {
152
+ if (!entry.cron.matches(boundary)) continue
153
+ try {
154
+ if (entry.oneServer) {
155
+ await this.dispatchOneServer(entry, boundary)
156
+ } else {
157
+ await this.queue.dispatch(entry.job, entry.payload as never)
158
+ }
159
+ } catch (error) {
160
+ this.logger.error('Scheduler: dispatch failed', {
161
+ name: entry.name,
162
+ jobName: entry.job.jobName,
163
+ error,
164
+ })
165
+ }
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Run the minute-tick loop until `signal` aborts. Each iteration
171
+ * sleeps until the next minute boundary, then calls `tick()`. The
172
+ * sleep is abort-aware — `signal.abort()` returns within one tick
173
+ * rather than waiting out the minute.
174
+ *
175
+ * On the FIRST iteration, sleeps to the next minute boundary
176
+ * BEFORE the first tick — so callers see one full minute pass
177
+ * between `run()` start and the first dispatch. (Calling `tick()`
178
+ * explicitly before `run()` is the way to dispatch immediately.)
179
+ */
180
+ async run(signal: AbortSignal): Promise<void> {
181
+ while (!signal.aborted) {
182
+ const now = new Date()
183
+ const nextBoundary = nextMinuteBoundary(now)
184
+ const sleepMs = Math.max(0, nextBoundary.getTime() - now.getTime())
185
+ await sleep(sleepMs, signal)
186
+ if (signal.aborted) return
187
+ try {
188
+ await this.tick(nextBoundary)
189
+ } catch (loopError) {
190
+ this.logger.error('Scheduler: tick iteration failed', { error: loopError })
191
+ }
192
+ }
193
+ }
194
+
195
+ private async dispatchOneServer(entry: ScheduledEntry, tickBoundary: Date): Promise<void> {
196
+ await this.tenants.withLock(`scheduler:${entry.name}`, async (tx) => {
197
+ const last = await tx.queryOne<{ last_run_at: Date }>(
198
+ `SELECT last_run_at FROM "strav_scheduler_runs" WHERE name = $1`,
199
+ [entry.name],
200
+ )
201
+ // The lock serializes the read+write window — if another server
202
+ // already recorded this tick boundary, skip cleanly.
203
+ if (last !== null && last.last_run_at.getTime() >= tickBoundary.getTime()) {
204
+ return
205
+ }
206
+ // Inside withLock's UoW, the dispatch routes through the ambient
207
+ // tx — the queue row INSERT + the run-tracking UPSERT commit
208
+ // atomically. If anything throws, both roll back.
209
+ await this.queue.dispatch(entry.job, entry.payload as never)
210
+ await tx.execute(
211
+ `INSERT INTO "strav_scheduler_runs" (id, name, last_run_at, created_at, updated_at)
212
+ VALUES ($1, $2, $3, now(), now())
213
+ ON CONFLICT (name) DO UPDATE
214
+ SET last_run_at = EXCLUDED.last_run_at, updated_at = now()`,
215
+ [ulid(), entry.name, tickBoundary],
216
+ )
217
+ })
218
+ }
219
+ }
220
+
221
+ /** Floor a Date to the start of its minute (seconds + millis cleared). */
222
+ function floorToMinute(date: Date): Date {
223
+ return new Date(
224
+ Date.UTC(
225
+ date.getUTCFullYear(),
226
+ date.getUTCMonth(),
227
+ date.getUTCDate(),
228
+ date.getUTCHours(),
229
+ date.getUTCMinutes(),
230
+ 0,
231
+ 0,
232
+ ),
233
+ )
234
+ }
235
+
236
+ /** Next-minute boundary strictly after `date`. */
237
+ function nextMinuteBoundary(date: Date): Date {
238
+ const floor = floorToMinute(date)
239
+ return new Date(floor.getTime() + 60_000)
240
+ }
241
+
242
+ /** Abort-aware sleep. */
243
+ function sleep(ms: number, signal: AbortSignal): Promise<void> {
244
+ return new Promise<void>((resolve) => {
245
+ if (signal.aborted) {
246
+ resolve()
247
+ return
248
+ }
249
+ const timer = setTimeout(resolve, ms)
250
+ const onAbort = () => {
251
+ clearTimeout(timer)
252
+ signal.removeEventListener('abort', onAbort)
253
+ resolve()
254
+ }
255
+ signal.addEventListener('abort', onAbort, { once: true })
256
+ })
257
+ }
258
+
259
+ /** No-op Logger — same shape as the ones in DatabaseQueue / Worker. */
260
+ function createNoopLogger(): Logger {
261
+ const noop = () => undefined
262
+ return {
263
+ debug: noop,
264
+ info: noop,
265
+ warn: noop,
266
+ error: noop,
267
+ fatal: noop,
268
+ trace: noop,
269
+ child: () => createNoopLogger(),
270
+ } as unknown as Logger
271
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * `strav_scheduler_runs` schema — tracks the last tick boundary at
3
+ * which a `Scheduler.onOneServer` entry dispatched.
4
+ *
5
+ * Why the table exists: `pg_advisory_xact_lock` releases on COMMIT —
6
+ * the lock is held only for the duration of the dispatch transaction
7
+ * (~10ms). Without a run-tracking row, two servers entering the lock
8
+ * block back-to-back at the start of the same minute would each see
9
+ * "cron matches now" and dispatch — double work.
10
+ *
11
+ * The check inside the lock block:
12
+ * 1. SELECT last_run_at WHERE name = $name.
13
+ * 2. If last_run_at >= tick_boundary, another server already won
14
+ * this minute. Skip.
15
+ * 3. Otherwise dispatch + UPSERT last_run_at = tick_boundary.
16
+ *
17
+ * The advisory lock serializes the read+write so the check is honest.
18
+ *
19
+ * Schema constraints: framework PK kinds are `id`/`uuid`/`bigSerial`/
20
+ * `tenantedBigSerial`. None are text, so we can't make `name` itself
21
+ * the PK; instead ULID PK + `name` UNIQUE. The ULID is dead weight on
22
+ * the application side but keeps the row shape consistent with the
23
+ * rest of the framework.
24
+ */
25
+
26
+ import { Archetype, defineSchema } from '@strav/database'
27
+
28
+ export const schedulerRunsSchema = defineSchema('strav_scheduler_runs', Archetype.Entity, (t) => {
29
+ t.id()
30
+ t.string('name').max(128).unique()
31
+ t.timestamp('last_run_at')
32
+ t.timestamps()
33
+ })
@@ -0,0 +1,126 @@
1
+ /**
2
+ * `SyncQueue` — in-process synchronous Queue driver.
3
+ *
4
+ * Instantiates the Job via the container (so `@inject()` resolves
5
+ * dependencies the same way the real Worker would), builds a
6
+ * `JobContext`, runs `handle(ctx)`. No persistence, no retries — if
7
+ * the handler throws, the throw propagates to the dispatcher.
8
+ *
9
+ * Use cases:
10
+ * - **Tests** — drive a job end-to-end without standing up a
11
+ * `DatabaseQueue` + Worker.
12
+ * - **Single-process dev** — flatten the Queue → Worker hop so
13
+ * `bun dev` shows job output inline.
14
+ * - **Inline-by-design** — rare in production, but callers that
15
+ * genuinely want sync execution (e.g. an importer that's already
16
+ * in a transaction) call `dispatchSync` directly.
17
+ *
18
+ * The non-sync methods (`dispatch` / `dispatchLater`) on this driver
19
+ * also run the work synchronously — they exist so the same code path
20
+ * works in both sync-only test setups and async-Worker production
21
+ * setups. `dispatchLater`'s delay is ignored under SyncQueue; the work
22
+ * runs immediately.
23
+ */
24
+
25
+ import { type Container, type Logger, ulid } from '@strav/kernel'
26
+ import type { JobClass, JobContext, PayloadOf } from './job.ts'
27
+ import type { DispatchLaterOptions, DispatchOptions, Queue } from './queue.ts'
28
+
29
+ export interface SyncQueueOptions {
30
+ /**
31
+ * Container used to construct Job instances. `make(JobClass)` builds
32
+ * via `@inject()` metadata, so subclasses with constructor deps get
33
+ * the same wiring the production Worker provides.
34
+ */
35
+ container: Container
36
+ /**
37
+ * Optional Logger to attach to every `JobContext.log`. When omitted,
38
+ * a no-op logger is used — useful in tests where log noise is
39
+ * unwelcome.
40
+ */
41
+ logger?: Logger
42
+ }
43
+
44
+ export class SyncQueue implements Queue {
45
+ private readonly container: Container
46
+ private readonly logger: Logger
47
+
48
+ constructor(opts: SyncQueueOptions) {
49
+ this.container = opts.container
50
+ this.logger = opts.logger ?? createNoopLogger()
51
+ }
52
+
53
+ async dispatch<TJob extends JobClass>(
54
+ jobClass: TJob,
55
+ payload: PayloadOf<TJob>,
56
+ _opts?: DispatchOptions,
57
+ ): Promise<string> {
58
+ const jobId = ulid()
59
+ await this.run(jobId, jobClass, payload)
60
+ return jobId
61
+ }
62
+
63
+ async dispatchLater<TJob extends JobClass>(
64
+ at: Date | number,
65
+ jobClass: TJob,
66
+ payload: PayloadOf<TJob>,
67
+ _opts?: DispatchLaterOptions,
68
+ ): Promise<string> {
69
+ // Validate the delay even though we don't honor it — same contract
70
+ // surface as the production driver, so callers can't accidentally
71
+ // pass a negative delay that "works" under SyncQueue and fails on
72
+ // DatabaseQueue.
73
+ if (typeof at === 'number' && at < 0) {
74
+ throw new Error(`SyncQueue.dispatchLater: delay must be non-negative, got ${at}.`)
75
+ }
76
+ return this.dispatch(jobClass, payload)
77
+ }
78
+
79
+ async dispatchSync<TJob extends JobClass>(
80
+ jobClass: TJob,
81
+ payload: PayloadOf<TJob>,
82
+ ): Promise<void> {
83
+ await this.run(ulid(), jobClass, payload)
84
+ }
85
+
86
+ private async run<TJob extends JobClass>(
87
+ jobId: string,
88
+ jobClass: TJob,
89
+ payload: PayloadOf<TJob>,
90
+ ): Promise<void> {
91
+ const job = this.container.make(jobClass)
92
+ const ctx: JobContext = {
93
+ jobId,
94
+ attempt: 1,
95
+ payload,
96
+ // SyncQueue runs to completion in one tick — abort isn't a thing
97
+ // here. We hand out a never-aborted signal so handlers written
98
+ // against the production contract don't need a separate code
99
+ // path.
100
+ signal: new AbortController().signal,
101
+ log: this.logger,
102
+ }
103
+ await job.handle(ctx)
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Bare Logger that drops every call. Avoids pulling in the full
109
+ * LoggerProvider when callers just want SyncQueue in tests.
110
+ */
111
+ function createNoopLogger(): Logger {
112
+ const noop = () => undefined
113
+ // The Logger contract is wider; the few methods downstream code may
114
+ // call are stubbed. Cast keeps the type signature honest at the
115
+ // boundary — Logger's full surface includes `child()` etc., which a
116
+ // future caller could exercise.
117
+ return {
118
+ debug: noop,
119
+ info: noop,
120
+ warn: noop,
121
+ error: noop,
122
+ fatal: noop,
123
+ trace: noop,
124
+ child: () => createNoopLogger(),
125
+ } as unknown as Logger
126
+ }