@volter/twin-upstash 0.1.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/LICENSE +202 -0
- package/README.md +202 -0
- package/api/src/fetch.ts +54 -0
- package/api/src/generated/surface.gen.json +1 -0
- package/api/src/generated/ui.gen.json +1 -0
- package/api/src/index.ts +19 -0
- package/api/src/key-gate.ts +30 -0
- package/api/src/manifest.ts +103 -0
- package/api/src/screens/developer-api.tsx +106 -0
- package/api/src/screens/qstash.tsx +99 -0
- package/api/src/screens/session.tsx +125 -0
- package/api/src/screens/teams.tsx +114 -0
- package/api/src/semantics/backups.ts +90 -0
- package/api/src/semantics/index.ts +191 -0
- package/api/src/semantics/shared.ts +42 -0
- package/api/src/semantics/teams.ts +108 -0
- package/api/src/semantics/time.ts +40 -0
- package/dist/api/src/fetch.d.ts +15 -0
- package/dist/api/src/fetch.js +44 -0
- package/dist/api/src/fetch.ts +54 -0
- package/dist/api/src/generated/surface.gen.json +1 -0
- package/dist/api/src/generated/ui.gen.json +1 -0
- package/dist/api/src/index.ts +19 -0
- package/dist/api/src/key-gate.d.ts +3 -0
- package/dist/api/src/key-gate.js +30 -0
- package/dist/api/src/key-gate.ts +30 -0
- package/dist/api/src/manifest.d.ts +2 -0
- package/dist/api/src/manifest.js +81 -0
- package/dist/api/src/manifest.ts +103 -0
- package/dist/api/src/screens/developer-api.d.ts +3 -0
- package/dist/api/src/screens/developer-api.js +101 -0
- package/dist/api/src/screens/developer-api.tsx +106 -0
- package/dist/api/src/screens/qstash.d.ts +3 -0
- package/dist/api/src/screens/qstash.js +92 -0
- package/dist/api/src/screens/qstash.tsx +99 -0
- package/dist/api/src/screens/session.d.ts +9 -0
- package/dist/api/src/screens/session.js +118 -0
- package/dist/api/src/screens/session.tsx +125 -0
- package/dist/api/src/screens/teams.d.ts +3 -0
- package/dist/api/src/screens/teams.js +99 -0
- package/dist/api/src/screens/teams.tsx +114 -0
- package/dist/api/src/semantics/backups.d.ts +7 -0
- package/dist/api/src/semantics/backups.js +75 -0
- package/dist/api/src/semantics/backups.ts +90 -0
- package/dist/api/src/semantics/index.d.ts +10 -0
- package/dist/api/src/semantics/index.js +191 -0
- package/dist/api/src/semantics/index.ts +191 -0
- package/dist/api/src/semantics/shared.d.ts +21 -0
- package/dist/api/src/semantics/shared.js +34 -0
- package/dist/api/src/semantics/shared.ts +42 -0
- package/dist/api/src/semantics/teams.d.ts +13 -0
- package/dist/api/src/semantics/teams.js +100 -0
- package/dist/api/src/semantics/teams.ts +108 -0
- package/dist/api/src/semantics/time.d.ts +2 -0
- package/dist/api/src/semantics/time.js +34 -0
- package/dist/api/src/semantics/time.ts +40 -0
- package/dist/qstash/src/doors.d.ts +6 -0
- package/dist/qstash/src/doors.js +33 -0
- package/dist/qstash/src/doors.ts +51 -0
- package/dist/qstash/src/egress.d.ts +7 -0
- package/dist/qstash/src/egress.js +66 -0
- package/dist/qstash/src/egress.ts +58 -0
- package/dist/qstash/src/fetch.d.ts +7 -0
- package/dist/qstash/src/fetch.js +48 -0
- package/dist/qstash/src/fetch.ts +46 -0
- package/dist/qstash/src/generated/surface.gen.json +1 -0
- package/dist/qstash/src/generated/ui.gen.json +1 -0
- package/dist/qstash/src/index.ts +35 -0
- package/dist/qstash/src/manifest.d.ts +10 -0
- package/dist/qstash/src/manifest.js +105 -0
- package/dist/qstash/src/manifest.ts +134 -0
- package/dist/qstash/src/semantics/account.d.ts +29 -0
- package/dist/qstash/src/semantics/account.js +91 -0
- package/dist/qstash/src/semantics/account.ts +98 -0
- package/dist/qstash/src/semantics/delivery.d.ts +17 -0
- package/dist/qstash/src/semantics/delivery.js +274 -0
- package/dist/qstash/src/semantics/delivery.ts +264 -0
- package/dist/qstash/src/semantics/dlq.d.ts +4 -0
- package/dist/qstash/src/semantics/dlq.js +51 -0
- package/dist/qstash/src/semantics/dlq.ts +61 -0
- package/dist/qstash/src/semantics/index.d.ts +2 -0
- package/dist/qstash/src/semantics/index.js +10 -0
- package/dist/qstash/src/semantics/index.ts +13 -0
- package/dist/qstash/src/semantics/keys.d.ts +2 -0
- package/dist/qstash/src/semantics/keys.js +9 -0
- package/dist/qstash/src/semantics/keys.ts +14 -0
- package/dist/qstash/src/semantics/messages.d.ts +74 -0
- package/dist/qstash/src/semantics/messages.js +233 -0
- package/dist/qstash/src/semantics/messages.ts +249 -0
- package/dist/qstash/src/semantics/queues.d.ts +2 -0
- package/dist/qstash/src/semantics/queues.js +60 -0
- package/dist/qstash/src/semantics/queues.ts +66 -0
- package/dist/qstash/src/semantics/schedules.d.ts +19 -0
- package/dist/qstash/src/semantics/schedules.js +125 -0
- package/dist/qstash/src/semantics/schedules.ts +132 -0
- package/dist/qstash/src/semantics/shared.d.ts +45 -0
- package/dist/qstash/src/semantics/shared.js +115 -0
- package/dist/qstash/src/semantics/shared.ts +121 -0
- package/dist/qstash/src/semantics/urlgroups.d.ts +2 -0
- package/dist/qstash/src/semantics/urlgroups.js +58 -0
- package/dist/qstash/src/semantics/urlgroups.ts +69 -0
- package/dist/qstash/src/semantics/workflows.d.ts +44 -0
- package/dist/qstash/src/semantics/workflows.js +379 -0
- package/dist/qstash/src/semantics/workflows.ts +401 -0
- package/dist/qstash/src/signing.d.ts +4 -0
- package/dist/qstash/src/signing.js +16 -0
- package/dist/qstash/src/signing.ts +19 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +35 -0
- package/dist/src/generated/surface.gen.json +1 -0
- package/dist/src/index.d.ts +18 -0
- package/dist/src/index.js +124 -0
- package/dist/src/manifest.d.ts +14 -0
- package/dist/src/manifest.js +8 -0
- package/dist/src/upstash-budget.d.ts +85 -0
- package/dist/src/upstash-budget.js +440 -0
- package/dist/src/upstash-capabilities.d.ts +4 -0
- package/dist/src/upstash-capabilities.js +1286 -0
- package/dist/src/upstash-conformance.d.ts +7 -0
- package/dist/src/upstash-conformance.js +119 -0
- package/dist/src/upstash-connector.d.ts +115 -0
- package/dist/src/upstash-connector.js +309 -0
- package/dist/src/upstash-lua.d.ts +140 -0
- package/dist/src/upstash-lua.js +1229 -0
- package/dist/src/upstash-server.d.ts +29 -0
- package/dist/src/upstash-server.js +81 -0
- package/dist/src/upstash-store.d.ts +114 -0
- package/dist/src/upstash-store.js +1663 -0
- package/dist/src/upstash-twin.d.ts +73 -0
- package/dist/src/upstash-twin.js +437 -0
- package/package.json +59 -0
- package/qstash/src/doors.ts +51 -0
- package/qstash/src/egress.ts +58 -0
- package/qstash/src/fetch.ts +46 -0
- package/qstash/src/generated/surface.gen.json +1 -0
- package/qstash/src/generated/ui.gen.json +1 -0
- package/qstash/src/index.ts +35 -0
- package/qstash/src/manifest.ts +134 -0
- package/qstash/src/semantics/account.ts +98 -0
- package/qstash/src/semantics/delivery.ts +264 -0
- package/qstash/src/semantics/dlq.ts +61 -0
- package/qstash/src/semantics/index.ts +13 -0
- package/qstash/src/semantics/keys.ts +14 -0
- package/qstash/src/semantics/messages.ts +249 -0
- package/qstash/src/semantics/queues.ts +66 -0
- package/qstash/src/semantics/schedules.ts +132 -0
- package/qstash/src/semantics/shared.ts +121 -0
- package/qstash/src/semantics/urlgroups.ts +69 -0
- package/qstash/src/semantics/workflows.ts +401 -0
- package/qstash/src/signing.ts +19 -0
- package/src/cli.ts +36 -0
- package/src/generated/surface.gen.json +1 -0
- package/src/index.ts +203 -0
- package/src/manifest.ts +26 -0
- package/src/upstash-budget.ts +486 -0
- package/src/upstash-capabilities.ts +1418 -0
- package/src/upstash-conformance.ts +131 -0
- package/src/upstash-connector.ts +340 -0
- package/src/upstash-lua.ts +1120 -0
- package/src/upstash-server.ts +103 -0
- package/src/upstash-store.ts +1437 -0
- package/src/upstash-twin.ts +465 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// The QStash lane's catch-up (docs/contributing/architecture.md, "Moves no API call makes": time): before the lane answers
|
|
2
|
+
// any request on a writable World, everything the World clock has made due happens, in order, each at the moment it fell
|
|
3
|
+
// due: a schedule's cron fires and publishes its message, a wait times out, a message is delivered (or retried, or
|
|
4
|
+
// failed into the DLQ, its callbacks published), a cancelled message is marked CANCELLED, and a DLQ entry past its
|
|
5
|
+
// retention is removed. There is no scheduler: the World clock is read, never watched.
|
|
6
|
+
//
|
|
7
|
+
// Delivery (https://upstash.com/docs/qstash/howto/debug-logs, /features/retry, /features/callbacks, /features/dlq):
|
|
8
|
+
// - A message is delivered when due: its publish instant, plus `Upstash-Delay`, or its `Upstash-Not-Before`. Messages
|
|
9
|
+
// of one queue go one at a time in order ("The next message will wait for retries of the current one"), and a
|
|
10
|
+
// workflow run's messages go in the order the run made them.
|
|
11
|
+
// - The destination receives the body and the publish's `Content-Type`, every `Upstash-Forward-*` header with its prefix
|
|
12
|
+
// stripped, `Upstash-Message-Id`, `Upstash-Retried` ("0" first, then how many retries so far) and an
|
|
13
|
+
// `Upstash-Signature` signed with the account's current key (signing.ts); a workflow's delivery carries the run's steps
|
|
14
|
+
// and headers (workflows.ts).
|
|
15
|
+
// - "If your API does not respond with a success status code (2XX), we retry the request": retry n waits
|
|
16
|
+
// `min(86400, e ** (2.5*n))` seconds, or what the destination's `Retry-After` asks (seconds, a duration or a date, capped
|
|
17
|
+
// at a day), or the message's `Upstash-Retry-Delay` expression (milliseconds, `retried` from 0). A 489 with
|
|
18
|
+
// `Upstash-NonRetryable-Error: true` is not retried.
|
|
19
|
+
// - "If all retries are exhausted, the task has FAILED and the message will be moved to the DLQ", and its
|
|
20
|
+
// `Upstash-Failure-Callback` is called; its `Upstash-Callback` is called with the answer once delivered or failed. A
|
|
21
|
+
// callback is a message of its own, delivered, signed and retried the same way, and never called back itself.
|
|
22
|
+
// - DLQ retention: "Messages are deleted when their retention period expires" (/features/dlq; the pricing page's Pay as
|
|
23
|
+
// You Go column: DLQ 7 days; the Workflow DLQ "Pay-as-you-go: 1 week", https://upstash.com/docs/workflow/features/dlq).
|
|
24
|
+
//
|
|
25
|
+
// What a destination answers is the World's: a destination the World has stated an answer for (the destinations door,
|
|
26
|
+
// ../doors.ts) answers that, and any other is asked over HTTP where the World lets the request out (egress.ts); a
|
|
27
|
+
// sealed World refuses it, which is a failed attempt. Where the documentation stops and the lane decides: the account's
|
|
28
|
+
// plan is Pay as You Go; a callback is called once when the message is delivered or fails, not after every retry.
|
|
29
|
+
import type { SemanticsContext } from '@volter/world-core';
|
|
30
|
+
import { accountOf } from './account.ts';
|
|
31
|
+
import { acceptToUrlGroup, ensureQueue, storeMessage } from './messages.ts';
|
|
32
|
+
import { nextFire } from './schedules.ts';
|
|
33
|
+
import { failRun, failureCallSettled, rawSteps, timeOut, workflowHeaders } from './workflows.ts';
|
|
34
|
+
import { b64, iso, multiValue, nowMs, type Row } from './shared.ts';
|
|
35
|
+
import { post, type Outcome } from '../egress.ts';
|
|
36
|
+
import { sign } from '../signing.ts';
|
|
37
|
+
|
|
38
|
+
/** The World's word on what a destination answers, stored by `POST /_twin/destinations` (../doors.ts). */
|
|
39
|
+
export const DESTINATION = '_qstash_destination';
|
|
40
|
+
/** What a destination received, read at `GET /_twin/deliveries?to=` (../doors.ts). */
|
|
41
|
+
export const DELIVERY = '_qstash_delivery';
|
|
42
|
+
|
|
43
|
+
const DAY_MS = 86_400_000;
|
|
44
|
+
const DLQ_RETENTION_MS = 7 * DAY_MS;
|
|
45
|
+
const PENDING = new Set(['CREATED', 'ACTIVE', 'RETRY']);
|
|
46
|
+
|
|
47
|
+
/** The answer the World has stated for a delivery: of the statements whose URL prefixes the delivery's and whose `when`
|
|
48
|
+
* headers (if any) it carries, one that names headers before one that does not, then the longest prefix. */
|
|
49
|
+
function stated(ctx: SemanticsContext, url: string, sent: Record<string, string>): Outcome | undefined {
|
|
50
|
+
const holds = (d: Row): boolean => url.startsWith(String(d.url)) && Object.entries((d.when ?? {}) as Record<string, string>).every(([k, v]) => sent[k.toLowerCase()] === v);
|
|
51
|
+
const hit = ctx.rowsRaw(DESTINATION).filter(holds)
|
|
52
|
+
.sort((a, b) => Number(Object.keys(b.when ?? {}).length > 0) - Number(Object.keys(a.when ?? {}).length > 0) || String(b.url).length - String(a.url).length)[0];
|
|
53
|
+
return hit ? { status: Number(hit.status), body: String(hit.body ?? ''), headers: (hit.headers ?? {}) as Record<string, string> } : undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** QStash's backoff for retry n, in milliseconds. */
|
|
57
|
+
const backoffMs = (n: number): number => Math.round(Math.min(86_400, Math.exp(2.5 * n)) * 1000);
|
|
58
|
+
|
|
59
|
+
/** A `Retry-After` answer in milliseconds: seconds, a duration (`6m5s`) or a date, capped at a day. */
|
|
60
|
+
function retryAfterMs(headers: Record<string, string>, at: number): number | undefined {
|
|
61
|
+
const raw = headers['retry-after']?.trim();
|
|
62
|
+
return raw ? retryAfterValue(raw, at) : undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A `Retry-After` value in milliseconds (https://upstash.com/docs/qstash/features/retry, Retry-After Headers). */
|
|
66
|
+
function retryAfterValue(raw: string, at: number): number | undefined {
|
|
67
|
+
const d = /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/.exec(raw);
|
|
68
|
+
const sec = /^\d+$/.test(raw) ? Number(raw) : d && raw !== '' ? Number(d[1] ?? 0) * 3600 + Number(d[2] ?? 0) * 60 + Number(d[3] ?? 0) : Number.isNaN(Date.parse(raw)) ? undefined : Math.max(0, (Date.parse(raw) - at) / 1000);
|
|
69
|
+
return sec === undefined ? undefined : Math.round(Math.min(86_400, sec) * 1000);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** An `Upstash-Retry-Delay` expression's value in milliseconds: numbers, `retried`, `+ - * /`, parentheses and `pow sqrt
|
|
73
|
+
* abs exp floor ceil round min max` (the retry page's Custom Retry Delay); anything else is undefined. */
|
|
74
|
+
export function retryDelayMs(expr: string, retried: number): number | undefined {
|
|
75
|
+
const src = expr.replace(/\s+/g, '');
|
|
76
|
+
let i = 0;
|
|
77
|
+
const fns: Record<string, (...a: number[]) => number> = { pow: Math.pow, sqrt: Math.sqrt, abs: Math.abs, exp: Math.exp, floor: Math.floor, ceil: Math.ceil, round: Math.round, min: Math.min, max: Math.max };
|
|
78
|
+
const fail = (): never => { throw new Error('retryDelay'); };
|
|
79
|
+
const primary = (): number => {
|
|
80
|
+
if (src[i] === '(') { i += 1; const v = sum(); if (src[i] !== ')') fail(); i += 1; return v; }
|
|
81
|
+
const num = /^\d+(\.\d+)?/.exec(src.slice(i));
|
|
82
|
+
if (num) { i += num[0].length; return Number(num[0]); }
|
|
83
|
+
const id = /^[a-z]+/.exec(src.slice(i));
|
|
84
|
+
if (!id) return fail();
|
|
85
|
+
i += id[0].length;
|
|
86
|
+
if (id[0] === 'retried') return retried;
|
|
87
|
+
const fn = fns[id[0]];
|
|
88
|
+
if (!fn || src[i] !== '(') return fail();
|
|
89
|
+
i += 1;
|
|
90
|
+
const args = [sum()];
|
|
91
|
+
while (src[i] === ',') { i += 1; args.push(sum()); }
|
|
92
|
+
if (src[i] !== ')') fail();
|
|
93
|
+
i += 1;
|
|
94
|
+
return fn(...args);
|
|
95
|
+
};
|
|
96
|
+
const product = (): number => { let v = primary(); while (src[i] === '*' || src[i] === '/') { const op = src[i]; i += 1; const r = primary(); v = op === '*' ? v * r : v / r; } return v; };
|
|
97
|
+
const sum = (): number => { let v = product(); while (src[i] === '+' || src[i] === '-') { const op = src[i]; i += 1; const r = product(); v = op === '+' ? v + r : v - r; } return v; };
|
|
98
|
+
try { const v = sum(); return i === src.length && Number.isFinite(v) && v >= 0 ? v : undefined; } catch { return undefined; }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A queue delivers in order, as many at a time as its parallelism ("The number of parallel consumers consuming from the
|
|
102
|
+
* queue", the spec's Queue), and none while it is paused ("Pausing a queue stops the delivery of enqueued messages", the
|
|
103
|
+
* spec's pause). A message whose queue was deleted goes as a queue of one would. */
|
|
104
|
+
function queueLets(ctx: SemanticsContext, m: Row, all: Row[]): boolean {
|
|
105
|
+
const q = ctx.rowsRaw('Queue').find((r) => r.name === m.queueName);
|
|
106
|
+
if (q?.paused === true) return false;
|
|
107
|
+
const ahead = all.filter((o) => o.queueName === m.queueName && PENDING.has(String(o.state)) && Number(o._ordinal) < Number(m._ordinal)).length;
|
|
108
|
+
return ahead < Math.max(1, Number(q?.parallelism ?? 1));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Whether a pending message may go now: its queue lets it (queueLets), and it is the first undelivered of its run. */
|
|
112
|
+
function eligible(ctx: SemanticsContext, m: Row, all: Row[]): boolean {
|
|
113
|
+
if (m.state !== 'CREATED' && m.state !== 'RETRY') return false;
|
|
114
|
+
if (typeof m.queueName === 'string' && !queueLets(ctx, m, all)) return false;
|
|
115
|
+
if (typeof m._run === 'string') {
|
|
116
|
+
const run = ctx.rowsRaw('WorkflowRun').find((r) => r.workflowRunId === m._run);
|
|
117
|
+
if (!run || run.workflowState !== 'RUN_STARTED') return false;
|
|
118
|
+
if (all.some((o) => o._run === m._run && PENDING.has(String(o.state)) && Number(o._seq) < Number(m._seq))) return false;
|
|
119
|
+
}
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** What a catch-up did: the deliveries it made, by outcome (the drain door answers it). */
|
|
124
|
+
export type CatchUpReport = { delivered: Row[]; retried: Row[]; failed: Row[] };
|
|
125
|
+
|
|
126
|
+
type Due = { when: number; order: number; act: (at: SemanticsContext, report: CatchUpReport) => Promise<void> };
|
|
127
|
+
|
|
128
|
+
function nextDue(ctx: SemanticsContext, until: number): Due | undefined {
|
|
129
|
+
const due: Due[] = [];
|
|
130
|
+
for (const s of ctx.rowsRaw('Schedule')) {
|
|
131
|
+
if (typeof s.nextScheduleTime === 'number' && s.nextScheduleTime <= until) due.push({ when: s.nextScheduleTime, order: 0, act: (at) => fire(at, s) });
|
|
132
|
+
}
|
|
133
|
+
for (const w of ctx.rowsRaw('Waiter')) {
|
|
134
|
+
const when = Number(w.deadline) * 1000;
|
|
135
|
+
if (when <= until) due.push({ when, order: 1, act: (at) => timeOut(at, w) });
|
|
136
|
+
}
|
|
137
|
+
const all = ctx.rowsRaw('Message');
|
|
138
|
+
for (const m of all) {
|
|
139
|
+
const when = Number(m._due);
|
|
140
|
+
if (when > until) continue;
|
|
141
|
+
if (m.state === 'CANCEL_REQUESTED') due.push({ when, order: 2 + Number(m._ordinal) / 1e9, act: (at) => cancelled(at, m) });
|
|
142
|
+
else if (eligible(ctx, m, all)) due.push({ when, order: 3 + Number(m._ordinal) / 1e9, act: (at, report) => attempt(at, m, report) });
|
|
143
|
+
}
|
|
144
|
+
for (const resource of ['DLQMessage', 'WorkflowDLQMessage']) {
|
|
145
|
+
for (const e of ctx.rowsRaw(resource)) {
|
|
146
|
+
const when = Number(e._filed_at ?? e.createdAt) + DLQ_RETENTION_MS;
|
|
147
|
+
if (when <= until) due.push({ when, order: 4, act: (at) => at.write(resource, String(e.id), { deleted: true }, 'dlq.expired').then(() => undefined) });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return due.sort((a, b) => a.when - b.when || a.order - b.order)[0];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Run everything due by the World's instant, in order, each at its own moment. */
|
|
154
|
+
export async function catchUp(ctx: SemanticsContext): Promise<CatchUpReport> {
|
|
155
|
+
const until = nowMs(ctx);
|
|
156
|
+
const report: CatchUpReport = { delivered: [], retried: [], failed: [] };
|
|
157
|
+
// a message held behind another (the one before it in its queue or its run) goes once that one has left, so the World
|
|
158
|
+
// clock never runs back: each move happens at its own due time or at the last move's, whichever is later
|
|
159
|
+
let last = 0;
|
|
160
|
+
for (let n = 0; n < 5000; n += 1) {
|
|
161
|
+
const next = nextDue(ctx, until);
|
|
162
|
+
if (!next) break;
|
|
163
|
+
last = Math.max(last, next.when);
|
|
164
|
+
await next.act(await ctx.at(iso(last)), report);
|
|
165
|
+
}
|
|
166
|
+
return report;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** A schedule's cron has fired: its message is published at that moment (a paused schedule's is not), and it is next
|
|
170
|
+
* due at the cron's following minute. */
|
|
171
|
+
async function fire(ctx: SemanticsContext, s: Row): Promise<void> {
|
|
172
|
+
const when = Number(s.nextScheduleTime);
|
|
173
|
+
if (s.isPaused !== true) {
|
|
174
|
+
const { 'upstash-cron': _cron, 'upstash-schedule-id': _id, 'upstash-queue-name': _q, ...headers } = (s._published ?? {}) as Record<string, string>;
|
|
175
|
+
const queue = typeof s._queue === 'string' ? s._queue : undefined;
|
|
176
|
+
if (queue) await ensureQueue(ctx, queue);
|
|
177
|
+
const destination = String(s.destination);
|
|
178
|
+
if (/^https?:\/\//.test(destination)) await storeMessage(ctx, { url: destination, headers, body: String(s.body ?? ''), owner: String(s._owner), scheduleId: String(s.scheduleId), queue });
|
|
179
|
+
else await acceptToUrlGroup(ctx, { destination, headers, body: String(s.body ?? ''), queue }, String(s._owner));
|
|
180
|
+
}
|
|
181
|
+
const next = nextFire(String(s.cron), when);
|
|
182
|
+
await ctx.write('Schedule', String(s.id), { lastScheduleTime: when, ...(next !== undefined ? { nextScheduleTime: next } : {}) }, 'schedule.fire');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** A cancelled message reaches its next delivery time: it is CANCELLED, never delivered. */
|
|
186
|
+
async function cancelled(ctx: SemanticsContext, m: Row): Promise<void> {
|
|
187
|
+
const refused = ctx.legal('Message', 'state', 'delete_v2_messages_messageid', m.state, 'CANCELLED', String(m.id), 'time');
|
|
188
|
+
if (refused) throw new Error(refused.message);
|
|
189
|
+
await ctx.write('Message', String(m.id), { state: 'CANCELLED' }, 'message.cancelled');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Move a message, as its machine allows. */
|
|
193
|
+
async function move(ctx: SemanticsContext, m: Row, to: string, actor: 'time' | 'vendor', fields: Row): Promise<void> {
|
|
194
|
+
const refused = ctx.legal('Message', 'state', 'delete_v2_messages_messageid', m.state, to, String(m.id), actor);
|
|
195
|
+
if (refused) throw new Error(refused.message);
|
|
196
|
+
await ctx.write('Message', String(m.id), { ...fields, state: to }, `message.${to.toLowerCase()}`);
|
|
197
|
+
m.state = to;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** One delivery attempt of a message, at the moment it is due. */
|
|
201
|
+
async function attempt(ctx: SemanticsContext, m: Row, report: CatchUpReport): Promise<void> {
|
|
202
|
+
const when = nowMs(ctx);
|
|
203
|
+
const tried = Number(m._attempts ?? 0);
|
|
204
|
+
await move(ctx, m, 'ACTIVE', 'time', { _attempts: tried });
|
|
205
|
+
const published = (m._published ?? {}) as Record<string, string>;
|
|
206
|
+
const step = typeof m._run === 'string';
|
|
207
|
+
const body = step ? JSON.stringify(rawSteps(ctx, m._run as string, Number(m._seq))) : String(m.body ?? '');
|
|
208
|
+
const base = published['upstash-workflow-runid'] !== undefined ? workflowHeaders(published) : {
|
|
209
|
+
...(typeof m._content_type === 'string' ? { 'content-type': m._content_type } : {}),
|
|
210
|
+
...Object.fromEntries(Object.entries(published).filter(([k]) => k.startsWith('upstash-forward-')).map(([k, v]) => [k.slice('upstash-forward-'.length), v])),
|
|
211
|
+
};
|
|
212
|
+
const url = String(m.url);
|
|
213
|
+
const key = accountOf(ctx, String(m._owner ?? 'world')).current;
|
|
214
|
+
const headers: Record<string, string> = {
|
|
215
|
+
...base, 'upstash-message-id': String(m.messageId), 'upstash-retried': String(tried),
|
|
216
|
+
'upstash-signature': sign(body, url, Math.floor(when / 1000), `jwt_${String(m.messageId)}_${tried}`, key),
|
|
217
|
+
};
|
|
218
|
+
const method = String(m.method ?? 'POST');
|
|
219
|
+
const outcome = stated(ctx, url, headers) ?? await post(url, method, headers, body);
|
|
220
|
+
await ctx.record(DELIVERY, { to: url, message_id: m.messageId, retried: tried, at: ctx.occurredAt, n: ctx.rowsRaw(DELIVERY).length, method, headers, body, status: outcome.status }, `delivery:${String(m.messageId)}:${tried}`);
|
|
221
|
+
const n = tried + 1;
|
|
222
|
+
const response = { _response_status: outcome.status, _response_body: outcome.body, _response_headers: outcome.headers };
|
|
223
|
+
if (outcome.status >= 200 && outcome.status < 300) {
|
|
224
|
+
await move(ctx, m, 'DELIVERED', 'vendor', { _attempts: n, ...response });
|
|
225
|
+
report.delivered.push({ messageId: m.messageId, url, status: outcome.status });
|
|
226
|
+
if (typeof m._callback_of === 'string') await failureCallSettled(ctx, m._callback_of, true);
|
|
227
|
+
else if (typeof m.callback === 'string') await callback(ctx, m, String(m.callback), outcome, n, undefined);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
const nonRetryable = outcome.status === 489 && outcome.headers['upstash-nonretryable-error'] === 'true';
|
|
231
|
+
if (n <= Number(m.maxRetries ?? 3) && !nonRetryable) {
|
|
232
|
+
const run = step ? ctx.rowsRaw('WorkflowRun').find((r) => r.workflowRunId === m._run) : undefined;
|
|
233
|
+
const expr = typeof m._retry_delay === 'string' ? m._retry_delay : typeof run?._retry_delay === 'string' ? run._retry_delay : undefined;
|
|
234
|
+
const wait = retryAfterMs(outcome.headers, when) ?? (expr !== undefined ? retryDelayMs(expr, tried) : undefined) ?? backoffMs(n);
|
|
235
|
+
await move(ctx, m, 'RETRY', 'vendor', { _attempts: n, _due: when + wait, ...response });
|
|
236
|
+
report.retried.push({ messageId: m.messageId, url, status: outcome.status, retried: n, nextDeliveryTime: when + wait });
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
await move(ctx, m, 'FAILED', 'vendor', { _attempts: n, ...response });
|
|
240
|
+
report.failed.push({ messageId: m.messageId, url, status: outcome.status });
|
|
241
|
+
if (typeof m._callback_of === 'string') { await failureCallSettled(ctx, m._callback_of, false); return; }
|
|
242
|
+
if (step) { await failRun(ctx, { ...m, _attempts: n }, outcome); return; }
|
|
243
|
+
const dlqId = `${when}-${ctx.rowsRaw('DLQMessage', { withDeleted: true }).length}`;
|
|
244
|
+
const own = ctx.own(m);
|
|
245
|
+
await ctx.write('DLQMessage', dlqId, {
|
|
246
|
+
...Object.fromEntries(Object.entries(own).filter(([k]) => !k.startsWith('_') && k !== 'state')),
|
|
247
|
+
dlqId, responseStatus: outcome.status, responseHeader: multiValue(outcome.headers), responseBody: outcome.body,
|
|
248
|
+
_filed_at: when, _published: published, _message_id: m.messageId, _owner: m._owner ?? 'world', ...(m._redact ? { _redact: m._redact } : {}),
|
|
249
|
+
}, 'dlq.create');
|
|
250
|
+
if (typeof m.failureCallback === 'string') await callback(ctx, m, String(m.failureCallback), outcome, n, dlqId);
|
|
251
|
+
if (typeof m.callback === 'string') await callback(ctx, m, String(m.callback), outcome, n, undefined);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** A callback: "QStash calls your callback URL with the response"; its body is the callbacks page's (`status`, `header`,
|
|
255
|
+
* base64 `body`, `retried`, `maxRetries`, `sourceMessageId`, `url`, `method`, `sourceHeader`, base64 `sourceBody`,
|
|
256
|
+
* `notBefore`, `createdAt`, `callerIP`), and a failure callback's adds the `dlqId`. It is a message of its own. */
|
|
257
|
+
async function callback(ctx: SemanticsContext, m: Row, url: string, o: Outcome, attempts: number, dlqId: string | undefined): Promise<void> {
|
|
258
|
+
const body = JSON.stringify({
|
|
259
|
+
status: o.status, header: multiValue(o.headers), body: b64(o.body), retried: attempts - 1, maxRetries: Number(m.maxRetries ?? 3),
|
|
260
|
+
...(dlqId ? { dlqId } : {}), sourceMessageId: m.messageId, url: m.url, method: m.method ?? 'POST', sourceHeader: m.header ?? {},
|
|
261
|
+
sourceBody: b64(String(m.body ?? '')), notBefore: m.notBefore, createdAt: m.createdAt, callerIP: '127.0.0.1',
|
|
262
|
+
});
|
|
263
|
+
await storeMessage(ctx, { url, headers: { 'content-type': 'application/json' }, body, owner: String(m._owner ?? 'world'), callbackOf: String(m.messageId) });
|
|
264
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// QStash's dead letter queue: "QStash automatically retries messages that fail due to a temporary issue but eventually
|
|
2
|
+
// stops and moves the message to a dead letter queue to be handled manually." "Retry - Republish the message and remove
|
|
3
|
+
// it from the dead letter queue. Republished messages are just like any other message and will be retried automatically if
|
|
4
|
+
// they fail." (https://upstash.com/docs/qstash/features/dlq). A message is filed here by the catch-up when its retries are
|
|
5
|
+
// spent (delivery.ts); an entry leaves when it is retried, or when its retention ends.
|
|
6
|
+
import type { Semantics, SemanticsContext } from '@volter/world-core';
|
|
7
|
+
import { ensureQueue, ownerFor, storeMessage } from './messages.ts';
|
|
8
|
+
import { idsOf, workflowDlq } from './workflows.ts';
|
|
9
|
+
import { matchesFilters, MESSAGE_FIELDS, type Row } from './shared.ts';
|
|
10
|
+
import { redacted } from './messages.ts';
|
|
11
|
+
|
|
12
|
+
const DLQ_FIELDS = [...MESSAGE_FIELDS, 'dlqId', 'responseStatus', 'responseHeader', 'responseBody', 'responseBodyBase64'];
|
|
13
|
+
|
|
14
|
+
export function dlqView(ctx: SemanticsContext, e: Row): Row {
|
|
15
|
+
const own = ctx.own(e);
|
|
16
|
+
return redacted(Object.fromEntries(DLQ_FIELDS.filter((k) => own[k] !== undefined && own[k] !== null).map((k) => [k, own[k]])), e);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** QStash's DLQ fields its list and bulk actions filter on, by query parameter (the spec's list and retry parameters). */
|
|
20
|
+
const FILTERS = { url: 'url', topicName: 'topicName', scheduleId: 'scheduleId', queueName: 'queueName', responseStatus: 'responseStatus', label: 'labels', flowControlKey: 'flowControlKey', messageId: 'messageId', callerIp: 'callerIP' };
|
|
21
|
+
|
|
22
|
+
/** The account's entries a request names: by `dlqIds`, else by its filters (all of them when it names none), oldest first. */
|
|
23
|
+
function selected(ctx: SemanticsContext): Row[] {
|
|
24
|
+
const owner = ownerFor(ctx);
|
|
25
|
+
const mine = ctx.rowsRaw('DLQMessage').filter((e) => e._owner === owner);
|
|
26
|
+
const ids = idsOf(ctx, 'dlqIds');
|
|
27
|
+
return (ids.length ? mine.filter((e) => ids.includes(String(e.dlqId))) : mine.filter((e) => matchesFilters(ctx, e, FILTERS, '_filed_at')))
|
|
28
|
+
.sort((a, b) => Number(a._filed_at) - Number(b._filed_at));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** GET /v2/dlq: QStash's failed messages, the latest to fail first, `{messages}` ("If no cursor is returned, you have reached the
|
|
32
|
+
* end"); with `source=workflow`, the Workflow DLQ (the spec patch, from @upstash/workflow's `client.dlq.list`). */
|
|
33
|
+
const list: Semantics = async (ctx) => {
|
|
34
|
+
if (new URL(ctx.call.request.url).searchParams.get('source') === 'workflow') return workflowDlq(ctx);
|
|
35
|
+
const count = Number(new URL(ctx.call.request.url).searchParams.get('count') ?? 0);
|
|
36
|
+
const all = selected(ctx).reverse();
|
|
37
|
+
return ctx.reply({ messages: (count > 0 ? all.slice(0, count) : all).map((e) => dlqView(ctx, e)) });
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** POST /v2/dlq/retry: each entry the request names (by `dlqIds`, by its filters, or all of them) is republished as a new
|
|
41
|
+
* message (its destination, body and headers, in its queue) and leaves the DLQ; answered 201 `{responses: [{messageId}]}`.
|
|
42
|
+
* An id the DLQ does not hold answers 404 (the spec's 404). */
|
|
43
|
+
const retry: Semantics = async (ctx) => {
|
|
44
|
+
const ids = idsOf(ctx, 'dlqIds');
|
|
45
|
+
const entries = selected(ctx);
|
|
46
|
+
const missing = ids.find((id) => !entries.some((e) => e.dlqId === id));
|
|
47
|
+
if (missing) return ctx.refuse({ status: 404, message: `DLQ message ${missing} not found` });
|
|
48
|
+
const responses: Row[] = [];
|
|
49
|
+
for (const e of entries as Row[]) {
|
|
50
|
+
const published = (e._published ?? {}) as Record<string, string>;
|
|
51
|
+
const queue = typeof e.queueName === 'string' ? e.queueName : undefined;
|
|
52
|
+
if (queue) await ensureQueue(ctx, queue);
|
|
53
|
+
const { 'upstash-delay': _delay, 'upstash-not-before': _notBefore, ...headers } = published;
|
|
54
|
+
const messageId = await storeMessage(ctx, { url: String(e.url), headers, body: String(e.body ?? ''), owner: ownerFor(ctx), queue });
|
|
55
|
+
await ctx.write('DLQMessage', String(e.id), { deleted: true }, 'dlq.retried');
|
|
56
|
+
responses.push({ messageId });
|
|
57
|
+
}
|
|
58
|
+
return ctx.reply({ responses }, 201);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const dlqSemantics: Record<string, Semantics> = { get_v2_dlq: list, post_v2_dlq_retry: retry };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// The QStash lane's handlers, by operationId (the IR's `method_path` slugs: neither document names its operations).
|
|
2
|
+
import type { Semantics } from '@volter/world-core';
|
|
3
|
+
import { dlqSemantics } from './dlq.ts';
|
|
4
|
+
import { keySemantics } from './keys.ts';
|
|
5
|
+
import { messageSemantics } from './messages.ts';
|
|
6
|
+
import { queueSemantics } from './queues.ts';
|
|
7
|
+
import { scheduleSemantics } from './schedules.ts';
|
|
8
|
+
import { workflowSemantics } from './workflows.ts';
|
|
9
|
+
import { urlGroupSemantics } from './urlgroups.ts';
|
|
10
|
+
|
|
11
|
+
export const qstashSemantics: Record<string, Semantics> = {
|
|
12
|
+
...messageSemantics, ...queueSemantics, ...scheduleSemantics, ...dlqSemantics, ...keySemantics, ...workflowSemantics, ...urlGroupSemantics,
|
|
13
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// The account's signing keys (account.ts): GET /v2/keys reads them, POST /v2/keys/rotate rolls them. "When you roll your
|
|
2
|
+
// keys, the current key will be replaced with the next key and a new next key will be generated."
|
|
3
|
+
// (https://upstash.com/docs/qstash/howto/roll-signing-keys); both answer `SigningKeys` `{current, next}`.
|
|
4
|
+
import type { Semantics } from '@volter/world-core';
|
|
5
|
+
import { accountOf, rollKeys } from './account.ts';
|
|
6
|
+
import { ownerFor } from './messages.ts';
|
|
7
|
+
|
|
8
|
+
export const keySemantics: Record<string, Semantics> = {
|
|
9
|
+
get_v2_keys: async (ctx) => {
|
|
10
|
+
const a = accountOf(ctx, ownerFor(ctx));
|
|
11
|
+
return ctx.reply({ current: a.current, next: a.next });
|
|
12
|
+
},
|
|
13
|
+
post_v2_keys_rotate: async (ctx) => ctx.reply(await rollKeys(ctx, ownerFor(ctx))),
|
|
14
|
+
};
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// Publishing: `POST /v2/publish/{destination}`, `POST /v2/enqueue/{queueName}/{destination}` and `POST /v2/batch`, and a
|
|
2
|
+
// message's read and cancel. A published message is accepted at once and delivered by the catch-up when it falls due
|
|
3
|
+
// (delivery.ts). A message that carries `Upstash-Workflow-RunId` is a workflow's (workflows.ts): the first of a run starts
|
|
4
|
+
// it, and each after is a step the run's route sends back. The headers a publish reads are the qstash document's
|
|
5
|
+
// (`/v2/publish/{destination}`'s header parameters); the lane reads the ones a customer of its life and Dub send.
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
7
|
+
import type { Semantics, SemanticsContext } from '@volter/world-core';
|
|
8
|
+
import { gate } from './account.ts';
|
|
9
|
+
import { acceptWorkflowMessage, isWorkflowMessage } from './workflows.ts';
|
|
10
|
+
import { durationSeconds, everHeld, headersOf, lowerKeys, MESSAGE_FIELDS, mintId, multiValue, nowMs, QUEUE_NAME, refuse, vendorFields, type Row } from './shared.ts';
|
|
11
|
+
|
|
12
|
+
/** How QStash retries by default: "By default, we retry a failed delivery 3 times." (https://upstash.com/docs/qstash/features/retry) */
|
|
13
|
+
export const DEFAULT_RETRIES = 3;
|
|
14
|
+
|
|
15
|
+
/** The account the request acts as (the lane's gate has admitted it). */
|
|
16
|
+
export function ownerFor(ctx: SemanticsContext): string {
|
|
17
|
+
const g = gate(ctx, ctx.call.request);
|
|
18
|
+
return 'owner' in g ? g.owner : 'world';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** One message as published: its destination, its headers (lower-cased) and its body, and the queue it is enqueued in. */
|
|
22
|
+
export type Publication = { destination: string; headers: Record<string, string>; body: string; queue?: string | undefined };
|
|
23
|
+
|
|
24
|
+
/** What a publish answers for one message: its id, and whether it was a duplicate. */
|
|
25
|
+
export type Accepted = { messageId: string; deduplicated?: boolean } | Array<{ messageId: string; url: string; deduplicated?: boolean }> | { error: string; status: number };
|
|
26
|
+
|
|
27
|
+
/** The `Upstash-Forward-*` headers, their prefix stripped: what the destination receives. */
|
|
28
|
+
export function forwarded(headers: Record<string, string>): Record<string, string> {
|
|
29
|
+
const out: Record<string, string> = {};
|
|
30
|
+
for (const [k, v] of Object.entries(headers)) if (k.startsWith('upstash-forward-')) out[k.slice('upstash-forward-'.length)] = v;
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A message's `header`: the headers sent to the API, its Content-Type and the ones it forwards, each name in its canonical
|
|
35
|
+
* form, as the message example of https://upstash.com/docs/qstash/overall/llms-txt answers them
|
|
36
|
+
* (`"header": { "Content-Type": ["application/json"] }`). */
|
|
37
|
+
export function sentHeader(headers: Record<string, string>): Record<string, string[]> {
|
|
38
|
+
const canonical = (k: string): string => k.split('-').map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()).join('-');
|
|
39
|
+
const sent = { ...(headers['content-type'] ? { 'content-type': headers['content-type'] } : {}), ...forwarded(headers) };
|
|
40
|
+
return multiValue(Object.fromEntries(Object.entries(sent).map(([k, v]) => [canonical(k), v])));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** `Upstash-Flow-Control-Value`'s settings ("parallelism=15, rate=10, period=1m"). */
|
|
44
|
+
function flowControl(value: string | undefined): Row {
|
|
45
|
+
const out: Row = {};
|
|
46
|
+
for (const part of (value ?? '').split(',')) {
|
|
47
|
+
const [k, v] = part.split('=').map((s) => s.trim());
|
|
48
|
+
if (k === 'parallelism' || k === 'rate') out[k] = Number(v);
|
|
49
|
+
if (k === 'period') out.period = durationSeconds(v) ?? v;
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Store one message: the vendor's Message fields, its state (CREATED) and what the lane keeps to deliver it. */
|
|
55
|
+
export async function storeMessage(ctx: SemanticsContext, p: {
|
|
56
|
+
url: string; headers: Record<string, string>; body: string; owner: string; queue?: string | undefined; scheduleId?: string | undefined;
|
|
57
|
+
callbackOf?: string | undefined; run?: { id: string; seq: number; call: string; initial: boolean } | undefined; retries?: number | undefined; due?: number | undefined;
|
|
58
|
+
topic?: { name: string; endpoint?: string | undefined } | undefined;
|
|
59
|
+
}): Promise<string> {
|
|
60
|
+
const h = p.headers;
|
|
61
|
+
const ordinal = everHeld(ctx, 'Message');
|
|
62
|
+
const id = mintId(ctx, 'msg_', 'message', ordinal);
|
|
63
|
+
const created = nowMs(ctx);
|
|
64
|
+
const notBefore = h['upstash-not-before'] !== undefined && /^\d+$/.test(h['upstash-not-before']) ? Number(h['upstash-not-before']) * 1000 : undefined;
|
|
65
|
+
const delay = durationSeconds(h['upstash-delay']) ?? 0;
|
|
66
|
+
const due = p.due ?? notBefore ?? created + delay * 1000;
|
|
67
|
+
const retries = p.retries ?? (h['upstash-retries'] !== undefined && /^\d+$/.test(h['upstash-retries']) ? Number(h['upstash-retries']) : DEFAULT_RETRIES);
|
|
68
|
+
const fields: Row = {
|
|
69
|
+
messageId: id, url: p.url, method: h['upstash-method'] ?? 'POST', header: sentHeader(h), body: p.body, maxRetries: retries,
|
|
70
|
+
notBefore: due, createdAt: created,
|
|
71
|
+
...(h['upstash-callback'] ? { callback: h['upstash-callback'] } : {}),
|
|
72
|
+
...(h['upstash-failure-callback'] ? { failureCallback: h['upstash-failure-callback'] } : {}),
|
|
73
|
+
...(p.queue ? { queueName: p.queue } : {}),
|
|
74
|
+
...(p.scheduleId ? { scheduleId: p.scheduleId } : {}),
|
|
75
|
+
...labelsOf(h),
|
|
76
|
+
...(p.topic ? { topicName: p.topic.name, ...(p.topic.endpoint ? { endpointName: p.topic.endpoint } : {}) } : {}),
|
|
77
|
+
...(h['upstash-flow-control-key'] ? { flowControlKey: h['upstash-flow-control-key'], ...flowControl(h['upstash-flow-control-value']) } : {}),
|
|
78
|
+
state: 'CREATED',
|
|
79
|
+
_owner: p.owner, _due: due, _attempts: 0, _ordinal: ordinal, _published: h,
|
|
80
|
+
...(h['content-type'] ? { _content_type: h['content-type'] } : {}),
|
|
81
|
+
...(h['upstash-retry-delay'] ? { _retry_delay: h['upstash-retry-delay'] } : {}),
|
|
82
|
+
...(h['upstash-deduplication-id'] ? { _dedup: h['upstash-deduplication-id'] } : h['upstash-content-based-deduplication'] === 'true' ? { _dedup: contentKey(p.url, p.body, h) } : {}),
|
|
83
|
+
...(h['upstash-timeout'] ? { _timeout: durationSeconds(h['upstash-timeout']) ?? h['upstash-timeout'] } : {}),
|
|
84
|
+
...(h['upstash-redact-fields'] ? { _redact: h['upstash-redact-fields'] } : {}),
|
|
85
|
+
...(p.callbackOf ? { _callback_of: p.callbackOf } : {}),
|
|
86
|
+
...(p.run ? { _run: p.run.id, _seq: p.run.seq, _call: p.run.call, _initial: p.run.initial } : {}),
|
|
87
|
+
};
|
|
88
|
+
await ctx.write('Message', id, fields, 'message.create');
|
|
89
|
+
return id;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A message's labels: `Upstash-Label` carries one, or several joined with commas (@upstash/qstash's `serializeLabel`:
|
|
93
|
+
* `label.join(",")`); the Message schema answers the first as `label` and all as `labels`. */
|
|
94
|
+
export function labelsOf(h: Record<string, string>): Row {
|
|
95
|
+
const labels = (h['upstash-label'] ?? '').split(',').map((l) => l.trim()).filter(Boolean);
|
|
96
|
+
return labels.length ? { label: labels[0], labels } : {};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Content-based deduplication: "If you want to deduplicate messages automatically, you can set the
|
|
100
|
+
* `Upstash-Content-Based-Deduplication` header to `true`." (https://upstash.com/docs/qstash/features/deduplication). Where
|
|
101
|
+
* the page stops and the lane decides: a message's content is its destination, its body and the headers it forwards. */
|
|
102
|
+
function contentKey(url: string, body: string, h: Record<string, string>): string {
|
|
103
|
+
const fwd = Object.entries(h).filter(([k]) => k.startsWith('upstash-forward-')).sort();
|
|
104
|
+
return `content:${createHash('sha256').update(JSON.stringify([url, body, fwd])).digest('hex')}`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** "The deduplication window is 10 minutes. After that, messages with the same ID or content can be sent again."
|
|
108
|
+
* (https://upstash.com/docs/qstash/features/deduplication) */
|
|
109
|
+
const DEDUP_WINDOW_MS = 10 * 60_000;
|
|
110
|
+
|
|
111
|
+
/** Accept one plain message (no workflow): refuse a destination that is not a URL, answer a duplicate's first id, and
|
|
112
|
+
* make the queue a first enqueue names. "Messages can be deduplicated... In case a message is a duplicate, we will accept
|
|
113
|
+
* the request and return the messageID of the existing message." (https://upstash.com/docs/qstash/features/deduplication) */
|
|
114
|
+
export async function acceptPlain(ctx: SemanticsContext, p: Publication, owner: string): Promise<Accepted> {
|
|
115
|
+
if (p.queue !== undefined && !QUEUE_NAME.test(p.queue)) return { error: 'Queue name is invalid. Queue names can only contain alphanumeric characters, hyphens, periods, and underscores.', status: 400 };
|
|
116
|
+
if (!/^https?:\/\//.test(p.destination)) return acceptToUrlGroup(ctx, p, owner);
|
|
117
|
+
const dedup = p.headers['upstash-deduplication-id'] ?? (p.headers['upstash-content-based-deduplication'] === 'true' ? contentKey(p.destination, p.body, p.headers) : undefined);
|
|
118
|
+
if (dedup) {
|
|
119
|
+
const prior = ctx.rowsRaw('Message').find((m) => m._dedup === dedup && m._owner === owner && nowMs(ctx) - Number(m.createdAt) < DEDUP_WINDOW_MS);
|
|
120
|
+
if (prior) return { messageId: String(prior.messageId), deduplicated: true };
|
|
121
|
+
}
|
|
122
|
+
if (p.queue !== undefined) await ensureQueue(ctx, p.queue);
|
|
123
|
+
const messageId = await storeMessage(ctx, { url: p.destination, headers: p.headers, body: p.body, owner, queue: p.queue });
|
|
124
|
+
return { messageId };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** A message to a URL group: "If the destination is a URL Group, a new message will be created for each endpoint in the
|
|
128
|
+
* group." (the spec's publish); the answer lists each endpoint's message and URL (`PublishToUrlGroupResponse`). A name the
|
|
129
|
+
* account has no URL group of answers 404 (the spec's publish 404 is the destination not found). */
|
|
130
|
+
export async function acceptToUrlGroup(ctx: SemanticsContext, p: Publication, owner: string): Promise<Accepted> {
|
|
131
|
+
const group = ctx.rowsRaw('URLGroup').find((g) => g.name === p.destination && g._owner === owner);
|
|
132
|
+
if (!group) return { error: `URL Group ${p.destination} not found`, status: 404 };
|
|
133
|
+
if (p.queue !== undefined) await ensureQueue(ctx, p.queue);
|
|
134
|
+
const out: Array<{ messageId: string; url: string }> = [];
|
|
135
|
+
for (const e of (group.endpoints ?? []) as Array<{ url: string; name?: string }>) {
|
|
136
|
+
const messageId = await storeMessage(ctx, { url: e.url, headers: p.headers, body: p.body, owner, queue: p.queue, topic: { name: String(group.name), endpoint: e.name } });
|
|
137
|
+
out.push({ messageId, url: e.url });
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** A queue a first enqueue names: "If the queue does not exist, it will be created automatically with default
|
|
143
|
+
* parallelism." (the spec's enqueue); a queue delivers in order, one message at a time
|
|
144
|
+
* (https://upstash.com/docs/qstash/features/queues). */
|
|
145
|
+
export async function ensureQueue(ctx: SemanticsContext, name: string): Promise<void> {
|
|
146
|
+
if (ctx.rowsRaw('Queue').some((q) => q.name === name)) return;
|
|
147
|
+
const at = nowMs(ctx);
|
|
148
|
+
await ctx.write('Queue', name, { name, createdAt: at, updatedAt: at, parallelism: 1, paused: false, lag: 0 }, 'queue.create');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function accept(ctx: SemanticsContext, p: Publication, owner: string): Promise<Accepted> {
|
|
152
|
+
return invalidDestination(ctx, p, owner) ?? (isWorkflowMessage(p.headers) ? acceptWorkflowMessage(ctx, p, owner) : acceptPlain(ctx, p, owner));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** "Destination can either be a valid URL where the message gets sent to, or a URL Group name." (the spec's publish): a
|
|
156
|
+
* URL that does not parse (a page's `https://<YOUR_WORKFLOW_ENDPOINT>/<YOUR-WORKFLOW-ROUTE>`) is refused 400, and a
|
|
157
|
+
* workflow's run sent to a name the account holds no URL group of answers the publish's 404 (a plain message's group is
|
|
158
|
+
* looked up by acceptToUrlGroup). */
|
|
159
|
+
function invalidDestination(ctx: SemanticsContext, p: Publication, owner: string): Accepted | undefined {
|
|
160
|
+
if (/^https?:\/\//i.test(p.destination)) return URL.canParse(p.destination) ? undefined : { error: `invalid destination url: ${p.destination}`, status: 400 };
|
|
161
|
+
const group = ctx.rowsRaw('URLGroup').some((g) => g.name === p.destination && g._owner === owner);
|
|
162
|
+
return isWorkflowMessage(p.headers) && !group ? { error: `URL Group ${p.destination} not found`, status: 404 } : undefined;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const answer = (ctx: SemanticsContext, a: Accepted): Response =>
|
|
166
|
+
!Array.isArray(a) && 'error' in a ? refuse(ctx, a.status, a.error) : ctx.reply(a, !Array.isArray(a) && a.deduplicated ? 202 : 200);
|
|
167
|
+
|
|
168
|
+
/** POST /v2/publish/{destination}. */
|
|
169
|
+
const publish: Semantics = async (ctx) => {
|
|
170
|
+
const destination = String(ctx.call.params.destination ?? '');
|
|
171
|
+
return answer(ctx, await accept(ctx, { destination, headers: headersOf(ctx), body: ctx.text ?? '' }, ownerFor(ctx)));
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
/** POST /v2/enqueue/{queueName}/{destination}. */
|
|
175
|
+
const enqueue: Semantics = async (ctx) => {
|
|
176
|
+
const destination = String(ctx.call.params.destination ?? '');
|
|
177
|
+
return answer(ctx, await accept(ctx, { destination, headers: headersOf(ctx), body: ctx.text ?? '', queue: String(ctx.call.params.queueName ?? '') }, ownerFor(ctx)));
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
/** POST /v2/batch: each entry `{destination, headers, body, queue}` is published as its own message, in order, and the
|
|
181
|
+
* answer lists each one's `PublishResponse`. Where the documents stop and the lane decides: an entry that cannot be
|
|
182
|
+
* published refuses the whole batch with its reason, before any later entry is published. */
|
|
183
|
+
const batch: Semantics = async (ctx) => {
|
|
184
|
+
if (!Array.isArray(ctx.body)) return refuse(ctx, 400, 'The request body must be a JSON array of messages.');
|
|
185
|
+
const owner = ownerFor(ctx);
|
|
186
|
+
const out: Row[] = [];
|
|
187
|
+
for (const entry of ctx.body as Row[]) {
|
|
188
|
+
const destination = typeof entry?.destination === 'string' ? entry.destination : '';
|
|
189
|
+
if (!destination) return refuse(ctx, 400, 'destination is required');
|
|
190
|
+
const body = typeof entry.body === 'string' ? entry.body : entry.body === undefined ? '' : JSON.stringify(entry.body);
|
|
191
|
+
const a = await accept(ctx, { destination, headers: lowerKeys(entry.headers as Row), body, ...(typeof entry.queue === 'string' ? { queue: entry.queue } : {}) }, owner);
|
|
192
|
+
if (!Array.isArray(a) && 'error' in a) return refuse(ctx, a.status, a.error);
|
|
193
|
+
out.push(a as Row);
|
|
194
|
+
}
|
|
195
|
+
return ctx.reply(out);
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
/** A message still to be delivered: "Messages are removed from the database shortly after they're delivered, so you will
|
|
199
|
+
* not be able to retrieve a message after." (the spec's get). The lane answers 404 once a message is delivered, failed
|
|
200
|
+
* or cancelled. */
|
|
201
|
+
function pending(ctx: SemanticsContext, id: string): Row | undefined {
|
|
202
|
+
const m = ctx.rowsRaw('Message').find((r) => r.messageId === id);
|
|
203
|
+
return m && ['CREATED', 'ACTIVE', 'RETRY', 'CANCEL_REQUESTED'].includes(String(m.state)) ? m : undefined;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** GET /v2/messages/{messageId}. */
|
|
207
|
+
const getMessage: Semantics = async (ctx) => {
|
|
208
|
+
const m = pending(ctx, String(ctx.call.params.messageId));
|
|
209
|
+
return m ? ctx.reply(redacted(vendorFields(ctx, m, MESSAGE_FIELDS), m)) : refuse(ctx, 404, 'Message not found.');
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
/** DELETE /v2/messages/{messageId}: "Cancel a pending message"; it is CANCELLED at its next delivery time (debug-logs),
|
|
213
|
+
* never delivered. Answered 202 with no body (the spec's 202). */
|
|
214
|
+
const cancelMessage: Semantics = async (ctx) => {
|
|
215
|
+
const id = String(ctx.call.params.messageId);
|
|
216
|
+
const m = ctx.rowsRaw('Message').find((r) => r.messageId === id);
|
|
217
|
+
if (!m) return refuse(ctx, 404, 'Message not found.');
|
|
218
|
+
const refused = ctx.legal('Message', 'state', 'delete_v2_messages_messageid', m.state, 'CANCEL_REQUESTED', id);
|
|
219
|
+
if (refused) return ctx.refuse(refused);
|
|
220
|
+
await ctx.write('Message', String(m.id), { state: 'CANCEL_REQUESTED' }, 'message.cancel');
|
|
221
|
+
return new Response(null, { status: 202 });
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
export const messageSemantics: Record<string, Semantics> = {
|
|
225
|
+
post_v2_publish_destination: publish,
|
|
226
|
+
post_v2_enqueue_queuename_destination: enqueue,
|
|
227
|
+
post_v2_batch: batch,
|
|
228
|
+
get_v2_messages_messageid: getMessage,
|
|
229
|
+
delete_v2_messages_messageid: cancelMessage,
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
/** A message's fields as the API answers them after redaction: "QStash allows you to redact specific fields so they appear
|
|
233
|
+
* as `REDACTED:<SHA256>` in the dashboard and API. The original values are still used when delivering messages to your
|
|
234
|
+
* endpoint." (https://upstash.com/docs/qstash/howto/redact-fields); `Upstash-Redact-Fields` names `body`, `header` (all)
|
|
235
|
+
* or `header[<name>]`. Where the page stops and the lane decides: the SHA-256 is of the value, in hex. */
|
|
236
|
+
export function redacted(view: Row, row: Row): Row {
|
|
237
|
+
const spec = typeof row._redact === 'string' ? row._redact : '';
|
|
238
|
+
if (!spec) return view;
|
|
239
|
+
const parts = spec.split(',').map((x) => x.trim());
|
|
240
|
+
const sha = (v: string): string => `REDACTED:${createHash('sha256').update(v).digest('hex')}`;
|
|
241
|
+
const out: Row = { ...view };
|
|
242
|
+
if (parts.includes('body') && typeof out.body === 'string') out.body = sha(out.body);
|
|
243
|
+
const all = parts.includes('header') || parts.includes('headers');
|
|
244
|
+
const names = parts.map((x) => /^headers?\[(.+)\]$/.exec(x)?.[1]?.toLowerCase()).filter((x): x is string => !!x);
|
|
245
|
+
if ((all || names.length) && out.header && typeof out.header === 'object') {
|
|
246
|
+
out.header = Object.fromEntries(Object.entries(out.header as Record<string, string[]>).map(([k, v]) => [k, all || names.includes(k.toLowerCase()) ? v.map(sha) : v]));
|
|
247
|
+
}
|
|
248
|
+
return out;
|
|
249
|
+
}
|