dsh-scheduled-message 0.1.1

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/index.js ADDED
@@ -0,0 +1,694 @@
1
+ /**
2
+ * Host half of dsh-scheduled-message.
3
+ *
4
+ * Owns one durable record per scheduled message and one `ctx.timer.timeout`
5
+ * armed for the earliest pending record, so a message still sends when the
6
+ * browser tab is closed and DSH keeps running; a message whose instant passed
7
+ * while DSH was closed is sent at the next start and marked late.
8
+ *
9
+ * Everything sits behind `ctx.inject`, so a profile without the Automation
10
+ * service still loads: Repeat is then reported as unavailable instead of
11
+ * failing the plugin.
12
+ *
13
+ * @module dsh-scheduled-message
14
+ */
15
+
16
+ import { randomUUID } from 'node:crypto'
17
+ import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain'
18
+ import {
19
+ DOMAIN_NAME,
20
+ MAX_PENDING,
21
+ NS,
22
+ buildRrule,
23
+ earliestPending,
24
+ isLate,
25
+ pruneTargets,
26
+ summariseTitle,
27
+ timerDelay,
28
+ validateCreateRequest,
29
+ } from './lib/core.js'
30
+
31
+ /** Largest accepted create body; ten images plus text fit comfortably. */
32
+ const MAX_CREATE_BODY_BYTES = 64 * 1024 * 1024
33
+
34
+ /** Delivery is abandoned after this long instead of holding the serialized queue. */
35
+ const DELIVERY_TIMEOUT_MS = 120_000
36
+
37
+ /** Records surfaced to a session's composer strip. */
38
+ const SENT_WINDOW_MS = 24 * 60 * 60 * 1000
39
+
40
+ /** Longest stored diagnostic. */
41
+ const MAX_ERROR_CHARS = 500
42
+
43
+ const STATUSES = new Set(['pending', 'sending', 'sent', 'failed', 'cancelled'])
44
+
45
+ const ROUTE_PREFIX = '/api/dsh-scheduled-message'
46
+
47
+ /** Stable machine codes the browser half maps onto localized copy. */
48
+ const CODES = {
49
+ invalidRequest: 'invalid-request',
50
+ tooSoon: 'too-soon',
51
+ unknownSession: 'unknown-session',
52
+ unknownReceipt: 'unknown-receipt',
53
+ repeatUnavailable: 'repeat-unavailable',
54
+ notPending: 'not-pending',
55
+ notFound: 'not-found',
56
+ tooManyPending: 'too-many-pending',
57
+ internal: 'internal',
58
+ }
59
+
60
+ function isRecord(value) {
61
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
62
+ }
63
+
64
+ function requireString(record, key) {
65
+ const value = record[key]
66
+ if (typeof value !== 'string' || value.length === 0) throw new Error(`${key} must be a non-empty string`)
67
+ return value
68
+ }
69
+
70
+ function requireInstant(record, key) {
71
+ const value = requireString(record, key)
72
+ if (!Number.isFinite(Date.parse(value))) throw new Error(`${key} must be an absolute instant`)
73
+ return value
74
+ }
75
+
76
+ function requireAttachmentRefs(record, key) {
77
+ const value = record[key]
78
+ if (!Array.isArray(value)) throw new Error(`${key} must be an array`)
79
+ return value.map((ref) => {
80
+ if (!isRecord(ref)) throw new Error(`${key} entries must be objects`)
81
+ if (typeof ref.attachmentId !== 'string' || ref.attachmentId.length === 0) {
82
+ throw new Error(`${key} entries need an attachmentId`)
83
+ }
84
+ return ref
85
+ })
86
+ }
87
+
88
+ /**
89
+ * Validate one stored record.
90
+ *
91
+ * The storage layer only ever calls `parse`, so this plain object plays the
92
+ * role `domainTable` normally fills with a zod schema: it normalizes one
93
+ * detached record and rejects anything the Host did not write itself.
94
+ */
95
+ const messageSchema = {
96
+ parse(value) {
97
+ if (!isRecord(value)) throw new Error('a scheduled message must be an object')
98
+ const status = requireString(value, 'status')
99
+ if (!STATUSES.has(status)) throw new Error(`unknown status ${status}`)
100
+ return {
101
+ id: requireString(value, 'id'),
102
+ sessionId: requireString(value, 'sessionId'),
103
+ dueAt: requireInstant(value, 'dueAt'),
104
+ timeZone: requireString(value, 'timeZone'),
105
+ text: typeof value.text === 'string' ? value.text : '',
106
+ images: requireAttachmentRefs(value, 'images'),
107
+ files: requireAttachmentRefs(value, 'files'),
108
+ createdAt: requireInstant(value, 'createdAt'),
109
+ updatedAt: requireInstant(value, 'updatedAt'),
110
+ status,
111
+ ...(value.sentAt === undefined ? {} : { sentAt: requireInstant(value, 'sentAt') }),
112
+ ...(value.late === undefined ? {} : { late: Boolean(value.late) }),
113
+ ...(value.error === undefined ? {} : { error: String(value.error).slice(0, MAX_ERROR_CHARS) }),
114
+ }
115
+ },
116
+ }
117
+
118
+ const domain = defineDomain({
119
+ name: DOMAIN_NAME,
120
+ version: 1,
121
+ tables: { messages: domainTable(messageSchema) },
122
+ })
123
+
124
+ function jsonResponse(status, body) {
125
+ return new Response(JSON.stringify(body), {
126
+ status,
127
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
128
+ })
129
+ }
130
+
131
+ function jsonOk(value) {
132
+ return jsonResponse(200, { ok: true, value })
133
+ }
134
+
135
+ function jsonError(code, message) {
136
+ return jsonResponse(200, { ok: false, error: { code, message } })
137
+ }
138
+
139
+ function messageOf(error) {
140
+ if (error instanceof Error) return error.message
141
+ return String(error)
142
+ }
143
+
144
+ /** Render one thrown error as a short user-facing diagnostic. */
145
+ function describeError(error) {
146
+ const code = isRecord(error) && typeof error.code === 'string' ? error.code : undefined
147
+ const text = messageOf(error).replace(/\s+/gu, ' ').trim()
148
+ const composed = code === undefined ? text : text.length === 0 ? code : `${code}: ${text}`
149
+ return (composed.length === 0 ? 'delivery failed' : composed).slice(0, MAX_ERROR_CHARS)
150
+ }
151
+
152
+ /** Read one request body as JSON, refusing an oversized streaming body early. */
153
+ async function readJsonBody(request, limit) {
154
+ const declared = Number(request.headers?.get?.('content-length') ?? Number.NaN)
155
+ if (Number.isFinite(declared) && declared > limit) return { ok: false, code: 'body-too-large' }
156
+ const body = request.body
157
+ let buffer
158
+ if (body !== null && body !== undefined && typeof body.getReader === 'function') {
159
+ const reader = body.getReader()
160
+ const chunks = []
161
+ let total = 0
162
+ for (;;) {
163
+ const { done, value } = await reader.read()
164
+ if (done === true) break
165
+ if (value === undefined) continue
166
+ total += value.byteLength
167
+ if (total > limit) {
168
+ await reader.cancel().catch(() => undefined)
169
+ return { ok: false, code: 'body-too-large' }
170
+ }
171
+ chunks.push(Buffer.from(value.buffer, value.byteOffset, value.byteLength))
172
+ }
173
+ buffer = Buffer.concat(chunks)
174
+ } else {
175
+ const text = await request.text()
176
+ if (text.length > limit) return { ok: false, code: 'body-too-large' }
177
+ buffer = Buffer.from(text, 'utf8')
178
+ }
179
+ if (buffer.byteLength === 0) return { ok: false, code: 'empty-body' }
180
+ try {
181
+ return { ok: true, value: JSON.parse(buffer.toString('utf8')) }
182
+ } catch {
183
+ return { ok: false, code: 'invalid-json' }
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Durable schedule store plus the single timer that delivers due messages.
189
+ *
190
+ * Records are read back from the domain rather than cached, so two tabs and a
191
+ * restart always act on the same durable state and a stale browser call is
192
+ * refused by status instead of being silently applied.
193
+ */
194
+ class Scheduler {
195
+ /**
196
+ * @param ctx - Host context that owns the services this feature needs.
197
+ */
198
+ constructor(ctx) {
199
+ this.ctx = ctx
200
+ /** @type {Promise<{ domain: any, table: any }> | undefined} */
201
+ this.opening = undefined
202
+ this.timer = undefined
203
+ this.armSeq = 0
204
+ this.chain = Promise.resolve()
205
+ this.stopping = false
206
+ /** @type {any} */
207
+ this.schedule = undefined
208
+ }
209
+
210
+ /** Open the durable domain once and prune expired records on first use. */
211
+ async opened() {
212
+ if (this.opening === undefined) {
213
+ this.opening = this.ctx.storageDomain.open(domain).then(async (openedDomain) => {
214
+ const handle = { domain: openedDomain, table: openedDomain.table('messages') }
215
+ try {
216
+ await this.prune(handle.table)
217
+ } catch (error) {
218
+ this.ctx.logger?.warn?.(`dsh-scheduled-message: pruning stored records failed: ${messageOf(error)}`)
219
+ }
220
+ return handle
221
+ }).catch((error) => {
222
+ this.opening = undefined
223
+ throw error
224
+ })
225
+ }
226
+ return await this.opening
227
+ }
228
+
229
+ /** The record table, opening the domain on first use. */
230
+ async table() {
231
+ return (await this.opened()).table
232
+ }
233
+
234
+ /** Every stored record. */
235
+ async records() {
236
+ const table = await this.table()
237
+ return [...table.entries()].map(([, record]) => record)
238
+ }
239
+
240
+ /** Drop finished records past retention. */
241
+ async prune(table) {
242
+ const ids = pruneTargets([...table.entries()].map(([, record]) => record), Date.now())
243
+ for (const id of ids) await table.delete(id)
244
+ }
245
+
246
+ /** Stop the armed timer without touching durable state. */
247
+ disarm() {
248
+ if (this.timer !== undefined) {
249
+ this.timer()
250
+ this.timer = undefined
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Arm one timer for the earliest pending record.
256
+ *
257
+ * Long waits are chunked: a delay above what `setTimeout` can hold fires
258
+ * early and re-arms, and every mutation and delivery re-arms again.
259
+ */
260
+ async rearm() {
261
+ const seq = ++this.armSeq
262
+ this.disarm()
263
+ if (this.stopping) return
264
+ // Arming is best-effort: a re-arm failure must never fail the request that
265
+ // triggered it, because the record it belongs to is already durable.
266
+ try {
267
+ const next = earliestPending(await this.records())
268
+ if (seq !== this.armSeq || this.stopping || next === undefined) return
269
+ const delay = timerDelay(Date.parse(next.dueAt), Date.now())
270
+ this.timer = this.ctx.timer.timeout(() => {
271
+ this.timer = undefined
272
+ void this.pump()
273
+ }, delay)
274
+ } catch (error) {
275
+ this.ctx.logger?.warn?.(`dsh-scheduled-message: arming the delivery timer failed: ${messageOf(error)}`)
276
+ }
277
+ }
278
+
279
+ /** Run one delivery task after every earlier one, keeping the chain settled. */
280
+ exclusive(task) {
281
+ const run = () => Promise.resolve().then(task)
282
+ const next = this.chain.then(run, run)
283
+ this.chain = next.then(() => undefined, () => undefined)
284
+ return next
285
+ }
286
+
287
+ /** Deliver whatever is due now, then re-arm. */
288
+ pump() {
289
+ return this.exclusive(() => this.runDue()).catch((error) => {
290
+ this.ctx.logger?.warn?.(`dsh-scheduled-message: delivery pass failed: ${messageOf(error)}`)
291
+ })
292
+ }
293
+
294
+ /** Deliver every record whose instant has passed, then re-arm. */
295
+ async runDue() {
296
+ if (this.stopping) return
297
+ const now = Date.now()
298
+ const due = (await this.records())
299
+ .filter((record) => (record.status === 'pending' || record.status === 'sending') && Date.parse(record.dueAt) <= now)
300
+ .sort((left, right) => Date.parse(left.dueAt) - Date.parse(right.dueAt))
301
+ for (const record of due) {
302
+ if (this.stopping) return
303
+ await this.deliver(record.id)
304
+ }
305
+ await this.prune(await this.table())
306
+ await this.rearm()
307
+ }
308
+
309
+ /** Build the wire content of one record, re-reading every stored attachment. */
310
+ async content(record, signal) {
311
+ const content = []
312
+ if (record.text.length > 0) content.push({ type: 'text', text: record.text })
313
+ for (const ref of record.images) {
314
+ const stored = await this.ctx.attachments.readImage(ref, signal)
315
+ content.push({
316
+ type: 'image',
317
+ mediaType: stored.ref.mediaType,
318
+ data: Buffer.from(stored.data).toString('base64'),
319
+ ...(ref.name === undefined ? {} : { name: ref.name }),
320
+ })
321
+ }
322
+ for (const ref of record.files) {
323
+ const uploaded = await this.ctx.fileUploads.uploadStream({
324
+ sessionId: record.sessionId,
325
+ data: this.ctx.attachments.readFileStream(ref, signal),
326
+ signal,
327
+ ...(ref.name === undefined ? {} : { name: ref.name }),
328
+ })
329
+ content.push({ type: 'file', receiptId: uploaded.receiptId })
330
+ }
331
+ return content
332
+ }
333
+
334
+ /**
335
+ * Deliver one record.
336
+ *
337
+ * The request id is derived from the record id, so a retry after a crash
338
+ * cannot deliver the same message twice: the Session inbox refuses a
339
+ * repeated request id.
340
+ * @param id - record id.
341
+ * @returns the settled record, or undefined when it no longer exists.
342
+ */
343
+ async deliver(id) {
344
+ const table = await this.table()
345
+ const record = table.get(id)
346
+ if (record === undefined) return undefined
347
+ if (record.status !== 'pending' && record.status !== 'sending') return record
348
+ const startedIso = new Date().toISOString()
349
+ const sending = messageSchema.parse({ ...record, status: 'sending', updatedAt: startedIso })
350
+ await table.put(id, sending)
351
+ const signal = AbortSignal.timeout(DELIVERY_TIMEOUT_MS)
352
+ try {
353
+ const content = await this.content(sending, signal)
354
+ await this.ctx.sessionController.prompt({
355
+ requestId: `dsm-${sending.id}`,
356
+ sessionId: sending.sessionId,
357
+ mode: 'queue',
358
+ content,
359
+ clientTimeZone: sending.timeZone,
360
+ }, signal)
361
+ const settledMs = Date.now()
362
+ const sent = messageSchema.parse({
363
+ ...sending,
364
+ status: 'sent',
365
+ sentAt: new Date(settledMs).toISOString(),
366
+ updatedAt: new Date(settledMs).toISOString(),
367
+ late: isLate(Date.parse(sending.dueAt), settledMs),
368
+ })
369
+ await table.put(id, sent)
370
+ return sent
371
+ } catch (error) {
372
+ const failed = messageSchema.parse({
373
+ ...sending,
374
+ status: 'failed',
375
+ updatedAt: new Date().toISOString(),
376
+ error: describeError(error),
377
+ })
378
+ await table.put(id, failed)
379
+ this.ctx.logger?.warn?.(`dsh-scheduled-message: delivery of ${id} failed: ${failed.error}`)
380
+ return failed
381
+ } finally {
382
+ void this.rearm()
383
+ }
384
+ }
385
+
386
+ /** Create one durable record from an already validated request. */
387
+ async createRecord(validated, images, files) {
388
+ const table = await this.table()
389
+ const nowIso = new Date().toISOString()
390
+ const record = messageSchema.parse({
391
+ id: randomUUID(),
392
+ sessionId: validated.sessionId,
393
+ dueAt: validated.dueAt,
394
+ timeZone: validated.timeZone,
395
+ text: validated.text,
396
+ images,
397
+ files,
398
+ createdAt: nowIso,
399
+ updatedAt: nowIso,
400
+ status: 'pending',
401
+ })
402
+ await table.put(record.id, record)
403
+ return record
404
+ }
405
+
406
+ /** Count the records that still hold a timer obligation. */
407
+ async pendingCount() {
408
+ return (await this.records()).filter((record) => record.status === 'pending').length
409
+ }
410
+ }
411
+
412
+ /** Compose the JSON view of one record for the browser half. */
413
+ function publicRecord(record) {
414
+ return {
415
+ id: record.id,
416
+ sessionId: record.sessionId,
417
+ dueAt: record.dueAt,
418
+ timeZone: record.timeZone,
419
+ text: record.text,
420
+ imageCount: record.images.length,
421
+ fileCount: record.files.length,
422
+ status: record.status,
423
+ createdAt: record.createdAt,
424
+ updatedAt: record.updatedAt,
425
+ ...(record.sentAt === undefined ? {} : { sentAt: record.sentAt }),
426
+ ...(record.late === undefined ? {} : { late: record.late }),
427
+ ...(record.error === undefined ? {} : { error: record.error }),
428
+ }
429
+ }
430
+
431
+ /**
432
+ * Apply one allowed status change under the serialized delivery chain.
433
+ * @param scheduler - owning store.
434
+ * @param id - record id.
435
+ * @param change - projection returning the patch, or null when the status forbids it.
436
+ */
437
+ async function settleMutation(scheduler, id, change) {
438
+ return await scheduler.exclusive(async () => {
439
+ const table = await scheduler.table()
440
+ const record = table.get(id)
441
+ if (record === undefined) return jsonError(CODES.notFound, 'That scheduled message no longer exists.')
442
+ const patch = change(record)
443
+ if (patch === null) return jsonError(CODES.notPending, 'That message is no longer waiting to be sent.')
444
+ const next = messageSchema.parse({ ...record, ...patch, updatedAt: new Date().toISOString() })
445
+ await table.put(id, next)
446
+ await scheduler.rearm()
447
+ return jsonOk({ message: publicRecord(next) })
448
+ })
449
+ }
450
+
451
+ /**
452
+ * Validate and commit one create request.
453
+ * @param scheduler - owning store.
454
+ * @param body - untrusted JSON body.
455
+ */
456
+ async function handleCreate(scheduler, body) {
457
+ const validated = validateCreateRequest(body, Date.now())
458
+ if (validated.ok !== true) return jsonError(validated.code, validated.message)
459
+ const request = validated.value
460
+
461
+ let agent
462
+ try {
463
+ const resolved = await scheduler.ctx.sessionController.resolveAgent(request.sessionId)
464
+ if (resolved === null || typeof resolved !== 'object' || 'error' in resolved) {
465
+ return jsonError(CODES.unknownSession, 'That conversation is no longer available; reopen it and try again.')
466
+ }
467
+ agent = resolved.agent
468
+ if (agent === undefined) {
469
+ return jsonError(CODES.unknownSession, 'That conversation is no longer available; reopen it and try again.')
470
+ }
471
+ } catch (error) {
472
+ return jsonError(CODES.unknownSession, describeError(error))
473
+ }
474
+
475
+ if (request.repeat !== null) {
476
+ if (scheduler.schedule === undefined) {
477
+ return jsonError(CODES.repeatUnavailable, 'Repeating messages need the Automation service in this profile.')
478
+ }
479
+ let rrule
480
+ try {
481
+ rrule = buildRrule({ repeat: request.repeat, instantMs: request.dueMs, timeZone: request.timeZone })
482
+ } catch (error) {
483
+ return jsonError(CODES.invalidRequest, describeError(error))
484
+ }
485
+ try {
486
+ const task = await scheduler.schedule.create({
487
+ title: summariseTitle(request.text) || 'Scheduled message',
488
+ prompt: request.text,
489
+ timing: { kind: 'rrule', rrule },
490
+ target: { kind: 'session', sessionId: request.sessionId },
491
+ })
492
+ return jsonOk({ kind: 'automation', taskId: String(task?.id ?? ''), rrule })
493
+ } catch (error) {
494
+ return jsonError(CODES.invalidRequest, describeError(error))
495
+ }
496
+ }
497
+
498
+ if (await scheduler.pendingCount() >= MAX_PENDING) {
499
+ return jsonError(CODES.tooManyPending, `At most ${MAX_PENDING} messages may wait to be sent.`)
500
+ }
501
+
502
+ let images = []
503
+ if (request.images.length > 0) {
504
+ try {
505
+ images = await scheduler.ctx.attachments.saveImages(request.images.map(image => ({
506
+ data: Buffer.from(image.data, 'base64'),
507
+ mediaType: image.mediaType,
508
+ ...(image.name === undefined ? {} : { name: image.name }),
509
+ })))
510
+ } catch (error) {
511
+ return jsonError('invalid-images', describeError(error))
512
+ }
513
+ }
514
+
515
+ const files = []
516
+ for (const receiptId of request.fileReceipts) {
517
+ const staged = scheduler.ctx.fileUploads.resolve(agent, receiptId)
518
+ if (staged === undefined || staged.type !== 'file') {
519
+ return jsonError(CODES.unknownReceipt, 'One of the attached files is no longer available; attach it again.')
520
+ }
521
+ files.push(staged.attachment)
522
+ }
523
+
524
+ try {
525
+ const record = await scheduler.createRecord(request, images, files)
526
+ await scheduler.rearm()
527
+ return jsonOk({ kind: 'once', message: publicRecord(record) })
528
+ } catch (error) {
529
+ return jsonError(CODES.internal, describeError(error))
530
+ }
531
+ }
532
+
533
+ /**
534
+ * Register the durable store, the six HTTP routes and the delivery timer.
535
+ * @param ctx - Host plugin context.
536
+ */
537
+ export function apply(ctx) {
538
+ const scheduler = new Scheduler(ctx)
539
+
540
+ ctx.inject(['storageDomain', 'sessionController', 'connection', 'attachments', 'fileUploads', 'timer'], (scope) => {
541
+ scheduler.ctx = scope
542
+
543
+ scope.effect(() => () => {
544
+ scheduler.stopping = true
545
+ scheduler.armSeq += 1
546
+ scheduler.disarm()
547
+ const opening = scheduler.opening
548
+ scheduler.opening = undefined
549
+ if (opening !== undefined) {
550
+ void opening.then(handle => handle.domain.close()).catch(() => undefined)
551
+ }
552
+ }, 'dsh-scheduled-message: stop the timer and close the durable domain')
553
+
554
+ scope.inject(['schedule'], (scheduleScope) => {
555
+ scheduler.schedule = scheduleScope.schedule
556
+ return () => {
557
+ scheduler.schedule = undefined
558
+ }
559
+ })
560
+
561
+ const routes = []
562
+
563
+ routes.push(scope.connection.fetch.register({
564
+ path: `${ROUTE_PREFIX}/create`,
565
+ methods: ['POST'],
566
+ requestBody: 'streaming',
567
+ fetch: async (request) => {
568
+ const body = await readJsonBody(request, MAX_CREATE_BODY_BYTES)
569
+ if (body.ok !== true) {
570
+ return jsonError(CODES.invalidRequest, body.code === 'body-too-large'
571
+ ? 'That message is too large to schedule.'
572
+ : 'The request body could not be read.')
573
+ }
574
+ return await handleCreate(scheduler, body.value)
575
+ },
576
+ }))
577
+
578
+ routes.push(scope.connection.fetch.register({
579
+ path: `${ROUTE_PREFIX}/list`,
580
+ methods: ['GET'],
581
+ requestBody: 'buffered',
582
+ fetch: async (request) => {
583
+ const url = new URL(request.url, 'http://localhost')
584
+ const sessionId = url.searchParams.get('sessionId')
585
+ if (sessionId === null || sessionId.length === 0) {
586
+ return jsonError(CODES.invalidRequest, 'A sessionId query parameter is required.')
587
+ }
588
+ const now = Date.now()
589
+ const items = (await scheduler.records())
590
+ .filter((record) => record.sessionId === sessionId)
591
+ .filter((record) => record.status === 'pending' || record.status === 'failed' || record.status === 'sending'
592
+ || (record.status === 'sent' && now - Date.parse(record.sentAt ?? record.updatedAt) <= SENT_WINDOW_MS))
593
+ .sort((left, right) => Date.parse(left.dueAt) - Date.parse(right.dueAt))
594
+ return jsonOk({ items: items.map(publicRecord), now })
595
+ },
596
+ }))
597
+
598
+ routes.push(scope.connection.fetch.register({
599
+ path: `${ROUTE_PREFIX}/cancel`,
600
+ methods: ['POST'],
601
+ requestBody: 'buffered',
602
+ fetch: async (request) => {
603
+ const body = await readJsonBody(request, 4096)
604
+ if (body.ok !== true || typeof body.value?.id !== 'string') {
605
+ return jsonError(CODES.invalidRequest, 'A record id is required.')
606
+ }
607
+ return await settleMutation(scheduler, body.value.id, (record) => (
608
+ record.status === 'pending' || record.status === 'failed' ? { status: 'cancelled' } : null
609
+ ))
610
+ },
611
+ }))
612
+
613
+ routes.push(scope.connection.fetch.register({
614
+ path: `${ROUTE_PREFIX}/reschedule`,
615
+ methods: ['POST'],
616
+ requestBody: 'buffered',
617
+ fetch: async (request) => {
618
+ const body = await readJsonBody(request, 4096)
619
+ const id = body.ok === true ? body.value?.id : undefined
620
+ const dueAt = body.ok === true ? body.value?.dueAt : undefined
621
+ if (typeof id !== 'string' || typeof dueAt !== 'string') {
622
+ return jsonError(CODES.invalidRequest, 'A record id and an absolute dueAt are required.')
623
+ }
624
+ const dueMs = Date.parse(dueAt)
625
+ if (!Number.isFinite(dueMs)) return jsonError(CODES.invalidRequest, 'dueAt is not a real instant.')
626
+ if (dueMs < Date.now() + 30_000) return jsonError(CODES.tooSoon, 'Choose a time at least 30 seconds from now.')
627
+ return await settleMutation(scheduler, id, (record) => (
628
+ record.status === 'pending' ? { dueAt: new Date(dueMs).toISOString() } : null
629
+ ))
630
+ },
631
+ }))
632
+
633
+ routes.push(scope.connection.fetch.register({
634
+ path: `${ROUTE_PREFIX}/send-now`,
635
+ methods: ['POST'],
636
+ requestBody: 'buffered',
637
+ fetch: async (request) => {
638
+ const body = await readJsonBody(request, 4096)
639
+ if (body.ok !== true || typeof body.value?.id !== 'string') {
640
+ return jsonError(CODES.invalidRequest, 'A record id is required.')
641
+ }
642
+ const id = body.value.id
643
+ return await scheduler.exclusive(async () => {
644
+ const table = await scheduler.table()
645
+ const record = table.get(id)
646
+ if (record === undefined) return jsonError(CODES.notFound, 'That scheduled message no longer exists.')
647
+ if (record.status !== 'pending' && record.status !== 'failed') {
648
+ return jsonError(CODES.notPending, 'That message is no longer waiting to be sent.')
649
+ }
650
+ const settled = await scheduler.deliver(id)
651
+ return jsonOk({ message: settled === undefined ? null : publicRecord(settled) })
652
+ })
653
+ },
654
+ }))
655
+
656
+ routes.push(scope.connection.fetch.register({
657
+ path: `${ROUTE_PREFIX}/retry`,
658
+ methods: ['POST'],
659
+ requestBody: 'buffered',
660
+ fetch: async (request) => {
661
+ const body = await readJsonBody(request, 4096)
662
+ if (body.ok !== true || typeof body.value?.id !== 'string') {
663
+ return jsonError(CODES.invalidRequest, 'A record id is required.')
664
+ }
665
+ const id = body.value.id
666
+ return await scheduler.exclusive(async () => {
667
+ const table = await scheduler.table()
668
+ const record = table.get(id)
669
+ if (record === undefined) return jsonError(CODES.notFound, 'That scheduled message no longer exists.')
670
+ if (record.status !== 'failed') return jsonError(CODES.notPending, 'Only a failed message can be retried.')
671
+ const nowIso = new Date().toISOString()
672
+ const { error: _error, ...rest } = record
673
+ const pending = messageSchema.parse({ ...rest, status: 'pending', dueAt: nowIso, updatedAt: nowIso })
674
+ await table.put(id, pending)
675
+ const settled = await scheduler.deliver(id)
676
+ await scheduler.rearm()
677
+ return jsonOk({ message: settled === undefined ? null : publicRecord(settled) })
678
+ })
679
+ },
680
+ }))
681
+
682
+ scope.effect(() => () => Promise.all(routes.map(remove => remove().catch(() => undefined))),
683
+ 'dsh-scheduled-message: remove the HTTP routes')
684
+
685
+ // Deliver whatever came due while DSH was closed, then arm the timer.
686
+ void scheduler.rearm()
687
+ void scheduler.pump()
688
+ })
689
+ }
690
+
691
+ export const name = NS
692
+
693
+ /** Services the one-shot path needs; `schedule` is injected separately. */
694
+ export const inject = ['storageDomain']