@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.
Files changed (162) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +202 -0
  3. package/api/src/fetch.ts +54 -0
  4. package/api/src/generated/surface.gen.json +1 -0
  5. package/api/src/generated/ui.gen.json +1 -0
  6. package/api/src/index.ts +19 -0
  7. package/api/src/key-gate.ts +30 -0
  8. package/api/src/manifest.ts +103 -0
  9. package/api/src/screens/developer-api.tsx +106 -0
  10. package/api/src/screens/qstash.tsx +99 -0
  11. package/api/src/screens/session.tsx +125 -0
  12. package/api/src/screens/teams.tsx +114 -0
  13. package/api/src/semantics/backups.ts +90 -0
  14. package/api/src/semantics/index.ts +191 -0
  15. package/api/src/semantics/shared.ts +42 -0
  16. package/api/src/semantics/teams.ts +108 -0
  17. package/api/src/semantics/time.ts +40 -0
  18. package/dist/api/src/fetch.d.ts +15 -0
  19. package/dist/api/src/fetch.js +44 -0
  20. package/dist/api/src/fetch.ts +54 -0
  21. package/dist/api/src/generated/surface.gen.json +1 -0
  22. package/dist/api/src/generated/ui.gen.json +1 -0
  23. package/dist/api/src/index.ts +19 -0
  24. package/dist/api/src/key-gate.d.ts +3 -0
  25. package/dist/api/src/key-gate.js +30 -0
  26. package/dist/api/src/key-gate.ts +30 -0
  27. package/dist/api/src/manifest.d.ts +2 -0
  28. package/dist/api/src/manifest.js +81 -0
  29. package/dist/api/src/manifest.ts +103 -0
  30. package/dist/api/src/screens/developer-api.d.ts +3 -0
  31. package/dist/api/src/screens/developer-api.js +101 -0
  32. package/dist/api/src/screens/developer-api.tsx +106 -0
  33. package/dist/api/src/screens/qstash.d.ts +3 -0
  34. package/dist/api/src/screens/qstash.js +92 -0
  35. package/dist/api/src/screens/qstash.tsx +99 -0
  36. package/dist/api/src/screens/session.d.ts +9 -0
  37. package/dist/api/src/screens/session.js +118 -0
  38. package/dist/api/src/screens/session.tsx +125 -0
  39. package/dist/api/src/screens/teams.d.ts +3 -0
  40. package/dist/api/src/screens/teams.js +99 -0
  41. package/dist/api/src/screens/teams.tsx +114 -0
  42. package/dist/api/src/semantics/backups.d.ts +7 -0
  43. package/dist/api/src/semantics/backups.js +75 -0
  44. package/dist/api/src/semantics/backups.ts +90 -0
  45. package/dist/api/src/semantics/index.d.ts +10 -0
  46. package/dist/api/src/semantics/index.js +191 -0
  47. package/dist/api/src/semantics/index.ts +191 -0
  48. package/dist/api/src/semantics/shared.d.ts +21 -0
  49. package/dist/api/src/semantics/shared.js +34 -0
  50. package/dist/api/src/semantics/shared.ts +42 -0
  51. package/dist/api/src/semantics/teams.d.ts +13 -0
  52. package/dist/api/src/semantics/teams.js +100 -0
  53. package/dist/api/src/semantics/teams.ts +108 -0
  54. package/dist/api/src/semantics/time.d.ts +2 -0
  55. package/dist/api/src/semantics/time.js +34 -0
  56. package/dist/api/src/semantics/time.ts +40 -0
  57. package/dist/qstash/src/doors.d.ts +6 -0
  58. package/dist/qstash/src/doors.js +33 -0
  59. package/dist/qstash/src/doors.ts +51 -0
  60. package/dist/qstash/src/egress.d.ts +7 -0
  61. package/dist/qstash/src/egress.js +66 -0
  62. package/dist/qstash/src/egress.ts +58 -0
  63. package/dist/qstash/src/fetch.d.ts +7 -0
  64. package/dist/qstash/src/fetch.js +48 -0
  65. package/dist/qstash/src/fetch.ts +46 -0
  66. package/dist/qstash/src/generated/surface.gen.json +1 -0
  67. package/dist/qstash/src/generated/ui.gen.json +1 -0
  68. package/dist/qstash/src/index.ts +35 -0
  69. package/dist/qstash/src/manifest.d.ts +10 -0
  70. package/dist/qstash/src/manifest.js +105 -0
  71. package/dist/qstash/src/manifest.ts +134 -0
  72. package/dist/qstash/src/semantics/account.d.ts +29 -0
  73. package/dist/qstash/src/semantics/account.js +91 -0
  74. package/dist/qstash/src/semantics/account.ts +98 -0
  75. package/dist/qstash/src/semantics/delivery.d.ts +17 -0
  76. package/dist/qstash/src/semantics/delivery.js +274 -0
  77. package/dist/qstash/src/semantics/delivery.ts +264 -0
  78. package/dist/qstash/src/semantics/dlq.d.ts +4 -0
  79. package/dist/qstash/src/semantics/dlq.js +51 -0
  80. package/dist/qstash/src/semantics/dlq.ts +61 -0
  81. package/dist/qstash/src/semantics/index.d.ts +2 -0
  82. package/dist/qstash/src/semantics/index.js +10 -0
  83. package/dist/qstash/src/semantics/index.ts +13 -0
  84. package/dist/qstash/src/semantics/keys.d.ts +2 -0
  85. package/dist/qstash/src/semantics/keys.js +9 -0
  86. package/dist/qstash/src/semantics/keys.ts +14 -0
  87. package/dist/qstash/src/semantics/messages.d.ts +74 -0
  88. package/dist/qstash/src/semantics/messages.js +233 -0
  89. package/dist/qstash/src/semantics/messages.ts +249 -0
  90. package/dist/qstash/src/semantics/queues.d.ts +2 -0
  91. package/dist/qstash/src/semantics/queues.js +60 -0
  92. package/dist/qstash/src/semantics/queues.ts +66 -0
  93. package/dist/qstash/src/semantics/schedules.d.ts +19 -0
  94. package/dist/qstash/src/semantics/schedules.js +125 -0
  95. package/dist/qstash/src/semantics/schedules.ts +132 -0
  96. package/dist/qstash/src/semantics/shared.d.ts +45 -0
  97. package/dist/qstash/src/semantics/shared.js +115 -0
  98. package/dist/qstash/src/semantics/shared.ts +121 -0
  99. package/dist/qstash/src/semantics/urlgroups.d.ts +2 -0
  100. package/dist/qstash/src/semantics/urlgroups.js +58 -0
  101. package/dist/qstash/src/semantics/urlgroups.ts +69 -0
  102. package/dist/qstash/src/semantics/workflows.d.ts +44 -0
  103. package/dist/qstash/src/semantics/workflows.js +379 -0
  104. package/dist/qstash/src/semantics/workflows.ts +401 -0
  105. package/dist/qstash/src/signing.d.ts +4 -0
  106. package/dist/qstash/src/signing.js +16 -0
  107. package/dist/qstash/src/signing.ts +19 -0
  108. package/dist/src/cli.d.ts +2 -0
  109. package/dist/src/cli.js +35 -0
  110. package/dist/src/generated/surface.gen.json +1 -0
  111. package/dist/src/index.d.ts +18 -0
  112. package/dist/src/index.js +124 -0
  113. package/dist/src/manifest.d.ts +14 -0
  114. package/dist/src/manifest.js +8 -0
  115. package/dist/src/upstash-budget.d.ts +85 -0
  116. package/dist/src/upstash-budget.js +440 -0
  117. package/dist/src/upstash-capabilities.d.ts +4 -0
  118. package/dist/src/upstash-capabilities.js +1286 -0
  119. package/dist/src/upstash-conformance.d.ts +7 -0
  120. package/dist/src/upstash-conformance.js +119 -0
  121. package/dist/src/upstash-connector.d.ts +115 -0
  122. package/dist/src/upstash-connector.js +309 -0
  123. package/dist/src/upstash-lua.d.ts +140 -0
  124. package/dist/src/upstash-lua.js +1229 -0
  125. package/dist/src/upstash-server.d.ts +29 -0
  126. package/dist/src/upstash-server.js +81 -0
  127. package/dist/src/upstash-store.d.ts +114 -0
  128. package/dist/src/upstash-store.js +1663 -0
  129. package/dist/src/upstash-twin.d.ts +73 -0
  130. package/dist/src/upstash-twin.js +437 -0
  131. package/package.json +59 -0
  132. package/qstash/src/doors.ts +51 -0
  133. package/qstash/src/egress.ts +58 -0
  134. package/qstash/src/fetch.ts +46 -0
  135. package/qstash/src/generated/surface.gen.json +1 -0
  136. package/qstash/src/generated/ui.gen.json +1 -0
  137. package/qstash/src/index.ts +35 -0
  138. package/qstash/src/manifest.ts +134 -0
  139. package/qstash/src/semantics/account.ts +98 -0
  140. package/qstash/src/semantics/delivery.ts +264 -0
  141. package/qstash/src/semantics/dlq.ts +61 -0
  142. package/qstash/src/semantics/index.ts +13 -0
  143. package/qstash/src/semantics/keys.ts +14 -0
  144. package/qstash/src/semantics/messages.ts +249 -0
  145. package/qstash/src/semantics/queues.ts +66 -0
  146. package/qstash/src/semantics/schedules.ts +132 -0
  147. package/qstash/src/semantics/shared.ts +121 -0
  148. package/qstash/src/semantics/urlgroups.ts +69 -0
  149. package/qstash/src/semantics/workflows.ts +401 -0
  150. package/qstash/src/signing.ts +19 -0
  151. package/src/cli.ts +36 -0
  152. package/src/generated/surface.gen.json +1 -0
  153. package/src/index.ts +203 -0
  154. package/src/manifest.ts +26 -0
  155. package/src/upstash-budget.ts +486 -0
  156. package/src/upstash-capabilities.ts +1418 -0
  157. package/src/upstash-conformance.ts +131 -0
  158. package/src/upstash-connector.ts +340 -0
  159. package/src/upstash-lua.ts +1120 -0
  160. package/src/upstash-server.ts +103 -0
  161. package/src/upstash-store.ts +1437 -0
  162. 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>;