@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,105 @@
|
|
|
1
|
+
/** The path parameters whose values hold slashes: a destination is a whole URL (`/v2/publish/https://example.com/x`). */
|
|
2
|
+
export const spanning = ['destination', 'workflowUrl'];
|
|
3
|
+
/** The hosts the lane serves: "https://qstash-{region}.upstash.io" with the regions us-east-1 and eu-central-1 (both
|
|
4
|
+
* documents' `servers`), and qstash.upstash.io, the host Upstash's pages send their examples to. An application pointed at
|
|
5
|
+
* the World through QSTASH_URL reaches the lane at the pack's own address instead. SHAPE judges the lane's life on these. */
|
|
6
|
+
export const hosts = [{ host: 'qstash-us-east-1.upstash.io' }, { host: 'qstash-eu-central-1.upstash.io' }, { host: 'qstash.upstash.io' }];
|
|
7
|
+
const LIFECYCLE = 'https://upstash.com/docs/qstash/howto/debug-logs';
|
|
8
|
+
const RETRY = 'https://upstash.com/docs/qstash/features/retry';
|
|
9
|
+
const CANCEL = 'spec:/documents/qstash/paths/~1v2~1messages~1{messageId}/delete "Cancel a pending message"';
|
|
10
|
+
/** A message's `state`: the state its log answers (the message's own view has no state field). "When a message is ready
|
|
11
|
+
* for execution, it will be become ACTIVE and a delivery to your API is attempted. If you API responds with a status code
|
|
12
|
+
* between 200 - 299, the task is considered successful and will be marked as DELIVERED. Otherwise the message is being
|
|
13
|
+
* retried if there are any retries left and moves to RETRY. If all retries are exhausted, the task has FAILED and the
|
|
14
|
+
* message will be moved to the DLQ." (debug-logs). A cancel logs CANCEL_REQUESTED, and "If retries are not exhausted
|
|
15
|
+
* yet, in the next deliver time, the message will be marked as CANCELLED" (the same page). A retry falls due on the World
|
|
16
|
+
* clock (`min(86400, e^(2.5n))` seconds, the retry page). ERROR is the log's record of a failed attempt, written beside
|
|
17
|
+
* the move to RETRY or FAILED: not a state the message rests in. Not made by the lane: a delivery in flight when a cancel
|
|
18
|
+
* arrives (ACTIVE → CANCEL_REQUESTED): the lane's attempt is over before any request is answered. */
|
|
19
|
+
const messageState = {
|
|
20
|
+
initial: 'CREATED',
|
|
21
|
+
transitions: [
|
|
22
|
+
{ actor: 'time', from: ['CREATED'], to: 'ACTIVE', source: LIFECYCLE },
|
|
23
|
+
{ actor: 'vendor', from: ['ACTIVE'], to: 'DELIVERED', source: LIFECYCLE },
|
|
24
|
+
{ actor: 'vendor', from: ['ACTIVE'], to: 'RETRY', source: RETRY },
|
|
25
|
+
{ actor: 'vendor', from: ['ACTIVE'], to: 'FAILED', source: LIFECYCLE },
|
|
26
|
+
{ actor: 'time', from: ['RETRY'], to: 'ACTIVE', source: RETRY },
|
|
27
|
+
{ operation: 'delete_v2_messages_messageid', from: ['CREATED', 'RETRY'], to: 'CANCEL_REQUESTED', refusal: { status: 404, message: 'Message not found.' }, source: CANCEL },
|
|
28
|
+
// a run canceled cancels the steps it still owes
|
|
29
|
+
{ operation: 'delete_v2_workflows_runs', from: ['CREATED', 'RETRY'], to: 'CANCEL_REQUESTED', source: 'spec:/documents/workflow/paths/~1v2~1workflows~1runs/delete "Cancel all matching workflow runs."' },
|
|
30
|
+
{ actor: 'time', from: ['CANCEL_REQUESTED'], to: 'CANCELLED', source: LIFECYCLE },
|
|
31
|
+
],
|
|
32
|
+
};
|
|
33
|
+
const RUN_STATES = 'https://upstash.com/docs/workflow/basics/client/logs';
|
|
34
|
+
const RUN_CANCEL = 'spec:/documents/workflow/paths/~1v2~1workflows~1runs~1{workflowRunId}/delete "Cancel an ongoing workflow run."';
|
|
35
|
+
const RUNS_CANCEL = 'spec:/documents/workflow/paths/~1v2~1workflows~1runs/delete "Cancel all matching workflow runs."';
|
|
36
|
+
/** A workflow run's `workflowState`: "RUN_STARTED The workflow run is in progress. RUN_SUCCESS The workflow run completed
|
|
37
|
+
* successfully. RUN_FAILED The run failed after all retries. RUN_CANCELED The run was manually canceled." (the client's
|
|
38
|
+
* logs page). A run succeeds when its route function returns: `serve()` then reports it finished with
|
|
39
|
+
* `DELETE /v2/workflows/runs/{workflowRunId}?cancel=false` (the spec patch's `cancel`, from @upstash/workflow's source);
|
|
40
|
+
* `client.cancel` cancels through the bulk `DELETE /v2/workflows/runs?workflowRunIds=`. A run fails when a step's message
|
|
41
|
+
* is FAILED. A run in the DLQ is resumed as a new run with a new id ("A new workflow run ID is generated",
|
|
42
|
+
* https://upstash.com/docs/workflow/api-reference/dlq/resume-workflow-from-dlq), so the failed run never moves again. */
|
|
43
|
+
const runState = {
|
|
44
|
+
initial: 'RUN_STARTED',
|
|
45
|
+
transitions: [
|
|
46
|
+
{ operation: 'delete_v2_workflows_runs_workflowrunid', from: ['RUN_STARTED'], to: 'RUN_SUCCESS', refusal: { status: 404, message: 'A workflow run is not found with the given id.' }, source: RUN_STATES },
|
|
47
|
+
{ operation: 'delete_v2_workflows_runs_workflowrunid', from: ['RUN_STARTED'], to: 'RUN_CANCELED', refusal: { status: 404, message: 'A workflow run is not found with the given id.' }, source: RUN_CANCEL },
|
|
48
|
+
{ operation: 'delete_v2_workflows_runs', from: ['RUN_STARTED'], to: 'RUN_CANCELED', source: RUNS_CANCEL },
|
|
49
|
+
{ actor: 'vendor', from: ['RUN_STARTED'], to: 'RUN_FAILED', source: RUN_STATES },
|
|
50
|
+
],
|
|
51
|
+
};
|
|
52
|
+
/** A schedule's `isPaused`: "When a schedule is paused, the cron trigger will simply be ignored. If the schedule is
|
|
53
|
+
* already paused, this action has no effect." (the spec's pause). The stay is declared first: an ask naming no target
|
|
54
|
+
* takes the first transition that allows it. Not made by the lane: resume (no customer of the life resumes a schedule;
|
|
55
|
+
* its operation is the gap). */
|
|
56
|
+
const SCHEDULE_PAUSE = 'spec:/documents/qstash/paths/~1v2~1schedules~1{scheduleId}~1pause/post "If the schedule is already paused, this action has no effect."';
|
|
57
|
+
const schedulePaused = {
|
|
58
|
+
initial: false,
|
|
59
|
+
transitions: [
|
|
60
|
+
{ operation: 'post_v2_schedules_scheduleid_pause', from: ['true'], source: SCHEDULE_PAUSE },
|
|
61
|
+
{ operation: 'post_v2_schedules_scheduleid_pause', from: ['false'], to: 'true', source: SCHEDULE_PAUSE },
|
|
62
|
+
// the clients' PATCH form of the same operation (the spec patch)
|
|
63
|
+
{ operation: 'patch_v2_schedules_scheduleid_pause', from: ['true'], source: SCHEDULE_PAUSE },
|
|
64
|
+
{ operation: 'patch_v2_schedules_scheduleid_pause', from: ['false'], to: 'true', source: SCHEDULE_PAUSE },
|
|
65
|
+
],
|
|
66
|
+
};
|
|
67
|
+
export const manifest = {
|
|
68
|
+
vendor: 'upstash',
|
|
69
|
+
service: 'upstash',
|
|
70
|
+
body: {},
|
|
71
|
+
// QStash mints a message id `msg_…`, a schedule id `scd_…` and a workflow run id `wfr_…` (the pages' examples:
|
|
72
|
+
// msg_xxx, scd_xxx, wfr_abc123); the lane's handlers mint them, and a trigger may name its own run id
|
|
73
|
+
// (`workflowRunId`, https://upstash.com/docs/workflow/basics/client/trigger)
|
|
74
|
+
ids: { template: '{prefix}{n}', acceptProvided: true },
|
|
75
|
+
// createdAt, notBefore and a waiter's deadline are Unix times
|
|
76
|
+
time: 'unix',
|
|
77
|
+
error: { error: '{message}' },
|
|
78
|
+
readOnly: { status: 405, message: 'This twin is read-only: omit readOnly to accept writes.' },
|
|
79
|
+
malformedBody: { status: 400, message: 'The request body is not valid JSON.' },
|
|
80
|
+
notFound: { status: 404, message: 'Not found.' },
|
|
81
|
+
// every list is a handler's (the DLQ's `{messages, cursor}`, a bare array of queues); required by the type, nothing
|
|
82
|
+
// reads it
|
|
83
|
+
list: { style: 'envelope', envelope: { messages: '{data}', cursor: '{next_cursor}' }, limit: { param: 'count', default: 100, max: 100 }, cursor: { param: 'cursor', encoding: 'base64-offset' } },
|
|
84
|
+
deleted: {},
|
|
85
|
+
// the operations of a declared resource the lane does not serve: the gap
|
|
86
|
+
unmodeled: [
|
|
87
|
+
'get_v2_dlq_dlqid', 'delete_v2_dlq_dlqid', 'get_v2_queues', 'get_v2_schedules', 'get_v2_schedules_scheduleid',
|
|
88
|
+
'get_v2_waiters_eventid', 'get_v2_workflows_dlq_dlqid', 'delete_v2_workflows_dlq_dlqid', 'get_v2_workflows_logs',
|
|
89
|
+
],
|
|
90
|
+
resources: {
|
|
91
|
+
// the message is not a resource of either document (GET /v2/messages/{messageId} answers `Message`, a view): its
|
|
92
|
+
// state is what its log answers
|
|
93
|
+
Message: { storedAs: 'qstash_message', idPrefix: 'msg_', state: { state: messageState } },
|
|
94
|
+
// a queue is made by its first enqueue or its upsert and holds its messages in order, paused or not
|
|
95
|
+
Queue: { storedAs: 'qstash_queue', idPrefix: '' },
|
|
96
|
+
Schedule: { storedAs: 'qstash_schedule', idPrefix: 'scd_', state: { isPaused: schedulePaused } },
|
|
97
|
+
// a URL group (a topic): its endpoints, keyed by its account and name
|
|
98
|
+
URLGroup: { storedAs: 'qstash_url_group', idPrefix: '' },
|
|
99
|
+
DLQMessage: { storedAs: 'qstash_dlq', idPrefix: '' },
|
|
100
|
+
WorkflowRun: { storedAs: 'workflow_run', idPrefix: 'wfr_', state: { workflowState: runState } },
|
|
101
|
+
WorkflowDLQMessage: { storedAs: 'workflow_dlq', idPrefix: '' },
|
|
102
|
+
// a waiter is present while a run waits on its event id, and gone when notified, timed out or canceled
|
|
103
|
+
Waiter: { storedAs: 'workflow_waiter', idPrefix: '' },
|
|
104
|
+
},
|
|
105
|
+
};
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Upstash QStash and Upstash Workflow, the `qstash` lane of the upstash pack: one API published as two OpenAPI documents
|
|
2
|
+
// (docs/contributing/architecture.md, "Other wires"). Upstash publishes qstash/openapi.yaml and workflow/openapi.yaml, both
|
|
3
|
+
// served at https://qstash-{region}.upstash.io with one key set and sharing endpoints; the lane vendors both as published
|
|
4
|
+
// (../spec/openapi/) and its surface is their union (./generated/surface.gen.json, by `bun scripts/derive-pack.ts
|
|
5
|
+
// upstash/qstash`). This manifest holds the vendor facts neither document carries, and the machines of the states the lane
|
|
6
|
+
// moves. The lane writes the pack's one store (`upstash`), beside the Redis keys and the console's records.
|
|
7
|
+
//
|
|
8
|
+
// A WORKFLOW IS QSTASH MESSAGES. "Upstash Workflow is built on top of Upstash QStash" and "Each step is executed in its
|
|
9
|
+
// own HTTP call to your application" (https://upstash.com/docs/workflow/basics/how): @upstash/workflow starts a run and
|
|
10
|
+
// sends each step through the QStash batch endpoint with its `Upstash-Workflow-*` headers, and QStash delivers each step
|
|
11
|
+
// to the app's workflow route as it delivers any message. So a run's steps are messages, moved by the message machine
|
|
12
|
+
// below, and the run has its own machine over them.
|
|
13
|
+
//
|
|
14
|
+
// NO `auth`: QStash reads its token as `Authorization: Bearer <QSTASH_TOKEN>` or as the `qstash_token` query parameter
|
|
15
|
+
// (both documents' `securitySchemes`: bearerAuth and bearerAuthQuery), and a token belongs to the account the console
|
|
16
|
+
// shows it to, which the kernel's gate cannot read; the lane's gate (semantics/account.ts) reads both.
|
|
17
|
+
//
|
|
18
|
+
// ERRORS: every refusal both documents give is the `Error` schema, `{"error": "<message>"}`; the documents name a
|
|
19
|
+
// refusal's status and cause ("Message not found") and never its message text. Where the documentation stops: each
|
|
20
|
+
// message below states the rule in the documents' words, not a text Upstash was seen to send.
|
|
21
|
+
import type { DerivedManifest, StateField } from '@volter/world-core';
|
|
22
|
+
|
|
23
|
+
/** The path parameters whose values hold slashes: a destination is a whole URL (`/v2/publish/https://example.com/x`). */
|
|
24
|
+
export const spanning = ['destination', 'workflowUrl'];
|
|
25
|
+
|
|
26
|
+
/** The hosts the lane serves: "https://qstash-{region}.upstash.io" with the regions us-east-1 and eu-central-1 (both
|
|
27
|
+
* documents' `servers`), and qstash.upstash.io, the host Upstash's pages send their examples to. An application pointed at
|
|
28
|
+
* the World through QSTASH_URL reaches the lane at the pack's own address instead. SHAPE judges the lane's life on these. */
|
|
29
|
+
export const hosts = [{ host: 'qstash-us-east-1.upstash.io' }, { host: 'qstash-eu-central-1.upstash.io' }, { host: 'qstash.upstash.io' }];
|
|
30
|
+
|
|
31
|
+
const LIFECYCLE = 'https://upstash.com/docs/qstash/howto/debug-logs';
|
|
32
|
+
const RETRY = 'https://upstash.com/docs/qstash/features/retry';
|
|
33
|
+
const CANCEL = 'spec:/documents/qstash/paths/~1v2~1messages~1{messageId}/delete "Cancel a pending message"';
|
|
34
|
+
|
|
35
|
+
/** A message's `state`: the state its log answers (the message's own view has no state field). "When a message is ready
|
|
36
|
+
* for execution, it will be become ACTIVE and a delivery to your API is attempted. If you API responds with a status code
|
|
37
|
+
* between 200 - 299, the task is considered successful and will be marked as DELIVERED. Otherwise the message is being
|
|
38
|
+
* retried if there are any retries left and moves to RETRY. If all retries are exhausted, the task has FAILED and the
|
|
39
|
+
* message will be moved to the DLQ." (debug-logs). A cancel logs CANCEL_REQUESTED, and "If retries are not exhausted
|
|
40
|
+
* yet, in the next deliver time, the message will be marked as CANCELLED" (the same page). A retry falls due on the World
|
|
41
|
+
* clock (`min(86400, e^(2.5n))` seconds, the retry page). ERROR is the log's record of a failed attempt, written beside
|
|
42
|
+
* the move to RETRY or FAILED: not a state the message rests in. Not made by the lane: a delivery in flight when a cancel
|
|
43
|
+
* arrives (ACTIVE → CANCEL_REQUESTED): the lane's attempt is over before any request is answered. */
|
|
44
|
+
const messageState: StateField = {
|
|
45
|
+
initial: 'CREATED',
|
|
46
|
+
transitions: [
|
|
47
|
+
{ actor: 'time', from: ['CREATED'], to: 'ACTIVE', source: LIFECYCLE },
|
|
48
|
+
{ actor: 'vendor', from: ['ACTIVE'], to: 'DELIVERED', source: LIFECYCLE },
|
|
49
|
+
{ actor: 'vendor', from: ['ACTIVE'], to: 'RETRY', source: RETRY },
|
|
50
|
+
{ actor: 'vendor', from: ['ACTIVE'], to: 'FAILED', source: LIFECYCLE },
|
|
51
|
+
{ actor: 'time', from: ['RETRY'], to: 'ACTIVE', source: RETRY },
|
|
52
|
+
{ operation: 'delete_v2_messages_messageid', from: ['CREATED', 'RETRY'], to: 'CANCEL_REQUESTED', refusal: { status: 404, message: 'Message not found.' }, source: CANCEL },
|
|
53
|
+
// a run canceled cancels the steps it still owes
|
|
54
|
+
{ operation: 'delete_v2_workflows_runs', from: ['CREATED', 'RETRY'], to: 'CANCEL_REQUESTED', source: 'spec:/documents/workflow/paths/~1v2~1workflows~1runs/delete "Cancel all matching workflow runs."' },
|
|
55
|
+
{ actor: 'time', from: ['CANCEL_REQUESTED'], to: 'CANCELLED', source: LIFECYCLE },
|
|
56
|
+
],
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const RUN_STATES = 'https://upstash.com/docs/workflow/basics/client/logs';
|
|
60
|
+
const RUN_CANCEL = 'spec:/documents/workflow/paths/~1v2~1workflows~1runs~1{workflowRunId}/delete "Cancel an ongoing workflow run."';
|
|
61
|
+
const RUNS_CANCEL = 'spec:/documents/workflow/paths/~1v2~1workflows~1runs/delete "Cancel all matching workflow runs."';
|
|
62
|
+
|
|
63
|
+
/** A workflow run's `workflowState`: "RUN_STARTED The workflow run is in progress. RUN_SUCCESS The workflow run completed
|
|
64
|
+
* successfully. RUN_FAILED The run failed after all retries. RUN_CANCELED The run was manually canceled." (the client's
|
|
65
|
+
* logs page). A run succeeds when its route function returns: `serve()` then reports it finished with
|
|
66
|
+
* `DELETE /v2/workflows/runs/{workflowRunId}?cancel=false` (the spec patch's `cancel`, from @upstash/workflow's source);
|
|
67
|
+
* `client.cancel` cancels through the bulk `DELETE /v2/workflows/runs?workflowRunIds=`. A run fails when a step's message
|
|
68
|
+
* is FAILED. A run in the DLQ is resumed as a new run with a new id ("A new workflow run ID is generated",
|
|
69
|
+
* https://upstash.com/docs/workflow/api-reference/dlq/resume-workflow-from-dlq), so the failed run never moves again. */
|
|
70
|
+
const runState: StateField = {
|
|
71
|
+
initial: 'RUN_STARTED',
|
|
72
|
+
transitions: [
|
|
73
|
+
{ operation: 'delete_v2_workflows_runs_workflowrunid', from: ['RUN_STARTED'], to: 'RUN_SUCCESS', refusal: { status: 404, message: 'A workflow run is not found with the given id.' }, source: RUN_STATES },
|
|
74
|
+
{ operation: 'delete_v2_workflows_runs_workflowrunid', from: ['RUN_STARTED'], to: 'RUN_CANCELED', refusal: { status: 404, message: 'A workflow run is not found with the given id.' }, source: RUN_CANCEL },
|
|
75
|
+
{ operation: 'delete_v2_workflows_runs', from: ['RUN_STARTED'], to: 'RUN_CANCELED', source: RUNS_CANCEL },
|
|
76
|
+
{ actor: 'vendor', from: ['RUN_STARTED'], to: 'RUN_FAILED', source: RUN_STATES },
|
|
77
|
+
],
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** A schedule's `isPaused`: "When a schedule is paused, the cron trigger will simply be ignored. If the schedule is
|
|
81
|
+
* already paused, this action has no effect." (the spec's pause). The stay is declared first: an ask naming no target
|
|
82
|
+
* takes the first transition that allows it. Not made by the lane: resume (no customer of the life resumes a schedule;
|
|
83
|
+
* its operation is the gap). */
|
|
84
|
+
const SCHEDULE_PAUSE = 'spec:/documents/qstash/paths/~1v2~1schedules~1{scheduleId}~1pause/post "If the schedule is already paused, this action has no effect."';
|
|
85
|
+
const schedulePaused: StateField = {
|
|
86
|
+
initial: false,
|
|
87
|
+
transitions: [
|
|
88
|
+
{ operation: 'post_v2_schedules_scheduleid_pause', from: ['true'], source: SCHEDULE_PAUSE },
|
|
89
|
+
{ operation: 'post_v2_schedules_scheduleid_pause', from: ['false'], to: 'true', source: SCHEDULE_PAUSE },
|
|
90
|
+
// the clients' PATCH form of the same operation (the spec patch)
|
|
91
|
+
{ operation: 'patch_v2_schedules_scheduleid_pause', from: ['true'], source: SCHEDULE_PAUSE },
|
|
92
|
+
{ operation: 'patch_v2_schedules_scheduleid_pause', from: ['false'], to: 'true', source: SCHEDULE_PAUSE },
|
|
93
|
+
],
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export const manifest: DerivedManifest = {
|
|
97
|
+
vendor: 'upstash',
|
|
98
|
+
service: 'upstash',
|
|
99
|
+
body: {},
|
|
100
|
+
// QStash mints a message id `msg_…`, a schedule id `scd_…` and a workflow run id `wfr_…` (the pages' examples:
|
|
101
|
+
// msg_xxx, scd_xxx, wfr_abc123); the lane's handlers mint them, and a trigger may name its own run id
|
|
102
|
+
// (`workflowRunId`, https://upstash.com/docs/workflow/basics/client/trigger)
|
|
103
|
+
ids: { template: '{prefix}{n}', acceptProvided: true },
|
|
104
|
+
// createdAt, notBefore and a waiter's deadline are Unix times
|
|
105
|
+
time: 'unix',
|
|
106
|
+
error: { error: '{message}' },
|
|
107
|
+
readOnly: { status: 405, message: 'This twin is read-only: omit readOnly to accept writes.' },
|
|
108
|
+
malformedBody: { status: 400, message: 'The request body is not valid JSON.' },
|
|
109
|
+
notFound: { status: 404, message: 'Not found.' },
|
|
110
|
+
// every list is a handler's (the DLQ's `{messages, cursor}`, a bare array of queues); required by the type, nothing
|
|
111
|
+
// reads it
|
|
112
|
+
list: { style: 'envelope', envelope: { messages: '{data}', cursor: '{next_cursor}' }, limit: { param: 'count', default: 100, max: 100 }, cursor: { param: 'cursor', encoding: 'base64-offset' } },
|
|
113
|
+
deleted: {},
|
|
114
|
+
// the operations of a declared resource the lane does not serve: the gap
|
|
115
|
+
unmodeled: [
|
|
116
|
+
'get_v2_dlq_dlqid', 'delete_v2_dlq_dlqid', 'get_v2_queues', 'get_v2_schedules', 'get_v2_schedules_scheduleid',
|
|
117
|
+
'get_v2_waiters_eventid', 'get_v2_workflows_dlq_dlqid', 'delete_v2_workflows_dlq_dlqid', 'get_v2_workflows_logs',
|
|
118
|
+
],
|
|
119
|
+
resources: {
|
|
120
|
+
// the message is not a resource of either document (GET /v2/messages/{messageId} answers `Message`, a view): its
|
|
121
|
+
// state is what its log answers
|
|
122
|
+
Message: { storedAs: 'qstash_message', idPrefix: 'msg_', state: { state: messageState } },
|
|
123
|
+
// a queue is made by its first enqueue or its upsert and holds its messages in order, paused or not
|
|
124
|
+
Queue: { storedAs: 'qstash_queue', idPrefix: '' },
|
|
125
|
+
Schedule: { storedAs: 'qstash_schedule', idPrefix: 'scd_', state: { isPaused: schedulePaused } },
|
|
126
|
+
// a URL group (a topic): its endpoints, keyed by its account and name
|
|
127
|
+
URLGroup: { storedAs: 'qstash_url_group', idPrefix: '' },
|
|
128
|
+
DLQMessage: { storedAs: 'qstash_dlq', idPrefix: '' },
|
|
129
|
+
WorkflowRun: { storedAs: 'workflow_run', idPrefix: 'wfr_', state: { workflowState: runState } },
|
|
130
|
+
WorkflowDLQMessage: { storedAs: 'workflow_dlq', idPrefix: '' },
|
|
131
|
+
// a waiter is present while a run waits on its event id, and gone when notified, timed out or canceled
|
|
132
|
+
Waiter: { storedAs: 'workflow_waiter', idPrefix: '' },
|
|
133
|
+
},
|
|
134
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { SemanticsContext } from '@volter/world-core';
|
|
2
|
+
export declare const ACCOUNT = "_qstash_account";
|
|
3
|
+
/** The account a token no person was shown belongs to. */
|
|
4
|
+
export declare const WORLD_ACCOUNT = "world";
|
|
5
|
+
/** An account's token and signing keys as they are now. */
|
|
6
|
+
export declare function accountOf(ctx: SemanticsContext, owner: string): {
|
|
7
|
+
owner: string;
|
|
8
|
+
token: string;
|
|
9
|
+
current: string;
|
|
10
|
+
next: string;
|
|
11
|
+
};
|
|
12
|
+
/** The console's Reset token: the old token is refused from now on. */
|
|
13
|
+
export declare function resetToken(ctx: SemanticsContext, owner: string): Promise<string>;
|
|
14
|
+
/** Roll the signing keys: the next key becomes the current one, and a new next key is made. */
|
|
15
|
+
export declare function rollKeys(ctx: SemanticsContext, owner: string): Promise<{
|
|
16
|
+
current: string;
|
|
17
|
+
next: string;
|
|
18
|
+
}>;
|
|
19
|
+
/** The token a request presents: `Authorization: Bearer <token>` or `?qstash_token=` (bearerAuth, bearerAuthQuery). */
|
|
20
|
+
export declare function tokenOf(request: Request): string | undefined;
|
|
21
|
+
/** The account a token names: a person's current token names their account; a token a person was shown before a reset
|
|
22
|
+
* is refused; any other is the World's own account's. */
|
|
23
|
+
export declare function ownerOfToken(ctx: SemanticsContext, token: string): string | 'refused';
|
|
24
|
+
/** The lane's gate: the account a request acts as, or the refusal it answers. */
|
|
25
|
+
export declare function gate(ctx: SemanticsContext, request: Request): {
|
|
26
|
+
owner: string;
|
|
27
|
+
} | {
|
|
28
|
+
refused: Response;
|
|
29
|
+
};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// A QStash account: its token and its two signing keys. Every Upstash account has QStash; the console's QStash page
|
|
2
|
+
// shows its `QSTASH_TOKEN` and its `QSTASH_CURRENT_SIGNING_KEY` and `QSTASH_NEXT_SIGNING_KEY` ("copy the `QSTASH_TOKEN`
|
|
3
|
+
// from the **Quickstart** section", https://upstash.com/docs/qstash/overall/getstarted) and resets the token ("Resetting
|
|
4
|
+
// your token will invalidate your current token and all future requests with the old token will be rejected",
|
|
5
|
+
// https://upstash.com/docs/qstash/howto/reset-token). Rolling the keys makes the next key the current one and a new next
|
|
6
|
+
// ("currentKey = nextKey / nextKey = generateNewKey()", https://upstash.com/docs/qstash/howto/roll-signing-keys).
|
|
7
|
+
//
|
|
8
|
+
// A person's account is their console email (the api lane's sign-in, ../../../api/src/screens/session.tsx). A token the
|
|
9
|
+
// console never showed is the World's own account's, as every token is for the pack root's Redis: an application pointed
|
|
10
|
+
// at the World (Dub) carries whatever token its configuration holds, and the World starts the twin with the signing keys
|
|
11
|
+
// it gave the application (QSTASH_CURRENT_SIGNING_KEY, QSTASH_NEXT_SIGNING_KEY).
|
|
12
|
+
//
|
|
13
|
+
// Where the documentation stops and the twin decides: a token has the form the local development page prints
|
|
14
|
+
// (base64 of `{"UserID":…,"Password":…}`) and a key the form it prints (`sig_` and 28 characters); both are derived from the
|
|
15
|
+
// account and how many times it was reset or rolled, so a World shows the same values every run. A request with no token
|
|
16
|
+
// answers 401 {"error": "Unauthorized"} (the documents' 401 is "Unauthorized"); a reset token answers the same.
|
|
17
|
+
import { createHash } from 'node:crypto';
|
|
18
|
+
export const ACCOUNT = '_qstash_account';
|
|
19
|
+
/** The account a token no person was shown belongs to. */
|
|
20
|
+
export const WORLD_ACCOUNT = 'world';
|
|
21
|
+
const hex = (s) => createHash('sha256').update(s).digest('hex');
|
|
22
|
+
const ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
|
23
|
+
function tokenAt(owner, generation) {
|
|
24
|
+
const h = hex(`qstash-token:${owner}:${generation}`);
|
|
25
|
+
const userId = `${h.slice(0, 8)}-${h.slice(8, 12)}-4${h.slice(13, 16)}-8${h.slice(17, 20)}-${h.slice(20, 32)}`;
|
|
26
|
+
return Buffer.from(JSON.stringify({ UserID: userId, Password: h.slice(32, 64) }), 'utf8').toString('base64');
|
|
27
|
+
}
|
|
28
|
+
function keyAt(owner, n) {
|
|
29
|
+
if (owner === WORLD_ACCOUNT && n === 0 && process.env.QSTASH_CURRENT_SIGNING_KEY)
|
|
30
|
+
return process.env.QSTASH_CURRENT_SIGNING_KEY;
|
|
31
|
+
if (owner === WORLD_ACCOUNT && n === 1 && process.env.QSTASH_NEXT_SIGNING_KEY)
|
|
32
|
+
return process.env.QSTASH_NEXT_SIGNING_KEY;
|
|
33
|
+
const h = hex(`qstash-signing-key:${owner}:${n}`);
|
|
34
|
+
let out = '';
|
|
35
|
+
for (let i = 0; out.length < 28; i += 1)
|
|
36
|
+
out += ALPHABET[parseInt(h.slice((i * 2) % 62, (i * 2) % 62 + 2), 16) % ALPHABET.length];
|
|
37
|
+
return `sig_${out}`;
|
|
38
|
+
}
|
|
39
|
+
/** How many times an account's token was reset and its keys rolled. */
|
|
40
|
+
function countsOf(ctx, owner) {
|
|
41
|
+
const row = ctx.rowsRaw(ACCOUNT).find((a) => a.id === owner);
|
|
42
|
+
return { resets: Number(row?.resets ?? 0), rolls: Number(row?.rolls ?? 0) };
|
|
43
|
+
}
|
|
44
|
+
/** An account's token and signing keys as they are now. */
|
|
45
|
+
export function accountOf(ctx, owner) {
|
|
46
|
+
const { resets, rolls } = countsOf(ctx, owner);
|
|
47
|
+
return { owner, token: tokenAt(owner, resets), current: keyAt(owner, rolls), next: keyAt(owner, rolls + 1) };
|
|
48
|
+
}
|
|
49
|
+
/** The console's Reset token: the old token is refused from now on. */
|
|
50
|
+
export async function resetToken(ctx, owner) {
|
|
51
|
+
const { resets, rolls } = countsOf(ctx, owner);
|
|
52
|
+
await ctx.record(ACCOUNT, { resets: resets + 1, rolls }, owner);
|
|
53
|
+
return tokenAt(owner, resets + 1);
|
|
54
|
+
}
|
|
55
|
+
/** Roll the signing keys: the next key becomes the current one, and a new next key is made. */
|
|
56
|
+
export async function rollKeys(ctx, owner) {
|
|
57
|
+
const { resets, rolls } = countsOf(ctx, owner);
|
|
58
|
+
await ctx.record(ACCOUNT, { resets, rolls: rolls + 1 }, owner);
|
|
59
|
+
return { current: keyAt(owner, rolls + 1), next: keyAt(owner, rolls + 2) };
|
|
60
|
+
}
|
|
61
|
+
/** The token a request presents: `Authorization: Bearer <token>` or `?qstash_token=` (bearerAuth, bearerAuthQuery). */
|
|
62
|
+
export function tokenOf(request) {
|
|
63
|
+
const bearer = /^bearer\s+(\S+)\s*$/i.exec(request.headers.get('authorization') ?? '');
|
|
64
|
+
const query = new URL(request.url).searchParams.get('qstash_token');
|
|
65
|
+
return bearer?.[1] ?? (query && query.trim() !== '' ? query.trim() : undefined);
|
|
66
|
+
}
|
|
67
|
+
/** The account a token names: a person's current token names their account; a token a person was shown before a reset
|
|
68
|
+
* is refused; any other is the World's own account's. */
|
|
69
|
+
export function ownerOfToken(ctx, token) {
|
|
70
|
+
const people = ctx.rowsRaw('_person').map((p) => ({ email: String(p.email), resets: countsOf(ctx, String(p.email)).resets }));
|
|
71
|
+
const current = people.find((p) => token === tokenAt(p.email, p.resets));
|
|
72
|
+
if (current)
|
|
73
|
+
return current.email;
|
|
74
|
+
return people.some((p) => resetAway(p, token)) ? 'refused' : WORLD_ACCOUNT;
|
|
75
|
+
}
|
|
76
|
+
/** Whether a token is one a person held before they reset it: "Resetting your token will invalidate your current token and
|
|
77
|
+
* all future requests with the old token will be rejected." (https://upstash.com/docs/qstash/howto/reset-token) */
|
|
78
|
+
function resetAway(p, token) {
|
|
79
|
+
for (let g = 0; g < p.resets; g += 1)
|
|
80
|
+
if (token === tokenAt(p.email, g))
|
|
81
|
+
return true;
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
/** The lane's gate: the account a request acts as, or the refusal it answers. */
|
|
85
|
+
export function gate(ctx, request) {
|
|
86
|
+
const token = tokenOf(request);
|
|
87
|
+
const owner = token ? ownerOfToken(ctx, token) : 'refused';
|
|
88
|
+
if (owner === 'refused')
|
|
89
|
+
return { refused: Response.json({ error: 'Unauthorized' }, { status: 401 }) };
|
|
90
|
+
return { owner };
|
|
91
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// A QStash account: its token and its two signing keys. Every Upstash account has QStash; the console's QStash page
|
|
2
|
+
// shows its `QSTASH_TOKEN` and its `QSTASH_CURRENT_SIGNING_KEY` and `QSTASH_NEXT_SIGNING_KEY` ("copy the `QSTASH_TOKEN`
|
|
3
|
+
// from the **Quickstart** section", https://upstash.com/docs/qstash/overall/getstarted) and resets the token ("Resetting
|
|
4
|
+
// your token will invalidate your current token and all future requests with the old token will be rejected",
|
|
5
|
+
// https://upstash.com/docs/qstash/howto/reset-token). Rolling the keys makes the next key the current one and a new next
|
|
6
|
+
// ("currentKey = nextKey / nextKey = generateNewKey()", https://upstash.com/docs/qstash/howto/roll-signing-keys).
|
|
7
|
+
//
|
|
8
|
+
// A person's account is their console email (the api lane's sign-in, ../../../api/src/screens/session.tsx). A token the
|
|
9
|
+
// console never showed is the World's own account's, as every token is for the pack root's Redis: an application pointed
|
|
10
|
+
// at the World (Dub) carries whatever token its configuration holds, and the World starts the twin with the signing keys
|
|
11
|
+
// it gave the application (QSTASH_CURRENT_SIGNING_KEY, QSTASH_NEXT_SIGNING_KEY).
|
|
12
|
+
//
|
|
13
|
+
// Where the documentation stops and the twin decides: a token has the form the local development page prints
|
|
14
|
+
// (base64 of `{"UserID":…,"Password":…}`) and a key the form it prints (`sig_` and 28 characters); both are derived from the
|
|
15
|
+
// account and how many times it was reset or rolled, so a World shows the same values every run. A request with no token
|
|
16
|
+
// answers 401 {"error": "Unauthorized"} (the documents' 401 is "Unauthorized"); a reset token answers the same.
|
|
17
|
+
import { createHash } from 'node:crypto';
|
|
18
|
+
import type { SemanticsContext } from '@volter/world-core';
|
|
19
|
+
import type { Row } from './shared.ts';
|
|
20
|
+
|
|
21
|
+
export const ACCOUNT = '_qstash_account';
|
|
22
|
+
/** The account a token no person was shown belongs to. */
|
|
23
|
+
export const WORLD_ACCOUNT = 'world';
|
|
24
|
+
|
|
25
|
+
const hex = (s: string): string => createHash('sha256').update(s).digest('hex');
|
|
26
|
+
const ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
|
27
|
+
|
|
28
|
+
function tokenAt(owner: string, generation: number): string {
|
|
29
|
+
const h = hex(`qstash-token:${owner}:${generation}`);
|
|
30
|
+
const userId = `${h.slice(0, 8)}-${h.slice(8, 12)}-4${h.slice(13, 16)}-8${h.slice(17, 20)}-${h.slice(20, 32)}`;
|
|
31
|
+
return Buffer.from(JSON.stringify({ UserID: userId, Password: h.slice(32, 64) }), 'utf8').toString('base64');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function keyAt(owner: string, n: number): string {
|
|
35
|
+
if (owner === WORLD_ACCOUNT && n === 0 && process.env.QSTASH_CURRENT_SIGNING_KEY) return process.env.QSTASH_CURRENT_SIGNING_KEY;
|
|
36
|
+
if (owner === WORLD_ACCOUNT && n === 1 && process.env.QSTASH_NEXT_SIGNING_KEY) return process.env.QSTASH_NEXT_SIGNING_KEY;
|
|
37
|
+
const h = hex(`qstash-signing-key:${owner}:${n}`);
|
|
38
|
+
let out = '';
|
|
39
|
+
for (let i = 0; out.length < 28; i += 1) out += ALPHABET[parseInt(h.slice((i * 2) % 62, (i * 2) % 62 + 2), 16) % ALPHABET.length];
|
|
40
|
+
return `sig_${out}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** How many times an account's token was reset and its keys rolled. */
|
|
44
|
+
function countsOf(ctx: SemanticsContext, owner: string): { resets: number; rolls: number } {
|
|
45
|
+
const row = ctx.rowsRaw(ACCOUNT).find((a) => a.id === owner) as Row | undefined;
|
|
46
|
+
return { resets: Number(row?.resets ?? 0), rolls: Number(row?.rolls ?? 0) };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** An account's token and signing keys as they are now. */
|
|
50
|
+
export function accountOf(ctx: SemanticsContext, owner: string): { owner: string; token: string; current: string; next: string } {
|
|
51
|
+
const { resets, rolls } = countsOf(ctx, owner);
|
|
52
|
+
return { owner, token: tokenAt(owner, resets), current: keyAt(owner, rolls), next: keyAt(owner, rolls + 1) };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The console's Reset token: the old token is refused from now on. */
|
|
56
|
+
export async function resetToken(ctx: SemanticsContext, owner: string): Promise<string> {
|
|
57
|
+
const { resets, rolls } = countsOf(ctx, owner);
|
|
58
|
+
await ctx.record(ACCOUNT, { resets: resets + 1, rolls }, owner);
|
|
59
|
+
return tokenAt(owner, resets + 1);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Roll the signing keys: the next key becomes the current one, and a new next key is made. */
|
|
63
|
+
export async function rollKeys(ctx: SemanticsContext, owner: string): Promise<{ current: string; next: string }> {
|
|
64
|
+
const { resets, rolls } = countsOf(ctx, owner);
|
|
65
|
+
await ctx.record(ACCOUNT, { resets, rolls: rolls + 1 }, owner);
|
|
66
|
+
return { current: keyAt(owner, rolls + 1), next: keyAt(owner, rolls + 2) };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The token a request presents: `Authorization: Bearer <token>` or `?qstash_token=` (bearerAuth, bearerAuthQuery). */
|
|
70
|
+
export function tokenOf(request: Request): string | undefined {
|
|
71
|
+
const bearer = /^bearer\s+(\S+)\s*$/i.exec(request.headers.get('authorization') ?? '');
|
|
72
|
+
const query = new URL(request.url).searchParams.get('qstash_token');
|
|
73
|
+
return bearer?.[1] ?? (query && query.trim() !== '' ? query.trim() : undefined);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The account a token names: a person's current token names their account; a token a person was shown before a reset
|
|
77
|
+
* is refused; any other is the World's own account's. */
|
|
78
|
+
export function ownerOfToken(ctx: SemanticsContext, token: string): string | 'refused' {
|
|
79
|
+
const people = ctx.rowsRaw('_person').map((p) => ({ email: String(p.email), resets: countsOf(ctx, String(p.email)).resets }));
|
|
80
|
+
const current = people.find((p) => token === tokenAt(p.email, p.resets));
|
|
81
|
+
if (current) return current.email;
|
|
82
|
+
return people.some((p) => resetAway(p, token)) ? 'refused' : WORLD_ACCOUNT;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Whether a token is one a person held before they reset it: "Resetting your token will invalidate your current token and
|
|
86
|
+
* all future requests with the old token will be rejected." (https://upstash.com/docs/qstash/howto/reset-token) */
|
|
87
|
+
function resetAway(p: { email: string; resets: number }, token: string): boolean {
|
|
88
|
+
for (let g = 0; g < p.resets; g += 1) if (token === tokenAt(p.email, g)) return true;
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The lane's gate: the account a request acts as, or the refusal it answers. */
|
|
93
|
+
export function gate(ctx: SemanticsContext, request: Request): { owner: string } | { refused: Response } {
|
|
94
|
+
const token = tokenOf(request);
|
|
95
|
+
const owner = token ? ownerOfToken(ctx, token) : 'refused';
|
|
96
|
+
if (owner === 'refused') return { refused: Response.json({ error: 'Unauthorized' }, { status: 401 }) };
|
|
97
|
+
return { owner };
|
|
98
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { SemanticsContext } from '@volter/world-core';
|
|
2
|
+
import { type Row } from './shared.js';
|
|
3
|
+
/** The World's word on what a destination answers, stored by `POST /_twin/destinations` (../doors.ts). */
|
|
4
|
+
export declare const DESTINATION = "_qstash_destination";
|
|
5
|
+
/** What a destination received, read at `GET /_twin/deliveries?to=` (../doors.ts). */
|
|
6
|
+
export declare const DELIVERY = "_qstash_delivery";
|
|
7
|
+
/** An `Upstash-Retry-Delay` expression's value in milliseconds: numbers, `retried`, `+ - * /`, parentheses and `pow sqrt
|
|
8
|
+
* abs exp floor ceil round min max` (the retry page's Custom Retry Delay); anything else is undefined. */
|
|
9
|
+
export declare function retryDelayMs(expr: string, retried: number): number | undefined;
|
|
10
|
+
/** What a catch-up did: the deliveries it made, by outcome (the drain door answers it). */
|
|
11
|
+
export type CatchUpReport = {
|
|
12
|
+
delivered: Row[];
|
|
13
|
+
retried: Row[];
|
|
14
|
+
failed: Row[];
|
|
15
|
+
};
|
|
16
|
+
/** Run everything due by the World's instant, in order, each at its own moment. */
|
|
17
|
+
export declare function catchUp(ctx: SemanticsContext): Promise<CatchUpReport>;
|