@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,401 @@
|
|
|
1
|
+
// Upstash Workflow on QStash: a run is a sequence of QStash messages to the app's workflow route, each delivery carrying
|
|
2
|
+
// every step the run has so far ("Each step is executed in its own HTTP call to your application. After a step finishes,
|
|
3
|
+
// its result is stored in durable state inside Upstash Workflow. On the next execution, Workflow skips completed steps and
|
|
4
|
+
// resumes exactly where it left off by restoring the previous step results.", https://upstash.com/docs/workflow/basics/how).
|
|
5
|
+
//
|
|
6
|
+
// The wire is the vendor's own client's, @upstash/workflow 1.3.0 (the version Dub installs; spec/SOURCE.md names what the
|
|
7
|
+
// documents omit): a run starts with a batch entry carrying `Upstash-Workflow-Init: true`, `Upstash-Workflow-RunId` and
|
|
8
|
+
// `Upstash-Workflow-Url` (`triggerFirstInvocation`, `WorkflowHeaders.addBaseHeaders`); each step the route runs comes back
|
|
9
|
+
// as a batch entry with `Upstash-Workflow-Init: false` and the step as its body (`submitSingleStep`); a delivery's body is
|
|
10
|
+
// the run's steps as `[{messageId, body: <base64>, callType}]`, the first being the run's payload (`processRawSteps`); a
|
|
11
|
+
// wait is `POST /v2/wait/{eventId}` (`LazyWaitEventStep`), and the notified or timed-out step comes back with its `out`
|
|
12
|
+
// (the event's data, base64) and `waitTimeout` (`LazyWaitForEventStep.parseWaitForEventOut`); the route reports the run
|
|
13
|
+
// finished with `DELETE /v2/workflows/runs/{id}?cancel=false` (`triggerWorkflowDelete`).
|
|
14
|
+
import type { Semantics, SemanticsContext } from '@volter/world-core';
|
|
15
|
+
import { labelsOf, ownerFor, storeMessage, type Accepted, type Publication, DEFAULT_RETRIES } from './messages.ts';
|
|
16
|
+
import { b64, durationSeconds, everHeld, headersOf, lowerKeys, matchesFilters, mintId, multiValue, nowMs, refuse, singleValue, type Row } from './shared.ts';
|
|
17
|
+
|
|
18
|
+
const FAILURE_PREFIX = 'upstash-failure-callback-';
|
|
19
|
+
|
|
20
|
+
export const isWorkflowMessage = (h: Record<string, string>): boolean => h['upstash-workflow-runid'] !== undefined;
|
|
21
|
+
|
|
22
|
+
const runOf = (ctx: SemanticsContext, id: string): Row | undefined => ctx.rowsRaw('WorkflowRun').find((r) => r.workflowRunId === id);
|
|
23
|
+
|
|
24
|
+
/** A run's messages, in the order the run made them. */
|
|
25
|
+
export function runMessages(ctx: SemanticsContext, runId: string): Row[] {
|
|
26
|
+
return ctx.rowsRaw('Message').filter((m) => m._run === runId).sort((a, b) => Number(a._seq) - Number(b._seq));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The steps a delivery of the run's message `seq` carries: the steps a resumed run kept, then the run's own messages up
|
|
30
|
+
* to `seq`, each as `{messageId, body: <base64 of what was published>, callType}` (`processRawSteps`). */
|
|
31
|
+
export function rawSteps(ctx: SemanticsContext, runId: string, seq: number): Row[] {
|
|
32
|
+
const run = runOf(ctx, runId);
|
|
33
|
+
const carried = Array.isArray(run?._carried) ? (run!._carried as Row[]) : [];
|
|
34
|
+
const own = runMessages(ctx, runId).filter((m) => Number(m._seq) <= seq && m.state !== 'CANCELLED')
|
|
35
|
+
.map((m) => ({ messageId: String(m.messageId), body: b64(String(m.body ?? '')), callType: String(m._call ?? 'step') }));
|
|
36
|
+
return [...carried, ...own];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** What a workflow's delivery carries in its headers: every `Upstash-Forward-*` header with the prefix stripped (the
|
|
40
|
+
* run's `Upstash-Workflow-Sdk-Version` among them, which is how `serve()` tells a delivery from its own first
|
|
41
|
+
* invocation), and the run's own workflow headers as published. */
|
|
42
|
+
export function workflowHeaders(published: Record<string, string>): Record<string, string> {
|
|
43
|
+
const out: Record<string, string> = {};
|
|
44
|
+
for (const [k, v] of Object.entries(published)) if (k.startsWith('upstash-forward-')) out[k.slice('upstash-forward-'.length)] = v;
|
|
45
|
+
for (const name of ['upstash-workflow-runid', 'upstash-workflow-url', 'upstash-workflow-init', 'upstash-workflow-calltype', 'upstash-feature-set', 'content-type']) {
|
|
46
|
+
if (published[name] !== undefined && out[name] === undefined) out[name] = published[name]!;
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A run's delivery configuration, from the entry that starts it: `Upstash-Retries` (absent: the default 3; the client
|
|
52
|
+
* sends it only when it differs), `Upstash-Retry-Delay`, the failure URL (`Upstash-Failure-Callback`, which the client
|
|
53
|
+
* sets to the workflow URL itself unless told otherwise: "const failureUrl = option.failureUrl ?? option.url") and the
|
|
54
|
+
* `Upstash-Failure-Callback-*` headers the failure call is published with. */
|
|
55
|
+
function runConfig(h: Record<string, string>): Row {
|
|
56
|
+
const failureHeaders: Record<string, string> = {};
|
|
57
|
+
for (const [k, v] of Object.entries(h)) if (k.startsWith(FAILURE_PREFIX)) failureHeaders[k] = v;
|
|
58
|
+
return {
|
|
59
|
+
_retries: h['upstash-retries'] !== undefined && /^\d+$/.test(h['upstash-retries']) ? Number(h['upstash-retries']) : DEFAULT_RETRIES,
|
|
60
|
+
...(h['upstash-retry-delay'] ? { _retry_delay: h['upstash-retry-delay'] } : {}),
|
|
61
|
+
...(h['upstash-failure-callback'] ? { _failure_url: h['upstash-failure-callback'] } : {}),
|
|
62
|
+
_failure_headers: failureHeaders,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** One batch or publish entry of a workflow: the first of a run starts it, a later one is a step of an active run. A run
|
|
67
|
+
* id the World already holds is not started twice: the client reads `deduplicated` and warns "Workflow run … already
|
|
68
|
+
* exists" (`triggerFirstInvocation`). A step for a run no longer active is refused 400, which the client reads as "Tried
|
|
69
|
+
* to append to a cancelled workflow" (`triggerRouteFunction`). */
|
|
70
|
+
export async function acceptWorkflowMessage(ctx: SemanticsContext, p: Publication, owner: string): Promise<Accepted> {
|
|
71
|
+
const h = p.headers;
|
|
72
|
+
const runId = h['upstash-workflow-runid']!;
|
|
73
|
+
const url = h['upstash-workflow-url'] ?? p.destination;
|
|
74
|
+
if (h['upstash-workflow-init'] === 'true') {
|
|
75
|
+
const existing = runOf(ctx, runId);
|
|
76
|
+
if (existing) return { messageId: String(runMessages(ctx, runId)[0]?.messageId ?? ''), deduplicated: true };
|
|
77
|
+
const config = runConfig(h);
|
|
78
|
+
await ctx.write('WorkflowRun', runId, {
|
|
79
|
+
workflowRunId: runId, workflowUrl: url, workflowState: 'RUN_STARTED', workflowRunCreatedAt: nowMs(ctx),
|
|
80
|
+
...labelsOf(h),
|
|
81
|
+
...(h['upstash-flow-control-key'] ? { flowControlKey: h['upstash-flow-control-key'] } : {}),
|
|
82
|
+
_owner: owner, _published: h, ...config,
|
|
83
|
+
}, 'workflow_run.start');
|
|
84
|
+
const messageId = await storeMessage(ctx, { url: p.destination, headers: h, body: p.body, owner, run: { id: runId, seq: 0, call: 'step', initial: true }, retries: Number(config._retries) });
|
|
85
|
+
return { messageId };
|
|
86
|
+
}
|
|
87
|
+
const run = runOf(ctx, runId);
|
|
88
|
+
if (!run) return { error: `workflow run ${runId} not found`, status: 404 };
|
|
89
|
+
if (run.workflowState !== 'RUN_STARTED') return { error: `workflow run ${runId} is not active`, status: 400 };
|
|
90
|
+
return { messageId: await appendStep(ctx, run, p.destination, h, p.body) };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Append a step to an active run: the next message of its sequence, to its route. A step's own `Upstash-Retries` governs
|
|
94
|
+
* its delivery; without one, the run's. */
|
|
95
|
+
async function appendStep(ctx: SemanticsContext, run: Row, url: string, h: Record<string, string>, body: string): Promise<string> {
|
|
96
|
+
const runId = String(run.workflowRunId);
|
|
97
|
+
const seq = runMessages(ctx, runId).reduce((max, m) => Math.max(max, Number(m._seq)), -1) + 1;
|
|
98
|
+
const own = h['upstash-retries'] !== undefined && /^\d+$/.test(h['upstash-retries']) ? Number(h['upstash-retries']) : undefined;
|
|
99
|
+
return storeMessage(ctx, { url, headers: h, body, owner: String(run._owner), run: { id: runId, seq, call: h['upstash-workflow-calltype'] ?? 'step', initial: false }, retries: own ?? Number(run._retries ?? DEFAULT_RETRIES) });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The headers a step the vendor makes for a run (a notified or timed-out wait) is published with: the run's own
|
|
103
|
+
* workflow headers, as a step the route sends carries them. */
|
|
104
|
+
function stepHeaders(run: Row, extra: Record<string, string> = {}): Record<string, string> {
|
|
105
|
+
const published = (run._published ?? {}) as Record<string, string>;
|
|
106
|
+
const out: Record<string, string> = {};
|
|
107
|
+
for (const [k, v] of Object.entries(published)) {
|
|
108
|
+
if (k.startsWith('upstash-forward-') || ['upstash-workflow-runid', 'upstash-workflow-url', 'upstash-feature-set', 'content-type'].includes(k)) out[k] = v;
|
|
109
|
+
}
|
|
110
|
+
return { ...out, 'upstash-workflow-init': 'false', 'upstash-workflow-calltype': 'step', ...extra };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The wait step coming back to the run: notified, with the event's data as its `out` (base64, as the client decodes it),
|
|
114
|
+
* or timed out. */
|
|
115
|
+
async function resumeWait(ctx: SemanticsContext, run: Row, step: Row, eventId: string, data: string | undefined, headers?: Record<string, string>): Promise<string> {
|
|
116
|
+
const body = JSON.stringify({ ...step, waitEventId: eventId, ...(data !== undefined ? { out: b64(data), waitTimeout: false } : { waitTimeout: true }) });
|
|
117
|
+
return appendStep(ctx, run, String(run.workflowUrl), headers ?? stepHeaders(run), body);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── waits and notifies ─────────────────────────────────────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
const NOTIFICATION = '_workflow_notification';
|
|
123
|
+
const waiterId = (eventId: string, runId: string): string => `${eventId}:${runId}`;
|
|
124
|
+
|
|
125
|
+
/** A waiter as the workflow document's `Waiter` answers it. */
|
|
126
|
+
function waiterView(ctx: SemanticsContext, w: Row): Row {
|
|
127
|
+
const own = ctx.own(w);
|
|
128
|
+
return { url: own.url, headers: own.headers ?? {}, deadline: own.deadline, ...(own.timeoutBody ? { timeoutBody: own.timeoutBody } : {}), timeoutUrl: own.timeoutUrl, timeoutHeaders: own.timeoutHeaders ?? {} };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** POST /v2/wait/{eventId} (the spec patch, from the client's `LazyWaitEventStep`): the run in the
|
|
132
|
+
* `Upstash-Workflow-RunId` header waits for the event until its timeout ("Default timeout value is 7 days",
|
|
133
|
+
* https://upstash.com/docs/workflow/basics/context/waitForEvent). A notification the run was sent before it waited is
|
|
134
|
+
* consumed at once ("When a waiter becomes active later, it will consume the saved notification", the spec's notify).
|
|
135
|
+
* Where the evidence stops: the lane answers 200 with no body (the client does not read it), and a run the World does
|
|
136
|
+
* not hold, or one no longer active, 400 in the notify's words. */
|
|
137
|
+
const wait: Semantics = async (ctx) => {
|
|
138
|
+
const eventId = String(ctx.call.params.eventId);
|
|
139
|
+
const runId = headersOf(ctx)['upstash-workflow-runid'];
|
|
140
|
+
const run = runId ? runOf(ctx, runId) : undefined;
|
|
141
|
+
if (!run || run.workflowState !== 'RUN_STARTED') return refuse(ctx, 400, 'Invalid workflow run ID or event ID');
|
|
142
|
+
const b = (ctx.body && typeof ctx.body === 'object' ? ctx.body : {}) as Row;
|
|
143
|
+
const step = (b.step && typeof b.step === 'object' ? b.step : {}) as Row;
|
|
144
|
+
const saved = ctx.rowsRaw(NOTIFICATION).find((n) => n.id === waiterId(eventId, String(runId)) && n.consumed !== true);
|
|
145
|
+
if (saved) {
|
|
146
|
+
await ctx.record(NOTIFICATION, { ...ctx.own(saved), consumed: true }, String(saved.id));
|
|
147
|
+
await resumeWait(ctx, run, step, eventId, String(saved.data ?? ''));
|
|
148
|
+
return new Response(null, { status: 200 });
|
|
149
|
+
}
|
|
150
|
+
const seconds = durationSeconds(b.timeout ?? '7d') ?? 7 * 86_400;
|
|
151
|
+
await ctx.write('Waiter', waiterId(eventId, String(runId)), {
|
|
152
|
+
url: typeof b.url === 'string' ? b.url : String(run.workflowUrl),
|
|
153
|
+
headers: b.timeoutHeaders ?? {},
|
|
154
|
+
deadline: Math.floor(nowMs(ctx) / 1000) + seconds,
|
|
155
|
+
...(typeof b.timeoutBody === 'string' ? { timeoutBody: b.timeoutBody } : {}),
|
|
156
|
+
timeoutUrl: typeof b.timeoutUrl === 'string' ? b.timeoutUrl : String(run.workflowUrl),
|
|
157
|
+
timeoutHeaders: b.timeoutHeaders ?? {},
|
|
158
|
+
_event: eventId, _run: runId, _step: step,
|
|
159
|
+
}, 'waiter.create');
|
|
160
|
+
return new Response(null, { status: 200 });
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
/** POST /v2/notify/{workflowRunId}/{eventId}: "If a waiter is currently active, it receives the notification
|
|
164
|
+
* immediately. If no waiter is active, the notification is saved in the database. When a waiter becomes active later, it
|
|
165
|
+
* will consume the saved notification." (the spec). Answered 201 with the `NotifyResponse`: the waiter and the message
|
|
166
|
+
* that resumes its run, or the run alone when the notification is saved. */
|
|
167
|
+
const notify: Semantics = async (ctx) => {
|
|
168
|
+
const runId = String(ctx.call.params.workflowRunId);
|
|
169
|
+
const eventId = String(ctx.call.params.eventId);
|
|
170
|
+
const run = runOf(ctx, runId);
|
|
171
|
+
if (!run || run.workflowState !== 'RUN_STARTED') return refuse(ctx, 400, 'Invalid workflow run ID or event ID');
|
|
172
|
+
const data = ctx.text ?? '';
|
|
173
|
+
const who = { workflowRunId: runId, workflowCreatedAt: Number(run.workflowRunCreatedAt) };
|
|
174
|
+
const w = ctx.rowsRaw('Waiter').find((x) => x.id === waiterId(eventId, runId));
|
|
175
|
+
if (!w) {
|
|
176
|
+
await ctx.record(NOTIFICATION, { run: runId, event: eventId, data, at: ctx.occurredAt, consumed: false }, waiterId(eventId, runId));
|
|
177
|
+
return ctx.reply({ ...who, error: '' }, 201);
|
|
178
|
+
}
|
|
179
|
+
const view = waiterView(ctx, w);
|
|
180
|
+
await ctx.write('Waiter', String(w.id), { deleted: true }, 'waiter.notified');
|
|
181
|
+
const messageId = await resumeWait(ctx, run, (w._step ?? {}) as Row, eventId, data);
|
|
182
|
+
return ctx.reply({ waiter: view, messageId, error: '', ...who }, 201);
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
/** POST /v2/notify/{eventId}: "Notify an event to all waiters listening for that event ID. ... if there are no active
|
|
186
|
+
* waiters when the notification is sent, the event is lost and will not be delivered later." (the spec). Answered 201
|
|
187
|
+
* with one `NotifyResponse` per waiter notified, an empty list when none waits. */
|
|
188
|
+
const notifyEvent: Semantics = async (ctx) => {
|
|
189
|
+
const eventId = String(ctx.call.params.eventId);
|
|
190
|
+
const data = ctx.text ?? '';
|
|
191
|
+
const out: Row[] = [];
|
|
192
|
+
for (const w of ctx.rowsRaw('Waiter').filter((x) => x._event === eventId).sort((a, b) => String(a.id).localeCompare(String(b.id)))) {
|
|
193
|
+
const run = runOf(ctx, String(w._run));
|
|
194
|
+
if (!run || run.workflowState !== 'RUN_STARTED') continue;
|
|
195
|
+
const view = waiterView(ctx, w);
|
|
196
|
+
await ctx.write('Waiter', String(w.id), { deleted: true }, 'waiter.notified');
|
|
197
|
+
const messageId = await resumeWait(ctx, run, (w._step ?? {}) as Row, eventId, data);
|
|
198
|
+
out.push({ waiter: view, messageId, error: '', workflowRunId: run.workflowRunId, workflowCreatedAt: run.workflowRunCreatedAt });
|
|
199
|
+
}
|
|
200
|
+
return ctx.reply(out, 201);
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
/** A wait whose timeout has come: the step comes back to its timeout URL timed out, with the headers the run gave for it
|
|
204
|
+
* (the catch-up, delivery.ts). */
|
|
205
|
+
export async function timeOut(ctx: SemanticsContext, w: Row): Promise<void> {
|
|
206
|
+
const run = runOf(ctx, String(w._run));
|
|
207
|
+
await ctx.write('Waiter', String(w.id), { deleted: true }, 'waiter.timeout');
|
|
208
|
+
if (!run || run.workflowState !== 'RUN_STARTED') return;
|
|
209
|
+
const given = lowerKeys(singleValue(w.timeoutHeaders as Row));
|
|
210
|
+
await resumeWait(ctx, run, (w._step ?? {}) as Row, String(w._event), undefined, stepHeaders(run, given));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ── ending a run ───────────────────────────────────────────────────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
/** Cancel a run: it moves to RUN_CANCELED, the steps it still owes are cancelled and its waits end. */
|
|
216
|
+
async function cancelRun(ctx: SemanticsContext, run: Row, operation: string): Promise<void> {
|
|
217
|
+
const runId = String(run.workflowRunId);
|
|
218
|
+
const refused = ctx.legal('WorkflowRun', 'workflowState', operation, run.workflowState, 'RUN_CANCELED', runId);
|
|
219
|
+
if (refused) throw new Error(refused.message);
|
|
220
|
+
await ctx.write('WorkflowRun', runId, { workflowState: 'RUN_CANCELED', workflowRunCompletedAt: nowMs(ctx) }, 'workflow_run.cancel');
|
|
221
|
+
for (const m of runMessages(ctx, runId).filter((x) => x.state === 'CREATED' || x.state === 'RETRY')) await cancelStep(ctx, m);
|
|
222
|
+
for (const w of ctx.rowsRaw('Waiter').filter((x) => x._run === runId)) await ctx.write('Waiter', String(w.id), { deleted: true }, 'waiter.canceled');
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** A step of a cancelled run still waiting for its delivery (a `context.sleep`, a retry): cancelled, never delivered. */
|
|
226
|
+
async function cancelStep(ctx: SemanticsContext, m: Row): Promise<void> {
|
|
227
|
+
const no = ctx.legal('Message', 'state', 'delete_v2_workflows_runs', m.state, 'CANCEL_REQUESTED', String(m.id));
|
|
228
|
+
if (no) throw new Error(no.message);
|
|
229
|
+
await ctx.write('Message', String(m.id), { state: 'CANCEL_REQUESTED' }, 'message.cancel');
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** DELETE /v2/workflows/runs/{workflowRunId}: `?cancel=true` cancels the run ("Cancel an ongoing workflow run."), and
|
|
233
|
+
* `?cancel=false` is the route reporting its function returned, the run's result as the body (the spec patch). Answered
|
|
234
|
+
* 200 with no body; a run the World does not hold, or one already ended, answers 404 in the spec's words. */
|
|
235
|
+
const endRun: Semantics = async (ctx) => {
|
|
236
|
+
const runId = String(ctx.call.params.workflowRunId);
|
|
237
|
+
const run = runOf(ctx, runId);
|
|
238
|
+
if (!run) return refuse(ctx, 404, 'A workflow run is not found with the given id.');
|
|
239
|
+
const cancel = new URL(ctx.call.request.url).searchParams.get('cancel') === 'true';
|
|
240
|
+
const to = cancel ? 'RUN_CANCELED' : 'RUN_SUCCESS';
|
|
241
|
+
const refused = ctx.legal('WorkflowRun', 'workflowState', 'delete_v2_workflows_runs_workflowrunid', run.workflowState, to, runId);
|
|
242
|
+
if (refused) return ctx.refuse(refused);
|
|
243
|
+
if (cancel) await cancelRun(ctx, run, 'delete_v2_workflows_runs_workflowrunid');
|
|
244
|
+
else await ctx.write('WorkflowRun', runId, { workflowState: 'RUN_SUCCESS', workflowRunCompletedAt: nowMs(ctx), ...(ctx.text ? { workflowRunResponse: ctx.text } : {}) }, 'workflow_run.finish');
|
|
245
|
+
return new Response(null, { status: 200 });
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
/** The ids a query names, whether repeated or comma-separated ("Multiple values can be passed either by repeating the
|
|
249
|
+
* query parameter ... or as a single comma-separated value", the spec's bulk cancel). */
|
|
250
|
+
export function idsOf(ctx: SemanticsContext, name: string): string[] {
|
|
251
|
+
return new URL(ctx.call.request.url).searchParams.getAll(name).flatMap((v) => v.split(',')).map((s) => s.trim()).filter(Boolean);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** The runs' fields a bulk cancel filters on, by query parameter (the spec's bulk cancel parameters). */
|
|
255
|
+
const RUN_FILTERS = { workflowUrl: 'workflowUrl', label: 'labels', flowControlKey: 'flowControlKey', callerIp: 'workflowRunCallerIp' };
|
|
256
|
+
|
|
257
|
+
/** DELETE /v2/workflows/runs: "If you provide a list of workflow run IDs, only those specific workflow runs will be
|
|
258
|
+
* canceled. If you include filter parameters, only the workflow runs matching the filters will be canceled. If no filter
|
|
259
|
+
* or workflow run IDs are sent, all workflow runs will be canceled." and "Cancelling by `workflowRunIds` is synchronous,
|
|
260
|
+
* and `cancelled` is the exact number of workflow runs cancelled." (the spec). A workflow URL filter matches by prefix
|
|
261
|
+
* unless `workflowUrlExactMatch` (the client's `workflowUrlStartingWith` and `workflowUrl`). Where the documentation stops
|
|
262
|
+
* and the lane decides: a filter-based cancel finishes within the request (the spec's default waits up to a minute for
|
|
263
|
+
* it), so it answers the number cancelled and no `actionId`. */
|
|
264
|
+
const cancelRuns: Semantics = async (ctx) => {
|
|
265
|
+
const ids = idsOf(ctx, 'workflowRunIds');
|
|
266
|
+
const exact = new URL(ctx.call.request.url).searchParams.get('workflowUrlExactMatch') === 'true';
|
|
267
|
+
const owner = ownerFor(ctx);
|
|
268
|
+
const targets = ids.length ? ids.map((id) => runOf(ctx, id)).filter((r): r is Row => !!r)
|
|
269
|
+
: ctx.rowsRaw('WorkflowRun').filter((r) => r._owner === owner && matchesFilters(ctx, r, RUN_FILTERS, 'workflowRunCreatedAt', exact ? [] : ['workflowUrl']));
|
|
270
|
+
let cancelled = 0;
|
|
271
|
+
for (const run of targets.sort((a, b) => Number(a.workflowRunCreatedAt) - Number(b.workflowRunCreatedAt))) {
|
|
272
|
+
if (run.workflowState !== 'RUN_STARTED') continue;
|
|
273
|
+
await cancelRun(ctx, run, 'delete_v2_workflows_runs');
|
|
274
|
+
cancelled += 1;
|
|
275
|
+
}
|
|
276
|
+
return ctx.reply({ cancelled });
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
// ── a run's failure, and its DLQ ───────────────────────────────────────────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
/** The `WorkflowDLQMessage` schema's fields. */
|
|
282
|
+
const WORKFLOW_DLQ_FIELDS = ['dlqId', 'workflowUrl', 'workflowRunId', 'workflowCreatedAt', 'url', 'method', 'header', 'body', 'bodyBase64', 'maxRetries', 'createdAt',
|
|
283
|
+
'failureCallback', 'callerIP', 'label', 'labels', 'flowControlKey', 'failureFunctionState', 'responseStatus', 'responseHeader', 'responseBody', 'responseBodyBase64'];
|
|
284
|
+
|
|
285
|
+
export function workflowDlqView(ctx: SemanticsContext, e: Row): Row {
|
|
286
|
+
const own = ctx.own(e);
|
|
287
|
+
return Object.fromEntries(WORKFLOW_DLQ_FIELDS.filter((k) => own[k] !== undefined && own[k] !== null).map((k) => [k, own[k]]));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** A step's message has failed after its retries: "When a workflow step fails and exhausts all configured retries,
|
|
291
|
+
* Upstash Workflow automatically moves the failed run to the DLQ." (https://upstash.com/docs/workflow/features/dlq); the
|
|
292
|
+
* run is RUN_FAILED, and its failure function is called ("a failure function ... executes custom logic when a workflow
|
|
293
|
+
* run fails after exhausting all retry attempts", https://upstash.com/docs/workflow/features/failure-callback) as a
|
|
294
|
+
* QStash failure callback to the failure URL, with the callback body plus the run's id and creation time, which the
|
|
295
|
+
* client's `serve()` reads, and the `Upstash-Failure-Callback-*` headers the run was started with (their prefix made
|
|
296
|
+
* `Upstash-`). */
|
|
297
|
+
export async function failRun(ctx: SemanticsContext, m: Row, outcome: { status: number; body: string; headers: Record<string, string> }): Promise<void> {
|
|
298
|
+
const run = runOf(ctx, String(m._run));
|
|
299
|
+
if (!run || run.workflowState !== 'RUN_STARTED') return;
|
|
300
|
+
const runId = String(run.workflowRunId);
|
|
301
|
+
const dlqId = `${Math.floor(nowMs(ctx))}-${everHeld(ctx, 'WorkflowDLQMessage')}`;
|
|
302
|
+
const failureUrl = typeof run._failure_url === 'string' ? run._failure_url : undefined;
|
|
303
|
+
await ctx.write('WorkflowDLQMessage', dlqId, {
|
|
304
|
+
dlqId, workflowUrl: run.workflowUrl, workflowRunId: runId, workflowCreatedAt: run.workflowRunCreatedAt,
|
|
305
|
+
url: m.url, method: m.method ?? 'POST', header: multiValue((m._published ?? {}) as Record<string, string>), body: m.body ?? '', maxRetries: m.maxRetries,
|
|
306
|
+
createdAt: nowMs(ctx), ...(failureUrl ? { failureCallback: failureUrl, failureFunctionState: 'CALLBACK_INPROGRESS' } : {}),
|
|
307
|
+
...(run.label ? { label: run.label, labels: run.labels } : {}), ...(run.flowControlKey ? { flowControlKey: run.flowControlKey } : {}),
|
|
308
|
+
responseStatus: outcome.status, responseHeader: multiValue(outcome.headers), responseBody: outcome.body,
|
|
309
|
+
_message_id: m.messageId, _seq: m._seq,
|
|
310
|
+
}, 'workflow_dlq.create');
|
|
311
|
+
const refused = ctx.legal('WorkflowRun', 'workflowState', 'delete_v2_workflows_runs_workflowrunid', run.workflowState, 'RUN_FAILED', runId, 'vendor');
|
|
312
|
+
if (refused) throw new Error(refused.message);
|
|
313
|
+
await ctx.write('WorkflowRun', runId, { workflowState: 'RUN_FAILED', workflowRunCompletedAt: nowMs(ctx), dlqId }, 'workflow_run.fail');
|
|
314
|
+
if (!failureUrl) return;
|
|
315
|
+
const headers: Record<string, string> = { 'content-type': 'application/json' };
|
|
316
|
+
for (const [k, v] of Object.entries((run._failure_headers ?? {}) as Record<string, string>)) headers[`upstash-${k.slice(FAILURE_PREFIX.length)}`] = v;
|
|
317
|
+
const body = JSON.stringify({
|
|
318
|
+
status: outcome.status, header: multiValue(outcome.headers), body: b64(outcome.body), retried: Number(m._attempts ?? 1) - 1,
|
|
319
|
+
maxRetries: Number(m.maxRetries ?? DEFAULT_RETRIES), dlqId, sourceMessageId: m.messageId, url: run.workflowUrl, method: 'POST',
|
|
320
|
+
sourceHeader: multiValue((m._published ?? {}) as Record<string, string>), sourceBody: b64(String(runMessages(ctx, runId)[0]?.body ?? '')),
|
|
321
|
+
notBefore: 0, createdAt: nowMs(ctx), callerIP: '127.0.0.1', workflowRunId: runId, workflowCreatedAt: run.workflowRunCreatedAt,
|
|
322
|
+
});
|
|
323
|
+
await storeMessage(ctx, { url: failureUrl, headers: { ...headers, 'upstash-retries': headers['upstash-retries'] ?? String(DEFAULT_RETRIES) }, body, owner: String(run._owner), callbackOf: String(m.messageId) });
|
|
324
|
+
await ctx.record('_failure_call', { dlq: dlqId, run: runId }, `failure:${dlqId}`);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** The failure call's outcome on its DLQ entry: CALLBACK_SUCCESS once it is answered, CALLBACK_FAIL once its retries are
|
|
328
|
+
* spent (the workflow document's `failureFunctionState` filter lists CALLBACK_INPROGRESS, CALLBACK_SUCCESS, CALLBACK_FAIL). */
|
|
329
|
+
export async function failureCallSettled(ctx: SemanticsContext, callbackOf: string, delivered: boolean): Promise<void> {
|
|
330
|
+
const entry = ctx.rowsRaw('WorkflowDLQMessage').find((e) => e._message_id === callbackOf);
|
|
331
|
+
if (entry) await ctx.write('WorkflowDLQMessage', String(entry.id), { failureFunctionState: delivered ? 'CALLBACK_SUCCESS' : 'CALLBACK_FAIL' }, 'workflow_dlq.failure_call');
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** When an entry was filed, in the order entries were filed: its time, then its place in the DLQ (the `-<n>` of its id), so
|
|
335
|
+
* that runs failing in one catch-up keep the order they failed in. */
|
|
336
|
+
const filedOrder = (e: Row): number => Number(e.createdAt) * 1000 + Number(String(e.dlqId).split('-')[1] ?? 0) % 1000;
|
|
337
|
+
|
|
338
|
+
/** The Workflow DLQ, newest first, as `{messages}`: "If no cursor is returned, you have reached the end". Answers
|
|
339
|
+
* `GET /v2/workflows/dlq` and the client's `GET /v2/dlq?source=workflow` (the spec patch). */
|
|
340
|
+
export function workflowDlq(ctx: SemanticsContext): Response {
|
|
341
|
+
const owner = ownerFor(ctx);
|
|
342
|
+
const count = Number(new URL(ctx.call.request.url).searchParams.get('count') ?? 0);
|
|
343
|
+
const all = ctx.rowsRaw('WorkflowDLQMessage').filter((e) => runOf(ctx, String(e.workflowRunId))?._owner === owner && matchesFilters(ctx, e, DLQ_FILTERS, 'createdAt'))
|
|
344
|
+
.sort((a, b) => filedOrder(b) - filedOrder(a));
|
|
345
|
+
const messages = (count > 0 ? all.slice(0, count) : all).map((e) => workflowDlqView(ctx, e));
|
|
346
|
+
return ctx.reply({ messages });
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** POST /v2/workflows/dlq/resume?dlqIds=: "continue a failed workflow run from the exact point of failure, preserving all
|
|
350
|
+
* successfully completed steps and their results" (https://upstash.com/docs/workflow/features/dlq/resume); "A new
|
|
351
|
+
* workflow run ID is generated, but the workflow maintains the state and results from previously completed steps."
|
|
352
|
+
* (the spec's resume). The new run keeps the steps the failed one had delivered and sends the failed step again, with
|
|
353
|
+
* the run's retries, or those the request names in `Upstash-Retries` (the client's `buildResumeRestartHeaders`).
|
|
354
|
+
* Where the documentation stops: the DLQ entry stays until its retention ends (delivery.ts). */
|
|
355
|
+
const resume: Semantics = async (ctx) => {
|
|
356
|
+
const byId = idsOf(ctx, 'dlqIds');
|
|
357
|
+
const owner = ownerFor(ctx);
|
|
358
|
+
const ids = byId.length ? byId : ctx.rowsRaw('WorkflowDLQMessage').filter((e) => runOf(ctx, String(e.workflowRunId))?._owner === owner && !e._resumed_as && matchesFilters(ctx, e, DLQ_FILTERS, 'createdAt'))
|
|
359
|
+
.sort((a, b) => filedOrder(a) - filedOrder(b)).map((e) => String(e.dlqId));
|
|
360
|
+
const h = headersOf(ctx);
|
|
361
|
+
const workflowRuns: Row[] = [];
|
|
362
|
+
for (const dlqId of ids) {
|
|
363
|
+
const entry = ctx.rowsRaw('WorkflowDLQMessage').find((e) => e.dlqId === dlqId);
|
|
364
|
+
const source = entry ? runOf(ctx, String(entry.workflowRunId)) : undefined;
|
|
365
|
+
if (!entry || !source) return refuse(ctx, 404, `DLQ message ${dlqId} not found`);
|
|
366
|
+
const failedSeq = Number(entry._seq);
|
|
367
|
+
const kept = rawSteps(ctx, String(source.workflowRunId), failedSeq - 1);
|
|
368
|
+
const runId = mintId(ctx, 'wfr_', 'workflow_run', everHeld(ctx, 'WorkflowRun'));
|
|
369
|
+
const created = nowMs(ctx);
|
|
370
|
+
const retries = h['upstash-retries'] !== undefined && /^\d+$/.test(h['upstash-retries']) ? Number(h['upstash-retries']) : Number(source._retries ?? DEFAULT_RETRIES);
|
|
371
|
+
const published = { ...((source._published ?? {}) as Record<string, string>), 'upstash-workflow-runid': runId };
|
|
372
|
+
await ctx.write('WorkflowRun', runId, {
|
|
373
|
+
workflowRunId: runId, workflowUrl: source.workflowUrl, workflowState: 'RUN_STARTED', workflowRunCreatedAt: created,
|
|
374
|
+
...(source.label ? { label: source.label, labels: source.labels } : {}), ...(source.flowControlKey ? { flowControlKey: source.flowControlKey } : {}),
|
|
375
|
+
_owner: source._owner, _published: published, _retries: retries, ...(source._retry_delay ? { _retry_delay: source._retry_delay } : {}),
|
|
376
|
+
...(source._failure_url ? { _failure_url: source._failure_url } : {}), _failure_headers: source._failure_headers ?? {},
|
|
377
|
+
_carried: kept, _resumed_from: dlqId,
|
|
378
|
+
}, 'workflow_run.resume');
|
|
379
|
+
const failed = runMessages(ctx, String(source.workflowRunId)).find((m) => m.messageId === entry._message_id);
|
|
380
|
+
const stepPublished = { ...((failed?._published ?? {}) as Record<string, string>), 'upstash-workflow-runid': runId };
|
|
381
|
+
await storeMessage(ctx, { url: String(entry.url), headers: stepPublished, body: String(entry.body ?? ''), owner: String(source._owner), run: { id: runId, seq: kept.length, call: String(failed?._call ?? 'step'), initial: false }, retries });
|
|
382
|
+
await ctx.write('WorkflowDLQMessage', String(entry.id), { _resumed_as: runId }, 'workflow_dlq.resumed');
|
|
383
|
+
workflowRuns.push({ workflowRunId: runId, workflowCreatedAt: created });
|
|
384
|
+
}
|
|
385
|
+
return ctx.reply({ workflowRuns });
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
/** The Workflow DLQ's fields its bulk actions and list filter on, by query parameter (the spec's resume parameters); each
|
|
389
|
+
* matches exactly: "Filter by exact workflow URL." (https://upstash.com/docs/workflow/basics/client/dlq/list). */
|
|
390
|
+
const DLQ_FILTERS = { workflowUrl: 'workflowUrl', workflowRunId: 'workflowRunId', label: 'labels', failureFunctionState: 'failureFunctionState', callerIp: 'callerIP', flowControlKey: 'flowControlKey' };
|
|
391
|
+
|
|
392
|
+
export const workflowSemantics: Record<string, Semantics> = {
|
|
393
|
+
post_v2_notify_eventid: notifyEvent,
|
|
394
|
+
post_v2_wait_eventid: wait,
|
|
395
|
+
post_v2_notify_workflowrunid_eventid: notify,
|
|
396
|
+
delete_v2_workflows_runs_workflowrunid: endRun,
|
|
397
|
+
delete_v2_workflows_runs: cancelRuns,
|
|
398
|
+
post_v2_workflows_dlq_resume: resume,
|
|
399
|
+
get_v2_workflows_dlq: async (ctx) => workflowDlq(ctx),
|
|
400
|
+
};
|
|
401
|
+
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** base64url(SHA-256(body)): the `body` claim. */
|
|
2
|
+
export declare const bodyHash: (body: string) => string;
|
|
3
|
+
/** The JWT for one delivery of `body` to `sub`, issued at `iat` (Unix seconds), valid five minutes. */
|
|
4
|
+
export declare function sign(body: string, sub: string, iat: number, jti: string, key: string): string;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// The `Upstash-Signature` a delivery carries: a JWT signed with the account's current signing key (HS256), whose claims
|
|
2
|
+
// name the issuer, the destination and the body's SHA-256. "Upstash provides a JWT with each request. This JWT is signed by
|
|
3
|
+
// your individual secret signing keys." (https://upstash.com/docs/qstash/howto/roll-signing-keys); the claims are the ones
|
|
4
|
+
// the vendor's client checks (@upstash/qstash's `Receiver`: `iss` "Upstash", `sub` the URL, `body` the base64url SHA-256
|
|
5
|
+
// of the body, `exp`/`nbf`; https://upstash.com/docs/qstash/howto/signature). Pure: no clock and no randomness of its own;
|
|
6
|
+
// the `jti` is derived from the message and the attempt, so a World signs the same bytes every run.
|
|
7
|
+
import { createHash, createHmac } from 'node:crypto';
|
|
8
|
+
const b64url = (b) => b.toString('base64url');
|
|
9
|
+
const b64urlJson = (o) => b64url(Buffer.from(JSON.stringify(o), 'utf8'));
|
|
10
|
+
/** base64url(SHA-256(body)): the `body` claim. */
|
|
11
|
+
export const bodyHash = (body) => b64url(createHash('sha256').update(body, 'utf8').digest());
|
|
12
|
+
/** The JWT for one delivery of `body` to `sub`, issued at `iat` (Unix seconds), valid five minutes. */
|
|
13
|
+
export function sign(body, sub, iat, jti, key) {
|
|
14
|
+
const input = `${b64urlJson({ alg: 'HS256', typ: 'JWT' })}.${b64urlJson({ iss: 'Upstash', sub, iat, nbf: iat, exp: iat + 300, jti, body: bodyHash(body) })}`;
|
|
15
|
+
return `${input}.${b64url(createHmac('sha256', key).update(input).digest())}`;
|
|
16
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// The `Upstash-Signature` a delivery carries: a JWT signed with the account's current signing key (HS256), whose claims
|
|
2
|
+
// name the issuer, the destination and the body's SHA-256. "Upstash provides a JWT with each request. This JWT is signed by
|
|
3
|
+
// your individual secret signing keys." (https://upstash.com/docs/qstash/howto/roll-signing-keys); the claims are the ones
|
|
4
|
+
// the vendor's client checks (@upstash/qstash's `Receiver`: `iss` "Upstash", `sub` the URL, `body` the base64url SHA-256
|
|
5
|
+
// of the body, `exp`/`nbf`; https://upstash.com/docs/qstash/howto/signature). Pure: no clock and no randomness of its own;
|
|
6
|
+
// the `jti` is derived from the message and the attempt, so a World signs the same bytes every run.
|
|
7
|
+
import { createHash, createHmac } from 'node:crypto';
|
|
8
|
+
|
|
9
|
+
const b64url = (b: Buffer): string => b.toString('base64url');
|
|
10
|
+
const b64urlJson = (o: unknown): string => b64url(Buffer.from(JSON.stringify(o), 'utf8'));
|
|
11
|
+
|
|
12
|
+
/** base64url(SHA-256(body)): the `body` claim. */
|
|
13
|
+
export const bodyHash = (body: string): string => b64url(createHash('sha256').update(body, 'utf8').digest());
|
|
14
|
+
|
|
15
|
+
/** The JWT for one delivery of `body` to `sub`, issued at `iat` (Unix seconds), valid five minutes. */
|
|
16
|
+
export function sign(body: string, sub: string, iat: number, jti: string, key: string): string {
|
|
17
|
+
const input = `${b64urlJson({ alg: 'HS256', typ: 'JWT' })}.${b64urlJson({ iss: 'Upstash', sub, iat, nbf: iat, exp: iat + 300, jti, body: bodyHash(body) })}`;
|
|
18
|
+
return `${input}.${b64url(createHmac('sha256', key).update(input).digest())}`;
|
|
19
|
+
}
|
package/dist/src/cli.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { keepProcessAlive } from '@volter/world-core/lifecycle';
|
|
3
|
+
// world-upstash CLI: serve the KERNEL-BACKED Upstash Redis REST twin, or run conformance.
|
|
4
|
+
// State lives in the @volter/world-core action log (no in-memory side-store). Conformance is dev-only +
|
|
5
|
+
// lazy-imported so the bin runs without @volter/world-tooling (E2).
|
|
6
|
+
import { hasFlag, optionValue } from '@volter/world-core/args';
|
|
7
|
+
import { createUpstashRedisTwinServer } from "./upstash-server.js";
|
|
8
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
9
|
+
const port = Number(optionValue(rest, '--port', String(process.env.PORT ?? '0'))) || undefined;
|
|
10
|
+
const root = optionValue(rest, '--root') || undefined;
|
|
11
|
+
// The token this twin will DEMAND. Omit it and any non-empty credential is accepted (a missing one
|
|
12
|
+
// is still a vendor-shaped 401) — the right default for a local twin that holds no real secret.
|
|
13
|
+
const token = optionValue(rest, '--token') || undefined;
|
|
14
|
+
const readOnly = hasFlag(rest, '--read-only'); // a twin accepts writes unless started read-only
|
|
15
|
+
if (cmd === 'serve' || cmd === undefined) {
|
|
16
|
+
const s = await createUpstashRedisTwinServer({
|
|
17
|
+
readOnly,
|
|
18
|
+
...(root ? { root } : {}),
|
|
19
|
+
...(port ? { port } : {}),
|
|
20
|
+
...(token ? { token } : {}),
|
|
21
|
+
});
|
|
22
|
+
process.stdout.write(`upstash twin (Redis over Upstash REST: POST /, /pipeline, /multi-exec, path-style, EVAL/EVALSHA Lua)${readOnly ? ' [read-only]' : ''} at http://127.0.0.1:${s.port}\n`
|
|
23
|
+
+ ` point a client at it with UPSTASH_REDIS_REST_URL=http://127.0.0.1:${s.port} and any non-empty UPSTASH_REDIS_REST_TOKEN\n`);
|
|
24
|
+
await keepProcessAlive();
|
|
25
|
+
}
|
|
26
|
+
else if (cmd === 'conformance') {
|
|
27
|
+
const { checkUpstashRedisConformance } = await import("./upstash-conformance.js");
|
|
28
|
+
const report = await checkUpstashRedisConformance();
|
|
29
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
30
|
+
if (!report.ok)
|
|
31
|
+
process.exitCode = 1;
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
process.stdout.write('Usage: world-upstash serve|conformance [--port N] [--root DIR] [--token T] [--read-only]\n');
|
|
35
|
+
}
|