@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,249 @@
1
+ // Publishing: `POST /v2/publish/{destination}`, `POST /v2/enqueue/{queueName}/{destination}` and `POST /v2/batch`, and a
2
+ // message's read and cancel. A published message is accepted at once and delivered by the catch-up when it falls due
3
+ // (delivery.ts). A message that carries `Upstash-Workflow-RunId` is a workflow's (workflows.ts): the first of a run starts
4
+ // it, and each after is a step the run's route sends back. The headers a publish reads are the qstash document's
5
+ // (`/v2/publish/{destination}`'s header parameters); the lane reads the ones a customer of its life and Dub send.
6
+ import { createHash } from 'node:crypto';
7
+ import type { Semantics, SemanticsContext } from '@volter/world-core';
8
+ import { gate } from './account.ts';
9
+ import { acceptWorkflowMessage, isWorkflowMessage } from './workflows.ts';
10
+ import { durationSeconds, everHeld, headersOf, lowerKeys, MESSAGE_FIELDS, mintId, multiValue, nowMs, QUEUE_NAME, refuse, vendorFields, type Row } from './shared.ts';
11
+
12
+ /** How QStash retries by default: "By default, we retry a failed delivery 3 times." (https://upstash.com/docs/qstash/features/retry) */
13
+ export const DEFAULT_RETRIES = 3;
14
+
15
+ /** The account the request acts as (the lane's gate has admitted it). */
16
+ export function ownerFor(ctx: SemanticsContext): string {
17
+ const g = gate(ctx, ctx.call.request);
18
+ return 'owner' in g ? g.owner : 'world';
19
+ }
20
+
21
+ /** One message as published: its destination, its headers (lower-cased) and its body, and the queue it is enqueued in. */
22
+ export type Publication = { destination: string; headers: Record<string, string>; body: string; queue?: string | undefined };
23
+
24
+ /** What a publish answers for one message: its id, and whether it was a duplicate. */
25
+ export type Accepted = { messageId: string; deduplicated?: boolean } | Array<{ messageId: string; url: string; deduplicated?: boolean }> | { error: string; status: number };
26
+
27
+ /** The `Upstash-Forward-*` headers, their prefix stripped: what the destination receives. */
28
+ export function forwarded(headers: Record<string, string>): Record<string, string> {
29
+ const out: Record<string, string> = {};
30
+ for (const [k, v] of Object.entries(headers)) if (k.startsWith('upstash-forward-')) out[k.slice('upstash-forward-'.length)] = v;
31
+ return out;
32
+ }
33
+
34
+ /** A message's `header`: the headers sent to the API, its Content-Type and the ones it forwards, each name in its canonical
35
+ * form, as the message example of https://upstash.com/docs/qstash/overall/llms-txt answers them
36
+ * (`"header": { "Content-Type": ["application/json"] }`). */
37
+ export function sentHeader(headers: Record<string, string>): Record<string, string[]> {
38
+ const canonical = (k: string): string => k.split('-').map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()).join('-');
39
+ const sent = { ...(headers['content-type'] ? { 'content-type': headers['content-type'] } : {}), ...forwarded(headers) };
40
+ return multiValue(Object.fromEntries(Object.entries(sent).map(([k, v]) => [canonical(k), v])));
41
+ }
42
+
43
+ /** `Upstash-Flow-Control-Value`'s settings ("parallelism=15, rate=10, period=1m"). */
44
+ function flowControl(value: string | undefined): Row {
45
+ const out: Row = {};
46
+ for (const part of (value ?? '').split(',')) {
47
+ const [k, v] = part.split('=').map((s) => s.trim());
48
+ if (k === 'parallelism' || k === 'rate') out[k] = Number(v);
49
+ if (k === 'period') out.period = durationSeconds(v) ?? v;
50
+ }
51
+ return out;
52
+ }
53
+
54
+ /** Store one message: the vendor's Message fields, its state (CREATED) and what the lane keeps to deliver it. */
55
+ export async function storeMessage(ctx: SemanticsContext, p: {
56
+ url: string; headers: Record<string, string>; body: string; owner: string; queue?: string | undefined; scheduleId?: string | undefined;
57
+ callbackOf?: string | undefined; run?: { id: string; seq: number; call: string; initial: boolean } | undefined; retries?: number | undefined; due?: number | undefined;
58
+ topic?: { name: string; endpoint?: string | undefined } | undefined;
59
+ }): Promise<string> {
60
+ const h = p.headers;
61
+ const ordinal = everHeld(ctx, 'Message');
62
+ const id = mintId(ctx, 'msg_', 'message', ordinal);
63
+ const created = nowMs(ctx);
64
+ const notBefore = h['upstash-not-before'] !== undefined && /^\d+$/.test(h['upstash-not-before']) ? Number(h['upstash-not-before']) * 1000 : undefined;
65
+ const delay = durationSeconds(h['upstash-delay']) ?? 0;
66
+ const due = p.due ?? notBefore ?? created + delay * 1000;
67
+ const retries = p.retries ?? (h['upstash-retries'] !== undefined && /^\d+$/.test(h['upstash-retries']) ? Number(h['upstash-retries']) : DEFAULT_RETRIES);
68
+ const fields: Row = {
69
+ messageId: id, url: p.url, method: h['upstash-method'] ?? 'POST', header: sentHeader(h), body: p.body, maxRetries: retries,
70
+ notBefore: due, createdAt: created,
71
+ ...(h['upstash-callback'] ? { callback: h['upstash-callback'] } : {}),
72
+ ...(h['upstash-failure-callback'] ? { failureCallback: h['upstash-failure-callback'] } : {}),
73
+ ...(p.queue ? { queueName: p.queue } : {}),
74
+ ...(p.scheduleId ? { scheduleId: p.scheduleId } : {}),
75
+ ...labelsOf(h),
76
+ ...(p.topic ? { topicName: p.topic.name, ...(p.topic.endpoint ? { endpointName: p.topic.endpoint } : {}) } : {}),
77
+ ...(h['upstash-flow-control-key'] ? { flowControlKey: h['upstash-flow-control-key'], ...flowControl(h['upstash-flow-control-value']) } : {}),
78
+ state: 'CREATED',
79
+ _owner: p.owner, _due: due, _attempts: 0, _ordinal: ordinal, _published: h,
80
+ ...(h['content-type'] ? { _content_type: h['content-type'] } : {}),
81
+ ...(h['upstash-retry-delay'] ? { _retry_delay: h['upstash-retry-delay'] } : {}),
82
+ ...(h['upstash-deduplication-id'] ? { _dedup: h['upstash-deduplication-id'] } : h['upstash-content-based-deduplication'] === 'true' ? { _dedup: contentKey(p.url, p.body, h) } : {}),
83
+ ...(h['upstash-timeout'] ? { _timeout: durationSeconds(h['upstash-timeout']) ?? h['upstash-timeout'] } : {}),
84
+ ...(h['upstash-redact-fields'] ? { _redact: h['upstash-redact-fields'] } : {}),
85
+ ...(p.callbackOf ? { _callback_of: p.callbackOf } : {}),
86
+ ...(p.run ? { _run: p.run.id, _seq: p.run.seq, _call: p.run.call, _initial: p.run.initial } : {}),
87
+ };
88
+ await ctx.write('Message', id, fields, 'message.create');
89
+ return id;
90
+ }
91
+
92
+ /** A message's labels: `Upstash-Label` carries one, or several joined with commas (@upstash/qstash's `serializeLabel`:
93
+ * `label.join(",")`); the Message schema answers the first as `label` and all as `labels`. */
94
+ export function labelsOf(h: Record<string, string>): Row {
95
+ const labels = (h['upstash-label'] ?? '').split(',').map((l) => l.trim()).filter(Boolean);
96
+ return labels.length ? { label: labels[0], labels } : {};
97
+ }
98
+
99
+ /** Content-based deduplication: "If you want to deduplicate messages automatically, you can set the
100
+ * `Upstash-Content-Based-Deduplication` header to `true`." (https://upstash.com/docs/qstash/features/deduplication). Where
101
+ * the page stops and the lane decides: a message's content is its destination, its body and the headers it forwards. */
102
+ function contentKey(url: string, body: string, h: Record<string, string>): string {
103
+ const fwd = Object.entries(h).filter(([k]) => k.startsWith('upstash-forward-')).sort();
104
+ return `content:${createHash('sha256').update(JSON.stringify([url, body, fwd])).digest('hex')}`;
105
+ }
106
+
107
+ /** "The deduplication window is 10 minutes. After that, messages with the same ID or content can be sent again."
108
+ * (https://upstash.com/docs/qstash/features/deduplication) */
109
+ const DEDUP_WINDOW_MS = 10 * 60_000;
110
+
111
+ /** Accept one plain message (no workflow): refuse a destination that is not a URL, answer a duplicate's first id, and
112
+ * make the queue a first enqueue names. "Messages can be deduplicated... In case a message is a duplicate, we will accept
113
+ * the request and return the messageID of the existing message." (https://upstash.com/docs/qstash/features/deduplication) */
114
+ export async function acceptPlain(ctx: SemanticsContext, p: Publication, owner: string): Promise<Accepted> {
115
+ if (p.queue !== undefined && !QUEUE_NAME.test(p.queue)) return { error: 'Queue name is invalid. Queue names can only contain alphanumeric characters, hyphens, periods, and underscores.', status: 400 };
116
+ if (!/^https?:\/\//.test(p.destination)) return acceptToUrlGroup(ctx, p, owner);
117
+ const dedup = p.headers['upstash-deduplication-id'] ?? (p.headers['upstash-content-based-deduplication'] === 'true' ? contentKey(p.destination, p.body, p.headers) : undefined);
118
+ if (dedup) {
119
+ const prior = ctx.rowsRaw('Message').find((m) => m._dedup === dedup && m._owner === owner && nowMs(ctx) - Number(m.createdAt) < DEDUP_WINDOW_MS);
120
+ if (prior) return { messageId: String(prior.messageId), deduplicated: true };
121
+ }
122
+ if (p.queue !== undefined) await ensureQueue(ctx, p.queue);
123
+ const messageId = await storeMessage(ctx, { url: p.destination, headers: p.headers, body: p.body, owner, queue: p.queue });
124
+ return { messageId };
125
+ }
126
+
127
+ /** A message to a URL group: "If the destination is a URL Group, a new message will be created for each endpoint in the
128
+ * group." (the spec's publish); the answer lists each endpoint's message and URL (`PublishToUrlGroupResponse`). A name the
129
+ * account has no URL group of answers 404 (the spec's publish 404 is the destination not found). */
130
+ export async function acceptToUrlGroup(ctx: SemanticsContext, p: Publication, owner: string): Promise<Accepted> {
131
+ const group = ctx.rowsRaw('URLGroup').find((g) => g.name === p.destination && g._owner === owner);
132
+ if (!group) return { error: `URL Group ${p.destination} not found`, status: 404 };
133
+ if (p.queue !== undefined) await ensureQueue(ctx, p.queue);
134
+ const out: Array<{ messageId: string; url: string }> = [];
135
+ for (const e of (group.endpoints ?? []) as Array<{ url: string; name?: string }>) {
136
+ const messageId = await storeMessage(ctx, { url: e.url, headers: p.headers, body: p.body, owner, queue: p.queue, topic: { name: String(group.name), endpoint: e.name } });
137
+ out.push({ messageId, url: e.url });
138
+ }
139
+ return out;
140
+ }
141
+
142
+ /** A queue a first enqueue names: "If the queue does not exist, it will be created automatically with default
143
+ * parallelism." (the spec's enqueue); a queue delivers in order, one message at a time
144
+ * (https://upstash.com/docs/qstash/features/queues). */
145
+ export async function ensureQueue(ctx: SemanticsContext, name: string): Promise<void> {
146
+ if (ctx.rowsRaw('Queue').some((q) => q.name === name)) return;
147
+ const at = nowMs(ctx);
148
+ await ctx.write('Queue', name, { name, createdAt: at, updatedAt: at, parallelism: 1, paused: false, lag: 0 }, 'queue.create');
149
+ }
150
+
151
+ async function accept(ctx: SemanticsContext, p: Publication, owner: string): Promise<Accepted> {
152
+ return invalidDestination(ctx, p, owner) ?? (isWorkflowMessage(p.headers) ? acceptWorkflowMessage(ctx, p, owner) : acceptPlain(ctx, p, owner));
153
+ }
154
+
155
+ /** "Destination can either be a valid URL where the message gets sent to, or a URL Group name." (the spec's publish): a
156
+ * URL that does not parse (a page's `https://<YOUR_WORKFLOW_ENDPOINT>/<YOUR-WORKFLOW-ROUTE>`) is refused 400, and a
157
+ * workflow's run sent to a name the account holds no URL group of answers the publish's 404 (a plain message's group is
158
+ * looked up by acceptToUrlGroup). */
159
+ function invalidDestination(ctx: SemanticsContext, p: Publication, owner: string): Accepted | undefined {
160
+ if (/^https?:\/\//i.test(p.destination)) return URL.canParse(p.destination) ? undefined : { error: `invalid destination url: ${p.destination}`, status: 400 };
161
+ const group = ctx.rowsRaw('URLGroup').some((g) => g.name === p.destination && g._owner === owner);
162
+ return isWorkflowMessage(p.headers) && !group ? { error: `URL Group ${p.destination} not found`, status: 404 } : undefined;
163
+ }
164
+
165
+ const answer = (ctx: SemanticsContext, a: Accepted): Response =>
166
+ !Array.isArray(a) && 'error' in a ? refuse(ctx, a.status, a.error) : ctx.reply(a, !Array.isArray(a) && a.deduplicated ? 202 : 200);
167
+
168
+ /** POST /v2/publish/{destination}. */
169
+ const publish: Semantics = async (ctx) => {
170
+ const destination = String(ctx.call.params.destination ?? '');
171
+ return answer(ctx, await accept(ctx, { destination, headers: headersOf(ctx), body: ctx.text ?? '' }, ownerFor(ctx)));
172
+ };
173
+
174
+ /** POST /v2/enqueue/{queueName}/{destination}. */
175
+ const enqueue: Semantics = async (ctx) => {
176
+ const destination = String(ctx.call.params.destination ?? '');
177
+ return answer(ctx, await accept(ctx, { destination, headers: headersOf(ctx), body: ctx.text ?? '', queue: String(ctx.call.params.queueName ?? '') }, ownerFor(ctx)));
178
+ };
179
+
180
+ /** POST /v2/batch: each entry `{destination, headers, body, queue}` is published as its own message, in order, and the
181
+ * answer lists each one's `PublishResponse`. Where the documents stop and the lane decides: an entry that cannot be
182
+ * published refuses the whole batch with its reason, before any later entry is published. */
183
+ const batch: Semantics = async (ctx) => {
184
+ if (!Array.isArray(ctx.body)) return refuse(ctx, 400, 'The request body must be a JSON array of messages.');
185
+ const owner = ownerFor(ctx);
186
+ const out: Row[] = [];
187
+ for (const entry of ctx.body as Row[]) {
188
+ const destination = typeof entry?.destination === 'string' ? entry.destination : '';
189
+ if (!destination) return refuse(ctx, 400, 'destination is required');
190
+ const body = typeof entry.body === 'string' ? entry.body : entry.body === undefined ? '' : JSON.stringify(entry.body);
191
+ const a = await accept(ctx, { destination, headers: lowerKeys(entry.headers as Row), body, ...(typeof entry.queue === 'string' ? { queue: entry.queue } : {}) }, owner);
192
+ if (!Array.isArray(a) && 'error' in a) return refuse(ctx, a.status, a.error);
193
+ out.push(a as Row);
194
+ }
195
+ return ctx.reply(out);
196
+ };
197
+
198
+ /** A message still to be delivered: "Messages are removed from the database shortly after they're delivered, so you will
199
+ * not be able to retrieve a message after." (the spec's get). The lane answers 404 once a message is delivered, failed
200
+ * or cancelled. */
201
+ function pending(ctx: SemanticsContext, id: string): Row | undefined {
202
+ const m = ctx.rowsRaw('Message').find((r) => r.messageId === id);
203
+ return m && ['CREATED', 'ACTIVE', 'RETRY', 'CANCEL_REQUESTED'].includes(String(m.state)) ? m : undefined;
204
+ }
205
+
206
+ /** GET /v2/messages/{messageId}. */
207
+ const getMessage: Semantics = async (ctx) => {
208
+ const m = pending(ctx, String(ctx.call.params.messageId));
209
+ return m ? ctx.reply(redacted(vendorFields(ctx, m, MESSAGE_FIELDS), m)) : refuse(ctx, 404, 'Message not found.');
210
+ };
211
+
212
+ /** DELETE /v2/messages/{messageId}: "Cancel a pending message"; it is CANCELLED at its next delivery time (debug-logs),
213
+ * never delivered. Answered 202 with no body (the spec's 202). */
214
+ const cancelMessage: Semantics = async (ctx) => {
215
+ const id = String(ctx.call.params.messageId);
216
+ const m = ctx.rowsRaw('Message').find((r) => r.messageId === id);
217
+ if (!m) return refuse(ctx, 404, 'Message not found.');
218
+ const refused = ctx.legal('Message', 'state', 'delete_v2_messages_messageid', m.state, 'CANCEL_REQUESTED', id);
219
+ if (refused) return ctx.refuse(refused);
220
+ await ctx.write('Message', String(m.id), { state: 'CANCEL_REQUESTED' }, 'message.cancel');
221
+ return new Response(null, { status: 202 });
222
+ };
223
+
224
+ export const messageSemantics: Record<string, Semantics> = {
225
+ post_v2_publish_destination: publish,
226
+ post_v2_enqueue_queuename_destination: enqueue,
227
+ post_v2_batch: batch,
228
+ get_v2_messages_messageid: getMessage,
229
+ delete_v2_messages_messageid: cancelMessage,
230
+ };
231
+
232
+ /** A message's fields as the API answers them after redaction: "QStash allows you to redact specific fields so they appear
233
+ * as `REDACTED:<SHA256>` in the dashboard and API. The original values are still used when delivering messages to your
234
+ * endpoint." (https://upstash.com/docs/qstash/howto/redact-fields); `Upstash-Redact-Fields` names `body`, `header` (all)
235
+ * or `header[<name>]`. Where the page stops and the lane decides: the SHA-256 is of the value, in hex. */
236
+ export function redacted(view: Row, row: Row): Row {
237
+ const spec = typeof row._redact === 'string' ? row._redact : '';
238
+ if (!spec) return view;
239
+ const parts = spec.split(',').map((x) => x.trim());
240
+ const sha = (v: string): string => `REDACTED:${createHash('sha256').update(v).digest('hex')}`;
241
+ const out: Row = { ...view };
242
+ if (parts.includes('body') && typeof out.body === 'string') out.body = sha(out.body);
243
+ const all = parts.includes('header') || parts.includes('headers');
244
+ const names = parts.map((x) => /^headers?\[(.+)\]$/.exec(x)?.[1]?.toLowerCase()).filter((x): x is string => !!x);
245
+ if ((all || names.length) && out.header && typeof out.header === 'object') {
246
+ out.header = Object.fromEntries(Object.entries(out.header as Record<string, string[]>).map(([k, v]) => [k, all || names.includes(k.toLowerCase()) ? v.map(sha) : v]));
247
+ }
248
+ return out;
249
+ }
@@ -0,0 +1,2 @@
1
+ import type { Semantics } from '@volter/world-core';
2
+ export declare const queueSemantics: Record<string, Semantics>;
@@ -0,0 +1,60 @@
1
+ import { nowMs, QUEUE_NAME, refuse } from "./shared.js";
2
+ const INVALID = 'Queue name is invalid. Queue names can only contain alphanumeric characters, hyphens, periods, and underscores.';
3
+ const queueOf = (ctx, name) => ctx.rowsRaw('Queue').find((r) => r.name === name);
4
+ /** A queue as the spec's `Queue` answers it; `lag`, "The number of unprocessed messages that exist in the queue". */
5
+ function view(ctx, q) {
6
+ const lag = ctx.rowsRaw('Message').filter((m) => m.queueName === q.name && ['CREATED', 'RETRY', 'ACTIVE'].includes(String(m.state))).length;
7
+ return { name: q.name, createdAt: q.createdAt, updatedAt: q.updatedAt, parallelism: q.parallelism, paused: q.paused === true, lag };
8
+ }
9
+ /** POST /v2/queues {queueName, parallelism}: "Updates or creates a queue" (the spec). Answered 200 with no body. A name
10
+ * that is not a queue name answers the spec's 400; a parallelism below 1 ("Must be greater than 0") a 400 of the lane's
11
+ * wording. */
12
+ const upsert = async (ctx) => {
13
+ const b = (ctx.body && typeof ctx.body === 'object' ? ctx.body : {});
14
+ const name = typeof b.queueName === 'string' ? b.queueName : '';
15
+ if (!QUEUE_NAME.test(name))
16
+ return refuse(ctx, 400, INVALID);
17
+ const parallelism = b.parallelism === undefined ? 1 : Number(b.parallelism);
18
+ if (!Number.isInteger(parallelism) || parallelism < 1)
19
+ return refuse(ctx, 400, 'parallelism must be greater than 0');
20
+ const q = queueOf(ctx, name);
21
+ const at = nowMs(ctx);
22
+ await ctx.write('Queue', q ? String(q.id) : name, { name, createdAt: q?.createdAt ?? at, updatedAt: at, parallelism, paused: q?.paused === true }, q ? 'queue.update' : 'queue.create');
23
+ return new Response(null, { status: 200 });
24
+ };
25
+ /** GET /v2/queues/{queueName}: the queue, or the spec's 404 "Queue not found". */
26
+ const get = async (ctx) => {
27
+ const q = queueOf(ctx, String(ctx.call.params.queueName));
28
+ return q ? ctx.reply(view(ctx, q)) : refuse(ctx, 404, 'Queue not found');
29
+ };
30
+ /** POST /v2/queues/{queueName}/pause and /resume: "Pausing a queue stops the delivery of enqueued messages. The queue
31
+ * continues to accept new messages, but they will not be delivered until the queue is resumed. If the queue is already
32
+ * paused, this action has no effect." and "Resuming a queue starts the delivery of enqueued messages, beginning with the
33
+ * earliest undelivered message." (the spec). Answered 200 with no body. Where the spec stops and the lane decides: a
34
+ * queue it does not hold answers 404, as its read does. */
35
+ const setPaused = (paused) => async (ctx) => {
36
+ const q = queueOf(ctx, String(ctx.call.params.queueName));
37
+ if (!q)
38
+ return refuse(ctx, 404, 'Queue not found');
39
+ if (q.paused !== paused)
40
+ await ctx.write('Queue', String(q.id), { paused, updatedAt: nowMs(ctx) }, paused ? 'queue.pause' : 'queue.resume');
41
+ return new Response(null, { status: 200 });
42
+ };
43
+ /** DELETE /v2/queues/{queueName}: the queue is gone. Answered 200 with no body (the spec's 200). Where the documentation
44
+ * stops and the lane decides: messages already enqueued in it are delivered as they were; a queue the World does not
45
+ * hold answers 404. */
46
+ const remove = async (ctx) => {
47
+ const name = String(ctx.call.params.queueName);
48
+ const q = queueOf(ctx, name);
49
+ if (!q)
50
+ return refuse(ctx, 404, `Queue ${name} not found.`);
51
+ await ctx.write('Queue', String(q.id), { deleted: true }, 'queue.delete');
52
+ return new Response(null, { status: 200 });
53
+ };
54
+ export const queueSemantics = {
55
+ post_v2_queues: upsert,
56
+ get_v2_queues_queuename: get,
57
+ post_v2_queues_queuename_pause: setPaused(true),
58
+ post_v2_queues_queuename_resume: setPaused(false),
59
+ delete_v2_queues_queuename: remove,
60
+ };
@@ -0,0 +1,66 @@
1
+ // Queues: "The queue concept in QStash allows ordered delivery (FIFO)." (https://upstash.com/docs/qstash/features/queues).
2
+ // A queue is made by its first enqueue (messages.ts) or its upsert; the lane serves its upsert, its read, its pause and
3
+ // resume, and its removal. How a queue delivers (its order, its parallelism, its pause) is the catch-up's (delivery.ts).
4
+ import type { Semantics, SemanticsContext } from '@volter/world-core';
5
+ import { nowMs, QUEUE_NAME, refuse, type Row } from './shared.ts';
6
+
7
+ const INVALID = 'Queue name is invalid. Queue names can only contain alphanumeric characters, hyphens, periods, and underscores.';
8
+ const queueOf = (ctx: SemanticsContext, name: string): Row | undefined => ctx.rowsRaw('Queue').find((r) => r.name === name);
9
+
10
+ /** A queue as the spec's `Queue` answers it; `lag`, "The number of unprocessed messages that exist in the queue". */
11
+ function view(ctx: SemanticsContext, q: Row): Row {
12
+ const lag = ctx.rowsRaw('Message').filter((m) => m.queueName === q.name && ['CREATED', 'RETRY', 'ACTIVE'].includes(String(m.state))).length;
13
+ return { name: q.name, createdAt: q.createdAt, updatedAt: q.updatedAt, parallelism: q.parallelism, paused: q.paused === true, lag };
14
+ }
15
+
16
+ /** POST /v2/queues {queueName, parallelism}: "Updates or creates a queue" (the spec). Answered 200 with no body. A name
17
+ * that is not a queue name answers the spec's 400; a parallelism below 1 ("Must be greater than 0") a 400 of the lane's
18
+ * wording. */
19
+ const upsert: Semantics = async (ctx) => {
20
+ const b = (ctx.body && typeof ctx.body === 'object' ? ctx.body : {}) as Row;
21
+ const name = typeof b.queueName === 'string' ? b.queueName : '';
22
+ if (!QUEUE_NAME.test(name)) return refuse(ctx, 400, INVALID);
23
+ const parallelism = b.parallelism === undefined ? 1 : Number(b.parallelism);
24
+ if (!Number.isInteger(parallelism) || parallelism < 1) return refuse(ctx, 400, 'parallelism must be greater than 0');
25
+ const q = queueOf(ctx, name);
26
+ const at = nowMs(ctx);
27
+ await ctx.write('Queue', q ? String(q.id) : name, { name, createdAt: q?.createdAt ?? at, updatedAt: at, parallelism, paused: q?.paused === true }, q ? 'queue.update' : 'queue.create');
28
+ return new Response(null, { status: 200 });
29
+ };
30
+
31
+ /** GET /v2/queues/{queueName}: the queue, or the spec's 404 "Queue not found". */
32
+ const get: Semantics = async (ctx) => {
33
+ const q = queueOf(ctx, String(ctx.call.params.queueName));
34
+ return q ? ctx.reply(view(ctx, q)) : refuse(ctx, 404, 'Queue not found');
35
+ };
36
+
37
+ /** POST /v2/queues/{queueName}/pause and /resume: "Pausing a queue stops the delivery of enqueued messages. The queue
38
+ * continues to accept new messages, but they will not be delivered until the queue is resumed. If the queue is already
39
+ * paused, this action has no effect." and "Resuming a queue starts the delivery of enqueued messages, beginning with the
40
+ * earliest undelivered message." (the spec). Answered 200 with no body. Where the spec stops and the lane decides: a
41
+ * queue it does not hold answers 404, as its read does. */
42
+ const setPaused = (paused: boolean): Semantics => async (ctx) => {
43
+ const q = queueOf(ctx, String(ctx.call.params.queueName));
44
+ if (!q) return refuse(ctx, 404, 'Queue not found');
45
+ if (q.paused !== paused) await ctx.write('Queue', String(q.id), { paused, updatedAt: nowMs(ctx) }, paused ? 'queue.pause' : 'queue.resume');
46
+ return new Response(null, { status: 200 });
47
+ };
48
+
49
+ /** DELETE /v2/queues/{queueName}: the queue is gone. Answered 200 with no body (the spec's 200). Where the documentation
50
+ * stops and the lane decides: messages already enqueued in it are delivered as they were; a queue the World does not
51
+ * hold answers 404. */
52
+ const remove: Semantics = async (ctx) => {
53
+ const name = String(ctx.call.params.queueName);
54
+ const q = queueOf(ctx, name);
55
+ if (!q) return refuse(ctx, 404, `Queue ${name} not found.`);
56
+ await ctx.write('Queue', String(q.id), { deleted: true }, 'queue.delete');
57
+ return new Response(null, { status: 200 });
58
+ };
59
+
60
+ export const queueSemantics: Record<string, Semantics> = {
61
+ post_v2_queues: upsert,
62
+ get_v2_queues_queuename: get,
63
+ post_v2_queues_queuename_pause: setPaused(true),
64
+ post_v2_queues_queuename_resume: setPaused(false),
65
+ delete_v2_queues_queuename: remove,
66
+ };
@@ -0,0 +1,19 @@
1
+ import type { Semantics } from '@volter/world-core';
2
+ type Field = Set<number>;
3
+ type Cron = {
4
+ minute: Field;
5
+ hour: Field;
6
+ dom: Field;
7
+ month: Field;
8
+ dow: Field;
9
+ domAny: boolean;
10
+ dowAny: boolean;
11
+ zone: string;
12
+ };
13
+ /** A cron expression's fields and zone, or undefined when it is not one the lane reads. */
14
+ export declare function parseCron(expr: string): Cron | undefined;
15
+ /** The first minute after `afterMs` the cron fires at, its fields read on the wall clock of its zone. Day of month and day
16
+ * of week, both restricted, fire on either (the classic cron rule). A day or hour that cannot match is skipped whole. */
17
+ export declare function nextFire(expr: string, afterMs: number): number | undefined;
18
+ export declare const scheduleSemantics: Record<string, Semantics>;
19
+ export {};
@@ -0,0 +1,125 @@
1
+ import { ownerFor, labelsOf, sentHeader } from "./messages.js";
2
+ import { durationSeconds, everHeld, headersOf, mintId, nowMs, refuse } from "./shared.js";
3
+ function field(spec, min, max) {
4
+ const out = new Set();
5
+ for (const part of spec.split(',')) {
6
+ const m = /^(\*|\d+(?:-\d+)?)(?:\/(\d+))?$/.exec(part);
7
+ if (!m)
8
+ return undefined;
9
+ const [lo, hi] = m[1] === '*' ? [min, max] : m[1].includes('-') ? m[1].split('-').map(Number) : [Number(m[1]), m[2] ? max : Number(m[1])];
10
+ const step = m[2] ? Number(m[2]) : 1;
11
+ if (lo < min || hi > max || lo > hi || step < 1)
12
+ return undefined;
13
+ for (let v = lo; v <= hi; v += step)
14
+ out.add(v);
15
+ }
16
+ return out;
17
+ }
18
+ /** A cron expression's fields and zone, or undefined when it is not one the lane reads. */
19
+ export function parseCron(expr) {
20
+ const tz = /^CRON_TZ=(\S+)\s+/.exec(expr.trim());
21
+ const zone = tz?.[1] ?? 'UTC';
22
+ try {
23
+ new Intl.DateTimeFormat('en-US', { timeZone: zone });
24
+ }
25
+ catch {
26
+ return undefined;
27
+ }
28
+ const parts = expr.trim().replace(/^CRON_TZ=\S+\s+/, '').split(/\s+/);
29
+ if (parts.length !== 5)
30
+ return undefined;
31
+ const [mi, h, dom, mo, dow] = parts;
32
+ const f = { minute: field(mi, 0, 59), hour: field(h, 0, 23), dom: field(dom, 1, 31), month: field(mo, 1, 12), dow: field(dow.replace(/\b7\b/g, '0'), 0, 6) };
33
+ if (!f.minute || !f.hour || !f.dom || !f.month || !f.dow)
34
+ return undefined;
35
+ return { minute: f.minute, hour: f.hour, dom: f.dom, month: f.month, dow: f.dow, domAny: dom === '*', dowAny: dow === '*', zone };
36
+ }
37
+ /** The wall clock in a zone at an instant: month, day of month, day of week, hour, minute. */
38
+ function wall(ms, zone) {
39
+ const parts = Object.fromEntries(new Intl.DateTimeFormat('en-US', { timeZone: zone, hourCycle: 'h23', month: 'numeric', day: 'numeric', weekday: 'short', hour: 'numeric', minute: 'numeric' })
40
+ .formatToParts(new Date(ms)).map((x) => [x.type, x.value]));
41
+ const dow = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].indexOf(String(parts.weekday));
42
+ return { month: Number(parts.month), dom: Number(parts.day), dow, hour: Number(parts.hour) % 24, minute: Number(parts.minute) };
43
+ }
44
+ /** The first minute after `afterMs` the cron fires at, its fields read on the wall clock of its zone. Day of month and day
45
+ * of week, both restricted, fire on either (the classic cron rule). A day or hour that cannot match is skipped whole. */
46
+ export function nextFire(expr, afterMs) {
47
+ const c = parseCron(expr);
48
+ if (!c)
49
+ return undefined;
50
+ let t = Math.floor(afterMs / 60_000) * 60_000 + 60_000;
51
+ const end = t + 366 * 86_400_000;
52
+ for (let skip = skipFrom(c, t); skip > 0 && t < end; skip = skipFrom(c, t))
53
+ t += skip;
54
+ return t < end ? t : neverFires();
55
+ }
56
+ /** How far past the minute `t` the cron's next candidate is: 0 when it fires at `t`, the rest of the day when the day
57
+ * cannot match, the rest of the hour when the hour cannot, else a minute. */
58
+ function skipFrom(c, t) {
59
+ const w = wall(t, c.zone);
60
+ const dayOk = c.domAny && c.dowAny ? true : c.domAny ? c.dow.has(w.dow) : c.dowAny ? c.dom.has(w.dom) : c.dom.has(w.dom) || c.dow.has(w.dow);
61
+ if (!c.month.has(w.month) || !dayOk)
62
+ return (60 - w.minute) * 60_000 + (23 - w.hour) * 3_600_000;
63
+ if (!c.hour.has(w.hour))
64
+ return (60 - w.minute) * 60_000;
65
+ return c.minute.has(w.minute) ? 0 : 60_000;
66
+ }
67
+ /** A cron no minute of the coming year meets (`0 0 30 2 *`, the 30th of February): it has no next time. */
68
+ function neverFires() {
69
+ return undefined;
70
+ }
71
+ /** POST /v2/schedules/{destination}: a schedule of the message the request describes (its body, its `Upstash-*` headers),
72
+ * answered `{scheduleId}`. `Upstash-Schedule-Id` names a schedule to create or overwrite (the spec's parameter). */
73
+ const createSchedule = async (ctx) => {
74
+ const destination = String(ctx.call.params.destination ?? '');
75
+ const h = headersOf(ctx);
76
+ const cron = h['upstash-cron'];
77
+ if (!cron)
78
+ return refuse(ctx, 400, 'Upstash-Cron header is required');
79
+ const owner = ownerFor(ctx);
80
+ if (!/^https?:\/\//.test(destination) && !ctx.rowsRaw('URLGroup').some((g) => g.name === destination && g._owner === owner))
81
+ return refuse(ctx, 404, `URL Group ${destination} not found`);
82
+ const next = nextFire(cron, nowMs(ctx));
83
+ if (next === undefined)
84
+ return refuse(ctx, 400, `invalid cron expression: ${cron}`);
85
+ const id = h['upstash-schedule-id'] ?? mintId(ctx, 'scd_', 'schedule', everHeld(ctx, 'Schedule'));
86
+ const retries = h['upstash-retries'] !== undefined && /^\d+$/.test(h['upstash-retries']) ? Number(h['upstash-retries']) : 3;
87
+ const fields = {
88
+ scheduleId: id, cron, destination, createdAt: nowMs(ctx), method: h['upstash-method'] ?? 'POST', header: sentHeader(h), body: ctx.text ?? '',
89
+ retries, ...(durationSeconds(h['upstash-delay']) ? { delay: durationSeconds(h['upstash-delay']) } : {}),
90
+ ...(h['upstash-callback'] ? { callback: h['upstash-callback'] } : {}), ...(h['upstash-failure-callback'] ? { failureCallback: h['upstash-failure-callback'] } : {}),
91
+ isPaused: false, nextScheduleTime: next, ...labelsOf(h),
92
+ ...(h['upstash-queue-name'] ? { _queue: h['upstash-queue-name'] } : {}),
93
+ _owner: owner, _published: h,
94
+ };
95
+ await ctx.write('Schedule', id, fields, 'schedule.create');
96
+ return ctx.reply({ scheduleId: id });
97
+ };
98
+ const scheduleOf = (ctx) => ctx.rowsRaw('Schedule').find((s) => s.scheduleId === String(ctx.call.params.scheduleId));
99
+ /** POST /v2/schedules/{scheduleId}/pause, and the PATCH the vendor's clients send (the spec patch): its cron is ignored from
100
+ * now on. Answered 200 with no body. */
101
+ const pause = async (ctx) => {
102
+ const s = scheduleOf(ctx);
103
+ if (!s)
104
+ return refuse(ctx, 404, 'Schedule not found.');
105
+ const refused = ctx.legal('Schedule', 'isPaused', ctx.call.operation.id, s.isPaused === true);
106
+ if (refused)
107
+ return ctx.refuse(refused);
108
+ if (s.isPaused !== true)
109
+ await ctx.write('Schedule', String(s.id), { isPaused: true }, 'schedule.pause');
110
+ return new Response(null, { status: 200 });
111
+ };
112
+ /** DELETE /v2/schedules/{scheduleId}: it fires no more. Answered 200 with no body. */
113
+ const remove = async (ctx) => {
114
+ const s = scheduleOf(ctx);
115
+ if (!s)
116
+ return refuse(ctx, 404, 'Schedule not found.');
117
+ await ctx.write('Schedule', String(s.id), { deleted: true }, 'schedule.delete');
118
+ return new Response(null, { status: 200 });
119
+ };
120
+ export const scheduleSemantics = {
121
+ post_v2_schedules_destination: createSchedule,
122
+ post_v2_schedules_scheduleid_pause: pause,
123
+ patch_v2_schedules_scheduleid_pause: pause,
124
+ delete_v2_schedules_scheduleid: remove,
125
+ };