elm-ssr 0.3.0 → 0.5.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/elm-src/ElmSsr/Loader.elm +177 -0
- package/package.json +2 -1
- package/src/jobs/index.ts +7 -0
- package/src/jobs/runner.ts +137 -0
- package/src/jobs/store.ts +60 -0
- package/src/jobs/types.ts +41 -0
|
@@ -9,6 +9,8 @@ module ElmSsr.Loader exposing
|
|
|
9
9
|
, getCookie
|
|
10
10
|
, enqueue
|
|
11
11
|
, session, csrfToken, setSession, clearSession
|
|
12
|
+
, custom
|
|
13
|
+
, JobId, JobStatus(..), startJob, jobStatus
|
|
12
14
|
, Effect, Step(..), step, encodeEffect
|
|
13
15
|
)
|
|
14
16
|
|
|
@@ -45,6 +47,31 @@ request time.
|
|
|
45
47
|
@docs session, csrfToken, setSession, clearSession
|
|
46
48
|
|
|
47
49
|
|
|
50
|
+
# Custom effects
|
|
51
|
+
|
|
52
|
+
Escape hatch for emitting a `kind` your own `EffectRunner` adapter handles.
|
|
53
|
+
Use this to plug in things the framework does not cover natively — fan-out
|
|
54
|
+
SQL with `Promise.all`, external services, vector search, queues. The shape
|
|
55
|
+
of `payload` and the decoder for the result are entirely your contract with
|
|
56
|
+
your adapter.
|
|
57
|
+
|
|
58
|
+
@docs custom
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# Background jobs
|
|
62
|
+
|
|
63
|
+
Long-running work that exceeds a single request budget. Submit a job with
|
|
64
|
+
[`startJob`](#startJob) (returns an id immediately), poll progress with
|
|
65
|
+
[`jobStatus`](#jobStatus). The TS-side `withJobs(runner, { store,
|
|
66
|
+
handlers })` adapter persists records and runs handlers via `ctx.waitUntil`.
|
|
67
|
+
|
|
68
|
+
A typical PRG flow: form action calls `startJob`, redirects to
|
|
69
|
+
`/jobs/<id>`, that page polls `jobStatus` (or subscribes via SSE) and
|
|
70
|
+
renders progress until `JobDone`.
|
|
71
|
+
|
|
72
|
+
@docs JobId, JobStatus, startJob, jobStatus
|
|
73
|
+
|
|
74
|
+
|
|
48
75
|
# Runtime interpretation
|
|
49
76
|
|
|
50
77
|
These are used by the elm-ssr runtime to drive a loader. Application authors do
|
|
@@ -309,6 +336,156 @@ clearSession =
|
|
|
309
336
|
(\_ -> Done ())
|
|
310
337
|
|
|
311
338
|
|
|
339
|
+
{-| Emit a custom effect that your TS-side `EffectRunner` handles. The runner
|
|
340
|
+
must inspect `effect.kind === <your kind>` and return
|
|
341
|
+
`{ ok: true, value: <data> }`; the `decoder` runs against that value.
|
|
342
|
+
|
|
343
|
+
The common use case is server-side fan-out — run several heavy SQL queries
|
|
344
|
+
or external fetches with `Promise.all` inside one effect call, then return a
|
|
345
|
+
combined payload so the route only awaits once:
|
|
346
|
+
|
|
347
|
+
-- Elm:
|
|
348
|
+
import Json.Decode as Decode
|
|
349
|
+
|
|
350
|
+
type alias Dashboard =
|
|
351
|
+
{ totals : Int, recent : List Order, byCountry : List CountryStat }
|
|
352
|
+
|
|
353
|
+
dashboard : Loader Dashboard
|
|
354
|
+
dashboard =
|
|
355
|
+
Loader.custom
|
|
356
|
+
{ kind = "parallelDashboard"
|
|
357
|
+
, payload = Encode.object []
|
|
358
|
+
, decoder = dashboardDecoder
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
-- TS adapter (wrap your existing runner):
|
|
362
|
+
const myEffects = async (effect, ctx) => {
|
|
363
|
+
if (effect.kind === "parallelDashboard") {
|
|
364
|
+
const [totals, recent, byCountry] = await Promise.all([
|
|
365
|
+
pg.unsafe("SELECT count(*) AS c FROM orders"),
|
|
366
|
+
pg.unsafe("SELECT * FROM orders ORDER BY created DESC LIMIT 10"),
|
|
367
|
+
pg.unsafe("SELECT country, sum(total) AS s FROM orders GROUP BY 1"),
|
|
368
|
+
]);
|
|
369
|
+
return { ok: true, value: { totals: totals[0].c, recent, byCountry } };
|
|
370
|
+
}
|
|
371
|
+
return baseRunner(effect, ctx);
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
See `docs/recipes/parallel-queries.md` for the full pattern.
|
|
375
|
+
-}
|
|
376
|
+
custom : { kind : String, payload : Encode.Value, decoder : Decoder a } -> Loader a
|
|
377
|
+
custom config =
|
|
378
|
+
Pending
|
|
379
|
+
{ kind = config.kind, payload = config.payload }
|
|
380
|
+
(\result -> resumeFetchJson config.decoder result)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
{-| Opaque handle to a submitted background job. Treat as a string for routing
|
|
384
|
+
or storage; the TS-side store assigns it. -}
|
|
385
|
+
type alias JobId =
|
|
386
|
+
String
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
{-| The state of a job as the TS-side `JobStore` sees it. Decoded from the
|
|
390
|
+
record by [`jobStatus`](#jobStatus); unknown ids decode as `JobMissing`.
|
|
391
|
+
|
|
392
|
+
- `JobQueued` — accepted, not yet running.
|
|
393
|
+
- `JobRunning { progress }` — currently executing; `progress` carries
|
|
394
|
+
whatever the handler last reported (or `Nothing`).
|
|
395
|
+
- `JobDone result` — handler resolved; `result` is the decoded value.
|
|
396
|
+
- `JobFailed { reason }` — handler threw; `reason` is the error message.
|
|
397
|
+
- `JobMissing` — no record under this id (TTL expired, never existed).
|
|
398
|
+
|
|
399
|
+
-}
|
|
400
|
+
type JobStatus a
|
|
401
|
+
= JobQueued
|
|
402
|
+
| JobRunning { progress : Maybe Decode.Value }
|
|
403
|
+
| JobDone a
|
|
404
|
+
| JobFailed { reason : String }
|
|
405
|
+
| JobMissing
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
{-| Submit a background job. Returns the assigned `JobId` immediately —
|
|
409
|
+
the handler runs after the response goes out (via `ctx.waitUntil` on
|
|
410
|
+
Cloudflare, fire-and-forget locally).
|
|
411
|
+
|
|
412
|
+
Pair `kind` with a handler registered in your TS-side
|
|
413
|
+
`withJobs(runner, { handlers })`. `payload` is your handler's input;
|
|
414
|
+
decoder for the result is on [`jobStatus`](#jobStatus).
|
|
415
|
+
|
|
416
|
+
submit : Action (Document Never)
|
|
417
|
+
submit =
|
|
418
|
+
Action.fromLoader
|
|
419
|
+
(Loader.startJob
|
|
420
|
+
{ kind = "generateReport"
|
|
421
|
+
, payload = Encode.object [ ( "month", Encode.string "2026-05" ) ]
|
|
422
|
+
}
|
|
423
|
+
)
|
|
424
|
+
|> Action.andThen (\id -> Action.redirect ("/reports/" ++ id))
|
|
425
|
+
|
|
426
|
+
-}
|
|
427
|
+
startJob : { kind : String, payload : Encode.Value } -> Loader JobId
|
|
428
|
+
startJob config =
|
|
429
|
+
Pending
|
|
430
|
+
{ kind = "startJob"
|
|
431
|
+
, payload =
|
|
432
|
+
Encode.object
|
|
433
|
+
[ ( "kind", Encode.string config.kind )
|
|
434
|
+
, ( "payload", config.payload )
|
|
435
|
+
]
|
|
436
|
+
}
|
|
437
|
+
(\result -> resumeFetchJson Decode.string result)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
{-| Read the current state of a submitted job. Decoded into [`JobStatus`](#JobStatus).
|
|
441
|
+
The result decoder runs against the handler's return value when `status === "done"`.
|
|
442
|
+
|
|
443
|
+
statusLoader : JobId -> Loader (JobStatus Report)
|
|
444
|
+
statusLoader id =
|
|
445
|
+
Loader.jobStatus { jobId = id, decoder = reportDecoder }
|
|
446
|
+
|
|
447
|
+
-}
|
|
448
|
+
jobStatus : { jobId : JobId, decoder : Decoder a } -> Loader (JobStatus a)
|
|
449
|
+
jobStatus config =
|
|
450
|
+
Pending
|
|
451
|
+
{ kind = "jobStatus"
|
|
452
|
+
, payload = Encode.object [ ( "jobId", Encode.string config.jobId ) ]
|
|
453
|
+
}
|
|
454
|
+
(\result -> resumeFetchJson (jobStatusDecoder config.decoder) result)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
jobStatusDecoder : Decoder a -> Decoder (JobStatus a)
|
|
458
|
+
jobStatusDecoder resultDecoder =
|
|
459
|
+
Decode.oneOf
|
|
460
|
+
[ Decode.null JobMissing
|
|
461
|
+
, Decode.field "status" Decode.string
|
|
462
|
+
|> Decode.andThen
|
|
463
|
+
(\status ->
|
|
464
|
+
case status of
|
|
465
|
+
"queued" ->
|
|
466
|
+
Decode.succeed JobQueued
|
|
467
|
+
|
|
468
|
+
"running" ->
|
|
469
|
+
Decode.map (\progress -> JobRunning { progress = progress })
|
|
470
|
+
(Decode.maybe (Decode.field "progress" Decode.value))
|
|
471
|
+
|
|
472
|
+
"done" ->
|
|
473
|
+
Decode.map JobDone (Decode.field "result" resultDecoder)
|
|
474
|
+
|
|
475
|
+
"failed" ->
|
|
476
|
+
Decode.map (\reason -> JobFailed { reason = reason })
|
|
477
|
+
(Decode.oneOf
|
|
478
|
+
[ Decode.field "error" Decode.string
|
|
479
|
+
, Decode.succeed "Job failed without a message"
|
|
480
|
+
]
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
other ->
|
|
484
|
+
Decode.fail ("Unknown job status: " ++ other)
|
|
485
|
+
)
|
|
486
|
+
]
|
|
487
|
+
|
|
488
|
+
|
|
312
489
|
sqlPayload : String -> List Encode.Value -> Encode.Value
|
|
313
490
|
sqlPayload sql params =
|
|
314
491
|
Encode.object
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "elm-ssr",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Elm-first SSR library and framework for Cloudflare Workers (and Bun): file-based routes/islands, backend-neutral effect adapters (KV/D1/Redis/Postgres), background tasks (waitUntil/Queues), SQL-file migrations, CLI scaffold + build.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Michał Majchrzak <michmajchrzak@gmail.com>",
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
"./middleware": "./src/middleware.ts",
|
|
43
43
|
"./sessions": "./src/sessions/index.ts",
|
|
44
44
|
"./sse": "./src/sse.ts",
|
|
45
|
+
"./jobs": "./src/jobs/index.ts",
|
|
45
46
|
"./response-headers": "./src/response-headers.ts",
|
|
46
47
|
"./serialize": "./src/serialize.ts",
|
|
47
48
|
"./protocol": "./src/protocol.ts",
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Public API for the background-job layer. Wrap your effect runner with
|
|
2
|
+
// `withJobs(runner, { store, handlers })` to enable `Loader.startJob` /
|
|
3
|
+
// `Loader.jobStatus` on the Elm side.
|
|
4
|
+
|
|
5
|
+
export { memoryJobStore, cacheJobStore, type CacheJobStoreOptions } from "./store";
|
|
6
|
+
export { withJobs, type JobsConfig } from "./runner";
|
|
7
|
+
export type { JobRecord, JobStore, JobContext, JobHandler, JobHandlers } from "./types";
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import type { EffectContext, EffectRunner } from "../effects";
|
|
2
|
+
import type { JobContext, JobHandlers, JobRecord, JobStore } from "./types";
|
|
3
|
+
|
|
4
|
+
export interface JobsConfig {
|
|
5
|
+
/** Where to persist job records. Use `memoryJobStore()` in dev/tests, `cacheJobStore(redisCache(...))` in prod. */
|
|
6
|
+
store: JobStore;
|
|
7
|
+
/** Named job handlers. Each maps `kind` → async function returning the result. */
|
|
8
|
+
handlers: JobHandlers;
|
|
9
|
+
/** Default record TTL in seconds (default `60 * 60 * 24` = 24h). */
|
|
10
|
+
defaultTtlSeconds?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const DEFAULT_TTL = 60 * 60 * 24;
|
|
14
|
+
|
|
15
|
+
const generateJobId = (): string => crypto.randomUUID();
|
|
16
|
+
|
|
17
|
+
const runJob = (
|
|
18
|
+
store: JobStore,
|
|
19
|
+
handler: JobHandlers[string] | undefined,
|
|
20
|
+
record: JobRecord,
|
|
21
|
+
context: EffectContext
|
|
22
|
+
): Promise<void> =>
|
|
23
|
+
Promise.resolve().then(async () => {
|
|
24
|
+
if (!handler) {
|
|
25
|
+
await store.set(record.id, {
|
|
26
|
+
...record,
|
|
27
|
+
status: "failed",
|
|
28
|
+
error: `No handler registered for kind "${record.kind}"`,
|
|
29
|
+
finishedAt: Date.now()
|
|
30
|
+
});
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const aborter = new AbortController();
|
|
35
|
+
// If the request's signal is still around (e.g. Bun fetch), wire it through;
|
|
36
|
+
// otherwise the job runs to completion regardless.
|
|
37
|
+
if (context.request) {
|
|
38
|
+
const reqSignal = context.request.signal;
|
|
39
|
+
if (reqSignal.aborted) {
|
|
40
|
+
aborter.abort();
|
|
41
|
+
} else {
|
|
42
|
+
reqSignal.addEventListener("abort", () => aborter.abort(), { once: true });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let progressVersion = 0;
|
|
47
|
+
const jobContext: JobContext = {
|
|
48
|
+
jobId: record.id,
|
|
49
|
+
reportProgress: async (value) => {
|
|
50
|
+
progressVersion += 1;
|
|
51
|
+
const current = await store.get(record.id);
|
|
52
|
+
if (!current) return;
|
|
53
|
+
await store.set(record.id, { ...current, progress: value });
|
|
54
|
+
},
|
|
55
|
+
signal: aborter.signal
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const startedAt = Date.now();
|
|
59
|
+
await store.set(record.id, { ...record, status: "running", startedAt });
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const result = await handler(record.payload, jobContext);
|
|
63
|
+
await store.set(record.id, {
|
|
64
|
+
...record,
|
|
65
|
+
status: "done",
|
|
66
|
+
startedAt,
|
|
67
|
+
finishedAt: Date.now(),
|
|
68
|
+
result
|
|
69
|
+
});
|
|
70
|
+
} catch (error) {
|
|
71
|
+
console.error(`elm-ssr: job "${record.kind}" (${record.id}) failed`, error);
|
|
72
|
+
await store.set(record.id, {
|
|
73
|
+
...record,
|
|
74
|
+
status: "failed",
|
|
75
|
+
startedAt,
|
|
76
|
+
finishedAt: Date.now(),
|
|
77
|
+
error: String(error instanceof Error ? error.message : error)
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Mark "finished progress version" so concurrent reportProgress calls after completion are no-ops.
|
|
82
|
+
void progressVersion;
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Wraps an effect runner so `Loader.startJob` schedules a background job and
|
|
87
|
+
* `Loader.jobStatus` reads its current record. Jobs run via `ctx.waitUntil`
|
|
88
|
+
* on Cloudflare (isolate stays alive); locally (no waitUntil) they run
|
|
89
|
+
* fire-and-forget. All other effects pass through unchanged.
|
|
90
|
+
*
|
|
91
|
+
* Use a durable `JobStore` (cacheJobStore over Redis/KV) in production —
|
|
92
|
+
* `memoryJobStore` is process-local and lost on isolate restart.
|
|
93
|
+
*/
|
|
94
|
+
export const withJobs = (runner: EffectRunner, config: JobsConfig): EffectRunner => {
|
|
95
|
+
const ttlSeconds = config.defaultTtlSeconds ?? DEFAULT_TTL;
|
|
96
|
+
|
|
97
|
+
return async (effect, context) => {
|
|
98
|
+
if (effect.kind === "startJob") {
|
|
99
|
+
const kind = String(effect.payload.kind ?? "");
|
|
100
|
+
if (!kind) {
|
|
101
|
+
return { ok: false, error: 'startJob requires a non-empty "kind"' };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const id = generateJobId();
|
|
105
|
+
const record: JobRecord = {
|
|
106
|
+
id,
|
|
107
|
+
kind,
|
|
108
|
+
payload: effect.payload.payload ?? null,
|
|
109
|
+
status: "queued",
|
|
110
|
+
expiresAt: Date.now() + ttlSeconds * 1000
|
|
111
|
+
};
|
|
112
|
+
await config.store.set(id, record);
|
|
113
|
+
|
|
114
|
+
const work = runJob(config.store, config.handlers[kind], record, context);
|
|
115
|
+
if (typeof context.waitUntil === "function") {
|
|
116
|
+
context.waitUntil(work);
|
|
117
|
+
} else {
|
|
118
|
+
void work.catch((error) => console.error("elm-ssr: job scheduling failed", error));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return { ok: true, value: id };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (effect.kind === "jobStatus") {
|
|
125
|
+
const id = String(effect.payload.jobId ?? "");
|
|
126
|
+
if (!id) {
|
|
127
|
+
return { ok: false, error: 'jobStatus requires a non-empty "jobId"' };
|
|
128
|
+
}
|
|
129
|
+
const record = await config.store.get(id);
|
|
130
|
+
// Returning the raw record (or null for unknown id). Elm-side decoder
|
|
131
|
+
// turns this into JobStatus a.
|
|
132
|
+
return { ok: true, value: record };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return runner(effect, context);
|
|
136
|
+
};
|
|
137
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { CacheBackend } from "../backends";
|
|
2
|
+
import type { JobRecord, JobStore } from "./types";
|
|
3
|
+
|
|
4
|
+
/** In-memory job store. Useful for dev/tests; lost on process restart. */
|
|
5
|
+
export const memoryJobStore = (initial?: Map<string, JobRecord>): JobStore => {
|
|
6
|
+
const store = initial ?? new Map<string, JobRecord>();
|
|
7
|
+
return {
|
|
8
|
+
get: async (id) => {
|
|
9
|
+
const record = store.get(id);
|
|
10
|
+
if (!record) return null;
|
|
11
|
+
if (record.expiresAt !== undefined && record.expiresAt <= Date.now()) {
|
|
12
|
+
store.delete(id);
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
return record;
|
|
16
|
+
},
|
|
17
|
+
set: async (id, record) => {
|
|
18
|
+
store.set(id, record);
|
|
19
|
+
},
|
|
20
|
+
delete: async (id) => {
|
|
21
|
+
store.delete(id);
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export interface CacheJobStoreOptions {
|
|
27
|
+
/** Cache-key prefix (default `"elm-ssr:job:"`). */
|
|
28
|
+
keyPrefix?: string;
|
|
29
|
+
/** Default TTL in seconds when the record has no `expiresAt`. */
|
|
30
|
+
defaultTtlSeconds?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Job store backed by an existing `CacheBackend` — wire it on
|
|
35
|
+
* `redisCache(...)`, a KV-backed cache, or anything matching the interface.
|
|
36
|
+
* Records are namespaced via the key prefix to avoid colliding with your
|
|
37
|
+
* other cache uses.
|
|
38
|
+
*/
|
|
39
|
+
export const cacheJobStore = (backend: CacheBackend, options: CacheJobStoreOptions = {}): JobStore => {
|
|
40
|
+
const prefix = options.keyPrefix ?? "elm-ssr:job:";
|
|
41
|
+
const defaultTtl = options.defaultTtlSeconds;
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
get: async (id) => {
|
|
45
|
+
const value = await backend.get(prefix + id);
|
|
46
|
+
return (value ?? null) as JobRecord | null;
|
|
47
|
+
},
|
|
48
|
+
set: async (id, record) => {
|
|
49
|
+
const ttl =
|
|
50
|
+
record.expiresAt !== undefined
|
|
51
|
+
? Math.max(1, Math.ceil((record.expiresAt - Date.now()) / 1000))
|
|
52
|
+
: defaultTtl;
|
|
53
|
+
await backend.put(prefix + id, record, ttl);
|
|
54
|
+
},
|
|
55
|
+
delete: async (id) => {
|
|
56
|
+
// CacheBackend has no delete; tombstone with TTL=1s.
|
|
57
|
+
await backend.put(prefix + id, null, 1);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** A persisted background job. The lifecycle is queued → running → done | failed. */
|
|
2
|
+
export interface JobRecord {
|
|
3
|
+
id: string;
|
|
4
|
+
kind: string;
|
|
5
|
+
/** Payload the job was started with. */
|
|
6
|
+
payload: unknown;
|
|
7
|
+
status: "queued" | "running" | "done" | "failed";
|
|
8
|
+
/** User-supplied progress payload (set via `reportProgress`). */
|
|
9
|
+
progress?: unknown;
|
|
10
|
+
/** Result payload when `status === "done"`. */
|
|
11
|
+
result?: unknown;
|
|
12
|
+
/** Error message when `status === "failed"`. */
|
|
13
|
+
error?: string;
|
|
14
|
+
/** Epoch ms when the handler started executing. */
|
|
15
|
+
startedAt?: number;
|
|
16
|
+
/** Epoch ms when the handler resolved or rejected. */
|
|
17
|
+
finishedAt?: number;
|
|
18
|
+
/** Epoch ms at which the store may evict the record. */
|
|
19
|
+
expiresAt?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Driver-agnostic job storage. Wire memory / cache / SQL behind it. */
|
|
23
|
+
export interface JobStore {
|
|
24
|
+
get(id: string): Promise<JobRecord | null>;
|
|
25
|
+
set(id: string, record: JobRecord): Promise<void>;
|
|
26
|
+
delete(id: string): Promise<void>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Per-job context passed to the handler. */
|
|
30
|
+
export interface JobContext {
|
|
31
|
+
jobId: string;
|
|
32
|
+
/** Update the record's `progress` field. Safe to call concurrently with the handler's work. */
|
|
33
|
+
reportProgress: (value: unknown) => Promise<void>;
|
|
34
|
+
/** Fires when the request context is gone (Worker isolate teardown) or store TTL elapses. */
|
|
35
|
+
signal: AbortSignal;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A handler for a named job kind. Returns the result; throw to mark failed. */
|
|
39
|
+
export type JobHandler = (payload: unknown, context: JobContext) => Promise<unknown> | unknown;
|
|
40
|
+
|
|
41
|
+
export type JobHandlers = Record<string, JobHandler>;
|