@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,274 @@
1
+ import { accountOf } from "./account.js";
2
+ import { acceptToUrlGroup, ensureQueue, storeMessage } from "./messages.js";
3
+ import { nextFire } from "./schedules.js";
4
+ import { failRun, failureCallSettled, rawSteps, timeOut, workflowHeaders } from "./workflows.js";
5
+ import { b64, iso, multiValue, nowMs } from "./shared.js";
6
+ import { post } from "../egress.js";
7
+ import { sign } from "../signing.js";
8
+ /** The World's word on what a destination answers, stored by `POST /_twin/destinations` (../doors.ts). */
9
+ export const DESTINATION = '_qstash_destination';
10
+ /** What a destination received, read at `GET /_twin/deliveries?to=` (../doors.ts). */
11
+ export const DELIVERY = '_qstash_delivery';
12
+ const DAY_MS = 86_400_000;
13
+ const DLQ_RETENTION_MS = 7 * DAY_MS;
14
+ const PENDING = new Set(['CREATED', 'ACTIVE', 'RETRY']);
15
+ /** The answer the World has stated for a delivery: of the statements whose URL prefixes the delivery's and whose `when`
16
+ * headers (if any) it carries, one that names headers before one that does not, then the longest prefix. */
17
+ function stated(ctx, url, sent) {
18
+ const holds = (d) => url.startsWith(String(d.url)) && Object.entries((d.when ?? {})).every(([k, v]) => sent[k.toLowerCase()] === v);
19
+ const hit = ctx.rowsRaw(DESTINATION).filter(holds)
20
+ .sort((a, b) => Number(Object.keys(b.when ?? {}).length > 0) - Number(Object.keys(a.when ?? {}).length > 0) || String(b.url).length - String(a.url).length)[0];
21
+ return hit ? { status: Number(hit.status), body: String(hit.body ?? ''), headers: (hit.headers ?? {}) } : undefined;
22
+ }
23
+ /** QStash's backoff for retry n, in milliseconds. */
24
+ const backoffMs = (n) => Math.round(Math.min(86_400, Math.exp(2.5 * n)) * 1000);
25
+ /** A `Retry-After` answer in milliseconds: seconds, a duration (`6m5s`) or a date, capped at a day. */
26
+ function retryAfterMs(headers, at) {
27
+ const raw = headers['retry-after']?.trim();
28
+ return raw ? retryAfterValue(raw, at) : undefined;
29
+ }
30
+ /** A `Retry-After` value in milliseconds (https://upstash.com/docs/qstash/features/retry, Retry-After Headers). */
31
+ function retryAfterValue(raw, at) {
32
+ const d = /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/.exec(raw);
33
+ const sec = /^\d+$/.test(raw) ? Number(raw) : d && raw !== '' ? Number(d[1] ?? 0) * 3600 + Number(d[2] ?? 0) * 60 + Number(d[3] ?? 0) : Number.isNaN(Date.parse(raw)) ? undefined : Math.max(0, (Date.parse(raw) - at) / 1000);
34
+ return sec === undefined ? undefined : Math.round(Math.min(86_400, sec) * 1000);
35
+ }
36
+ /** An `Upstash-Retry-Delay` expression's value in milliseconds: numbers, `retried`, `+ - * /`, parentheses and `pow sqrt
37
+ * abs exp floor ceil round min max` (the retry page's Custom Retry Delay); anything else is undefined. */
38
+ export function retryDelayMs(expr, retried) {
39
+ const src = expr.replace(/\s+/g, '');
40
+ let i = 0;
41
+ const fns = { pow: Math.pow, sqrt: Math.sqrt, abs: Math.abs, exp: Math.exp, floor: Math.floor, ceil: Math.ceil, round: Math.round, min: Math.min, max: Math.max };
42
+ const fail = () => { throw new Error('retryDelay'); };
43
+ const primary = () => {
44
+ if (src[i] === '(') {
45
+ i += 1;
46
+ const v = sum();
47
+ if (src[i] !== ')')
48
+ fail();
49
+ i += 1;
50
+ return v;
51
+ }
52
+ const num = /^\d+(\.\d+)?/.exec(src.slice(i));
53
+ if (num) {
54
+ i += num[0].length;
55
+ return Number(num[0]);
56
+ }
57
+ const id = /^[a-z]+/.exec(src.slice(i));
58
+ if (!id)
59
+ return fail();
60
+ i += id[0].length;
61
+ if (id[0] === 'retried')
62
+ return retried;
63
+ const fn = fns[id[0]];
64
+ if (!fn || src[i] !== '(')
65
+ return fail();
66
+ i += 1;
67
+ const args = [sum()];
68
+ while (src[i] === ',') {
69
+ i += 1;
70
+ args.push(sum());
71
+ }
72
+ if (src[i] !== ')')
73
+ fail();
74
+ i += 1;
75
+ return fn(...args);
76
+ };
77
+ const product = () => { let v = primary(); while (src[i] === '*' || src[i] === '/') {
78
+ const op = src[i];
79
+ i += 1;
80
+ const r = primary();
81
+ v = op === '*' ? v * r : v / r;
82
+ } return v; };
83
+ const sum = () => { let v = product(); while (src[i] === '+' || src[i] === '-') {
84
+ const op = src[i];
85
+ i += 1;
86
+ const r = product();
87
+ v = op === '+' ? v + r : v - r;
88
+ } return v; };
89
+ try {
90
+ const v = sum();
91
+ return i === src.length && Number.isFinite(v) && v >= 0 ? v : undefined;
92
+ }
93
+ catch {
94
+ return undefined;
95
+ }
96
+ }
97
+ /** A queue delivers in order, as many at a time as its parallelism ("The number of parallel consumers consuming from the
98
+ * queue", the spec's Queue), and none while it is paused ("Pausing a queue stops the delivery of enqueued messages", the
99
+ * spec's pause). A message whose queue was deleted goes as a queue of one would. */
100
+ function queueLets(ctx, m, all) {
101
+ const q = ctx.rowsRaw('Queue').find((r) => r.name === m.queueName);
102
+ if (q?.paused === true)
103
+ return false;
104
+ const ahead = all.filter((o) => o.queueName === m.queueName && PENDING.has(String(o.state)) && Number(o._ordinal) < Number(m._ordinal)).length;
105
+ return ahead < Math.max(1, Number(q?.parallelism ?? 1));
106
+ }
107
+ /** Whether a pending message may go now: its queue lets it (queueLets), and it is the first undelivered of its run. */
108
+ function eligible(ctx, m, all) {
109
+ if (m.state !== 'CREATED' && m.state !== 'RETRY')
110
+ return false;
111
+ if (typeof m.queueName === 'string' && !queueLets(ctx, m, all))
112
+ return false;
113
+ if (typeof m._run === 'string') {
114
+ const run = ctx.rowsRaw('WorkflowRun').find((r) => r.workflowRunId === m._run);
115
+ if (!run || run.workflowState !== 'RUN_STARTED')
116
+ return false;
117
+ if (all.some((o) => o._run === m._run && PENDING.has(String(o.state)) && Number(o._seq) < Number(m._seq)))
118
+ return false;
119
+ }
120
+ return true;
121
+ }
122
+ function nextDue(ctx, until) {
123
+ const due = [];
124
+ for (const s of ctx.rowsRaw('Schedule')) {
125
+ if (typeof s.nextScheduleTime === 'number' && s.nextScheduleTime <= until)
126
+ due.push({ when: s.nextScheduleTime, order: 0, act: (at) => fire(at, s) });
127
+ }
128
+ for (const w of ctx.rowsRaw('Waiter')) {
129
+ const when = Number(w.deadline) * 1000;
130
+ if (when <= until)
131
+ due.push({ when, order: 1, act: (at) => timeOut(at, w) });
132
+ }
133
+ const all = ctx.rowsRaw('Message');
134
+ for (const m of all) {
135
+ const when = Number(m._due);
136
+ if (when > until)
137
+ continue;
138
+ if (m.state === 'CANCEL_REQUESTED')
139
+ due.push({ when, order: 2 + Number(m._ordinal) / 1e9, act: (at) => cancelled(at, m) });
140
+ else if (eligible(ctx, m, all))
141
+ due.push({ when, order: 3 + Number(m._ordinal) / 1e9, act: (at, report) => attempt(at, m, report) });
142
+ }
143
+ for (const resource of ['DLQMessage', 'WorkflowDLQMessage']) {
144
+ for (const e of ctx.rowsRaw(resource)) {
145
+ const when = Number(e._filed_at ?? e.createdAt) + DLQ_RETENTION_MS;
146
+ if (when <= until)
147
+ due.push({ when, order: 4, act: (at) => at.write(resource, String(e.id), { deleted: true }, 'dlq.expired').then(() => undefined) });
148
+ }
149
+ }
150
+ return due.sort((a, b) => a.when - b.when || a.order - b.order)[0];
151
+ }
152
+ /** Run everything due by the World's instant, in order, each at its own moment. */
153
+ export async function catchUp(ctx) {
154
+ const until = nowMs(ctx);
155
+ const report = { delivered: [], retried: [], failed: [] };
156
+ // a message held behind another (the one before it in its queue or its run) goes once that one has left, so the World
157
+ // clock never runs back: each move happens at its own due time or at the last move's, whichever is later
158
+ let last = 0;
159
+ for (let n = 0; n < 5000; n += 1) {
160
+ const next = nextDue(ctx, until);
161
+ if (!next)
162
+ break;
163
+ last = Math.max(last, next.when);
164
+ await next.act(await ctx.at(iso(last)), report);
165
+ }
166
+ return report;
167
+ }
168
+ /** A schedule's cron has fired: its message is published at that moment (a paused schedule's is not), and it is next
169
+ * due at the cron's following minute. */
170
+ async function fire(ctx, s) {
171
+ const when = Number(s.nextScheduleTime);
172
+ if (s.isPaused !== true) {
173
+ const { 'upstash-cron': _cron, 'upstash-schedule-id': _id, 'upstash-queue-name': _q, ...headers } = (s._published ?? {});
174
+ const queue = typeof s._queue === 'string' ? s._queue : undefined;
175
+ if (queue)
176
+ await ensureQueue(ctx, queue);
177
+ const destination = String(s.destination);
178
+ if (/^https?:\/\//.test(destination))
179
+ await storeMessage(ctx, { url: destination, headers, body: String(s.body ?? ''), owner: String(s._owner), scheduleId: String(s.scheduleId), queue });
180
+ else
181
+ await acceptToUrlGroup(ctx, { destination, headers, body: String(s.body ?? ''), queue }, String(s._owner));
182
+ }
183
+ const next = nextFire(String(s.cron), when);
184
+ await ctx.write('Schedule', String(s.id), { lastScheduleTime: when, ...(next !== undefined ? { nextScheduleTime: next } : {}) }, 'schedule.fire');
185
+ }
186
+ /** A cancelled message reaches its next delivery time: it is CANCELLED, never delivered. */
187
+ async function cancelled(ctx, m) {
188
+ const refused = ctx.legal('Message', 'state', 'delete_v2_messages_messageid', m.state, 'CANCELLED', String(m.id), 'time');
189
+ if (refused)
190
+ throw new Error(refused.message);
191
+ await ctx.write('Message', String(m.id), { state: 'CANCELLED' }, 'message.cancelled');
192
+ }
193
+ /** Move a message, as its machine allows. */
194
+ async function move(ctx, m, to, actor, fields) {
195
+ const refused = ctx.legal('Message', 'state', 'delete_v2_messages_messageid', m.state, to, String(m.id), actor);
196
+ if (refused)
197
+ throw new Error(refused.message);
198
+ await ctx.write('Message', String(m.id), { ...fields, state: to }, `message.${to.toLowerCase()}`);
199
+ m.state = to;
200
+ }
201
+ /** One delivery attempt of a message, at the moment it is due. */
202
+ async function attempt(ctx, m, report) {
203
+ const when = nowMs(ctx);
204
+ const tried = Number(m._attempts ?? 0);
205
+ await move(ctx, m, 'ACTIVE', 'time', { _attempts: tried });
206
+ const published = (m._published ?? {});
207
+ const step = typeof m._run === 'string';
208
+ const body = step ? JSON.stringify(rawSteps(ctx, m._run, Number(m._seq))) : String(m.body ?? '');
209
+ const base = published['upstash-workflow-runid'] !== undefined ? workflowHeaders(published) : {
210
+ ...(typeof m._content_type === 'string' ? { 'content-type': m._content_type } : {}),
211
+ ...Object.fromEntries(Object.entries(published).filter(([k]) => k.startsWith('upstash-forward-')).map(([k, v]) => [k.slice('upstash-forward-'.length), v])),
212
+ };
213
+ const url = String(m.url);
214
+ const key = accountOf(ctx, String(m._owner ?? 'world')).current;
215
+ const headers = {
216
+ ...base, 'upstash-message-id': String(m.messageId), 'upstash-retried': String(tried),
217
+ 'upstash-signature': sign(body, url, Math.floor(when / 1000), `jwt_${String(m.messageId)}_${tried}`, key),
218
+ };
219
+ const method = String(m.method ?? 'POST');
220
+ const outcome = stated(ctx, url, headers) ?? await post(url, method, headers, body);
221
+ await ctx.record(DELIVERY, { to: url, message_id: m.messageId, retried: tried, at: ctx.occurredAt, n: ctx.rowsRaw(DELIVERY).length, method, headers, body, status: outcome.status }, `delivery:${String(m.messageId)}:${tried}`);
222
+ const n = tried + 1;
223
+ const response = { _response_status: outcome.status, _response_body: outcome.body, _response_headers: outcome.headers };
224
+ if (outcome.status >= 200 && outcome.status < 300) {
225
+ await move(ctx, m, 'DELIVERED', 'vendor', { _attempts: n, ...response });
226
+ report.delivered.push({ messageId: m.messageId, url, status: outcome.status });
227
+ if (typeof m._callback_of === 'string')
228
+ await failureCallSettled(ctx, m._callback_of, true);
229
+ else if (typeof m.callback === 'string')
230
+ await callback(ctx, m, String(m.callback), outcome, n, undefined);
231
+ return;
232
+ }
233
+ const nonRetryable = outcome.status === 489 && outcome.headers['upstash-nonretryable-error'] === 'true';
234
+ if (n <= Number(m.maxRetries ?? 3) && !nonRetryable) {
235
+ const run = step ? ctx.rowsRaw('WorkflowRun').find((r) => r.workflowRunId === m._run) : undefined;
236
+ const expr = typeof m._retry_delay === 'string' ? m._retry_delay : typeof run?._retry_delay === 'string' ? run._retry_delay : undefined;
237
+ const wait = retryAfterMs(outcome.headers, when) ?? (expr !== undefined ? retryDelayMs(expr, tried) : undefined) ?? backoffMs(n);
238
+ await move(ctx, m, 'RETRY', 'vendor', { _attempts: n, _due: when + wait, ...response });
239
+ report.retried.push({ messageId: m.messageId, url, status: outcome.status, retried: n, nextDeliveryTime: when + wait });
240
+ return;
241
+ }
242
+ await move(ctx, m, 'FAILED', 'vendor', { _attempts: n, ...response });
243
+ report.failed.push({ messageId: m.messageId, url, status: outcome.status });
244
+ if (typeof m._callback_of === 'string') {
245
+ await failureCallSettled(ctx, m._callback_of, false);
246
+ return;
247
+ }
248
+ if (step) {
249
+ await failRun(ctx, { ...m, _attempts: n }, outcome);
250
+ return;
251
+ }
252
+ const dlqId = `${when}-${ctx.rowsRaw('DLQMessage', { withDeleted: true }).length}`;
253
+ const own = ctx.own(m);
254
+ await ctx.write('DLQMessage', dlqId, {
255
+ ...Object.fromEntries(Object.entries(own).filter(([k]) => !k.startsWith('_') && k !== 'state')),
256
+ dlqId, responseStatus: outcome.status, responseHeader: multiValue(outcome.headers), responseBody: outcome.body,
257
+ _filed_at: when, _published: published, _message_id: m.messageId, _owner: m._owner ?? 'world', ...(m._redact ? { _redact: m._redact } : {}),
258
+ }, 'dlq.create');
259
+ if (typeof m.failureCallback === 'string')
260
+ await callback(ctx, m, String(m.failureCallback), outcome, n, dlqId);
261
+ if (typeof m.callback === 'string')
262
+ await callback(ctx, m, String(m.callback), outcome, n, undefined);
263
+ }
264
+ /** A callback: "QStash calls your callback URL with the response"; its body is the callbacks page's (`status`, `header`,
265
+ * base64 `body`, `retried`, `maxRetries`, `sourceMessageId`, `url`, `method`, `sourceHeader`, base64 `sourceBody`,
266
+ * `notBefore`, `createdAt`, `callerIP`), and a failure callback's adds the `dlqId`. It is a message of its own. */
267
+ async function callback(ctx, m, url, o, attempts, dlqId) {
268
+ const body = JSON.stringify({
269
+ status: o.status, header: multiValue(o.headers), body: b64(o.body), retried: attempts - 1, maxRetries: Number(m.maxRetries ?? 3),
270
+ ...(dlqId ? { dlqId } : {}), sourceMessageId: m.messageId, url: m.url, method: m.method ?? 'POST', sourceHeader: m.header ?? {},
271
+ sourceBody: b64(String(m.body ?? '')), notBefore: m.notBefore, createdAt: m.createdAt, callerIP: '127.0.0.1',
272
+ });
273
+ await storeMessage(ctx, { url, headers: { 'content-type': 'application/json' }, body, owner: String(m._owner ?? 'world'), callbackOf: String(m.messageId) });
274
+ }
@@ -0,0 +1,264 @@
1
+ // The QStash lane's catch-up (docs/contributing/architecture.md, "Moves no API call makes": time): before the lane answers
2
+ // any request on a writable World, everything the World clock has made due happens, in order, each at the moment it fell
3
+ // due: a schedule's cron fires and publishes its message, a wait times out, a message is delivered (or retried, or
4
+ // failed into the DLQ, its callbacks published), a cancelled message is marked CANCELLED, and a DLQ entry past its
5
+ // retention is removed. There is no scheduler: the World clock is read, never watched.
6
+ //
7
+ // Delivery (https://upstash.com/docs/qstash/howto/debug-logs, /features/retry, /features/callbacks, /features/dlq):
8
+ // - A message is delivered when due: its publish instant, plus `Upstash-Delay`, or its `Upstash-Not-Before`. Messages
9
+ // of one queue go one at a time in order ("The next message will wait for retries of the current one"), and a
10
+ // workflow run's messages go in the order the run made them.
11
+ // - The destination receives the body and the publish's `Content-Type`, every `Upstash-Forward-*` header with its prefix
12
+ // stripped, `Upstash-Message-Id`, `Upstash-Retried` ("0" first, then how many retries so far) and an
13
+ // `Upstash-Signature` signed with the account's current key (signing.ts); a workflow's delivery carries the run's steps
14
+ // and headers (workflows.ts).
15
+ // - "If your API does not respond with a success status code (2XX), we retry the request": retry n waits
16
+ // `min(86400, e ** (2.5*n))` seconds, or what the destination's `Retry-After` asks (seconds, a duration or a date, capped
17
+ // at a day), or the message's `Upstash-Retry-Delay` expression (milliseconds, `retried` from 0). A 489 with
18
+ // `Upstash-NonRetryable-Error: true` is not retried.
19
+ // - "If all retries are exhausted, the task has FAILED and the message will be moved to the DLQ", and its
20
+ // `Upstash-Failure-Callback` is called; its `Upstash-Callback` is called with the answer once delivered or failed. A
21
+ // callback is a message of its own, delivered, signed and retried the same way, and never called back itself.
22
+ // - DLQ retention: "Messages are deleted when their retention period expires" (/features/dlq; the pricing page's Pay as
23
+ // You Go column: DLQ 7 days; the Workflow DLQ "Pay-as-you-go: 1 week", https://upstash.com/docs/workflow/features/dlq).
24
+ //
25
+ // What a destination answers is the World's: a destination the World has stated an answer for (the destinations door,
26
+ // ../doors.ts) answers that, and any other is asked over HTTP where the World lets the request out (egress.ts); a
27
+ // sealed World refuses it, which is a failed attempt. Where the documentation stops and the lane decides: the account's
28
+ // plan is Pay as You Go; a callback is called once when the message is delivered or fails, not after every retry.
29
+ import type { SemanticsContext } from '@volter/world-core';
30
+ import { accountOf } from './account.ts';
31
+ import { acceptToUrlGroup, ensureQueue, storeMessage } from './messages.ts';
32
+ import { nextFire } from './schedules.ts';
33
+ import { failRun, failureCallSettled, rawSteps, timeOut, workflowHeaders } from './workflows.ts';
34
+ import { b64, iso, multiValue, nowMs, type Row } from './shared.ts';
35
+ import { post, type Outcome } from '../egress.ts';
36
+ import { sign } from '../signing.ts';
37
+
38
+ /** The World's word on what a destination answers, stored by `POST /_twin/destinations` (../doors.ts). */
39
+ export const DESTINATION = '_qstash_destination';
40
+ /** What a destination received, read at `GET /_twin/deliveries?to=` (../doors.ts). */
41
+ export const DELIVERY = '_qstash_delivery';
42
+
43
+ const DAY_MS = 86_400_000;
44
+ const DLQ_RETENTION_MS = 7 * DAY_MS;
45
+ const PENDING = new Set(['CREATED', 'ACTIVE', 'RETRY']);
46
+
47
+ /** The answer the World has stated for a delivery: of the statements whose URL prefixes the delivery's and whose `when`
48
+ * headers (if any) it carries, one that names headers before one that does not, then the longest prefix. */
49
+ function stated(ctx: SemanticsContext, url: string, sent: Record<string, string>): Outcome | undefined {
50
+ const holds = (d: Row): boolean => url.startsWith(String(d.url)) && Object.entries((d.when ?? {}) as Record<string, string>).every(([k, v]) => sent[k.toLowerCase()] === v);
51
+ const hit = ctx.rowsRaw(DESTINATION).filter(holds)
52
+ .sort((a, b) => Number(Object.keys(b.when ?? {}).length > 0) - Number(Object.keys(a.when ?? {}).length > 0) || String(b.url).length - String(a.url).length)[0];
53
+ return hit ? { status: Number(hit.status), body: String(hit.body ?? ''), headers: (hit.headers ?? {}) as Record<string, string> } : undefined;
54
+ }
55
+
56
+ /** QStash's backoff for retry n, in milliseconds. */
57
+ const backoffMs = (n: number): number => Math.round(Math.min(86_400, Math.exp(2.5 * n)) * 1000);
58
+
59
+ /** A `Retry-After` answer in milliseconds: seconds, a duration (`6m5s`) or a date, capped at a day. */
60
+ function retryAfterMs(headers: Record<string, string>, at: number): number | undefined {
61
+ const raw = headers['retry-after']?.trim();
62
+ return raw ? retryAfterValue(raw, at) : undefined;
63
+ }
64
+
65
+ /** A `Retry-After` value in milliseconds (https://upstash.com/docs/qstash/features/retry, Retry-After Headers). */
66
+ function retryAfterValue(raw: string, at: number): number | undefined {
67
+ const d = /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/.exec(raw);
68
+ const sec = /^\d+$/.test(raw) ? Number(raw) : d && raw !== '' ? Number(d[1] ?? 0) * 3600 + Number(d[2] ?? 0) * 60 + Number(d[3] ?? 0) : Number.isNaN(Date.parse(raw)) ? undefined : Math.max(0, (Date.parse(raw) - at) / 1000);
69
+ return sec === undefined ? undefined : Math.round(Math.min(86_400, sec) * 1000);
70
+ }
71
+
72
+ /** An `Upstash-Retry-Delay` expression's value in milliseconds: numbers, `retried`, `+ - * /`, parentheses and `pow sqrt
73
+ * abs exp floor ceil round min max` (the retry page's Custom Retry Delay); anything else is undefined. */
74
+ export function retryDelayMs(expr: string, retried: number): number | undefined {
75
+ const src = expr.replace(/\s+/g, '');
76
+ let i = 0;
77
+ const fns: Record<string, (...a: number[]) => number> = { pow: Math.pow, sqrt: Math.sqrt, abs: Math.abs, exp: Math.exp, floor: Math.floor, ceil: Math.ceil, round: Math.round, min: Math.min, max: Math.max };
78
+ const fail = (): never => { throw new Error('retryDelay'); };
79
+ const primary = (): number => {
80
+ if (src[i] === '(') { i += 1; const v = sum(); if (src[i] !== ')') fail(); i += 1; return v; }
81
+ const num = /^\d+(\.\d+)?/.exec(src.slice(i));
82
+ if (num) { i += num[0].length; return Number(num[0]); }
83
+ const id = /^[a-z]+/.exec(src.slice(i));
84
+ if (!id) return fail();
85
+ i += id[0].length;
86
+ if (id[0] === 'retried') return retried;
87
+ const fn = fns[id[0]];
88
+ if (!fn || src[i] !== '(') return fail();
89
+ i += 1;
90
+ const args = [sum()];
91
+ while (src[i] === ',') { i += 1; args.push(sum()); }
92
+ if (src[i] !== ')') fail();
93
+ i += 1;
94
+ return fn(...args);
95
+ };
96
+ const product = (): number => { let v = primary(); while (src[i] === '*' || src[i] === '/') { const op = src[i]; i += 1; const r = primary(); v = op === '*' ? v * r : v / r; } return v; };
97
+ const sum = (): number => { let v = product(); while (src[i] === '+' || src[i] === '-') { const op = src[i]; i += 1; const r = product(); v = op === '+' ? v + r : v - r; } return v; };
98
+ try { const v = sum(); return i === src.length && Number.isFinite(v) && v >= 0 ? v : undefined; } catch { return undefined; }
99
+ }
100
+
101
+ /** A queue delivers in order, as many at a time as its parallelism ("The number of parallel consumers consuming from the
102
+ * queue", the spec's Queue), and none while it is paused ("Pausing a queue stops the delivery of enqueued messages", the
103
+ * spec's pause). A message whose queue was deleted goes as a queue of one would. */
104
+ function queueLets(ctx: SemanticsContext, m: Row, all: Row[]): boolean {
105
+ const q = ctx.rowsRaw('Queue').find((r) => r.name === m.queueName);
106
+ if (q?.paused === true) return false;
107
+ const ahead = all.filter((o) => o.queueName === m.queueName && PENDING.has(String(o.state)) && Number(o._ordinal) < Number(m._ordinal)).length;
108
+ return ahead < Math.max(1, Number(q?.parallelism ?? 1));
109
+ }
110
+
111
+ /** Whether a pending message may go now: its queue lets it (queueLets), and it is the first undelivered of its run. */
112
+ function eligible(ctx: SemanticsContext, m: Row, all: Row[]): boolean {
113
+ if (m.state !== 'CREATED' && m.state !== 'RETRY') return false;
114
+ if (typeof m.queueName === 'string' && !queueLets(ctx, m, all)) return false;
115
+ if (typeof m._run === 'string') {
116
+ const run = ctx.rowsRaw('WorkflowRun').find((r) => r.workflowRunId === m._run);
117
+ if (!run || run.workflowState !== 'RUN_STARTED') return false;
118
+ if (all.some((o) => o._run === m._run && PENDING.has(String(o.state)) && Number(o._seq) < Number(m._seq))) return false;
119
+ }
120
+ return true;
121
+ }
122
+
123
+ /** What a catch-up did: the deliveries it made, by outcome (the drain door answers it). */
124
+ export type CatchUpReport = { delivered: Row[]; retried: Row[]; failed: Row[] };
125
+
126
+ type Due = { when: number; order: number; act: (at: SemanticsContext, report: CatchUpReport) => Promise<void> };
127
+
128
+ function nextDue(ctx: SemanticsContext, until: number): Due | undefined {
129
+ const due: Due[] = [];
130
+ for (const s of ctx.rowsRaw('Schedule')) {
131
+ if (typeof s.nextScheduleTime === 'number' && s.nextScheduleTime <= until) due.push({ when: s.nextScheduleTime, order: 0, act: (at) => fire(at, s) });
132
+ }
133
+ for (const w of ctx.rowsRaw('Waiter')) {
134
+ const when = Number(w.deadline) * 1000;
135
+ if (when <= until) due.push({ when, order: 1, act: (at) => timeOut(at, w) });
136
+ }
137
+ const all = ctx.rowsRaw('Message');
138
+ for (const m of all) {
139
+ const when = Number(m._due);
140
+ if (when > until) continue;
141
+ if (m.state === 'CANCEL_REQUESTED') due.push({ when, order: 2 + Number(m._ordinal) / 1e9, act: (at) => cancelled(at, m) });
142
+ else if (eligible(ctx, m, all)) due.push({ when, order: 3 + Number(m._ordinal) / 1e9, act: (at, report) => attempt(at, m, report) });
143
+ }
144
+ for (const resource of ['DLQMessage', 'WorkflowDLQMessage']) {
145
+ for (const e of ctx.rowsRaw(resource)) {
146
+ const when = Number(e._filed_at ?? e.createdAt) + DLQ_RETENTION_MS;
147
+ if (when <= until) due.push({ when, order: 4, act: (at) => at.write(resource, String(e.id), { deleted: true }, 'dlq.expired').then(() => undefined) });
148
+ }
149
+ }
150
+ return due.sort((a, b) => a.when - b.when || a.order - b.order)[0];
151
+ }
152
+
153
+ /** Run everything due by the World's instant, in order, each at its own moment. */
154
+ export async function catchUp(ctx: SemanticsContext): Promise<CatchUpReport> {
155
+ const until = nowMs(ctx);
156
+ const report: CatchUpReport = { delivered: [], retried: [], failed: [] };
157
+ // a message held behind another (the one before it in its queue or its run) goes once that one has left, so the World
158
+ // clock never runs back: each move happens at its own due time or at the last move's, whichever is later
159
+ let last = 0;
160
+ for (let n = 0; n < 5000; n += 1) {
161
+ const next = nextDue(ctx, until);
162
+ if (!next) break;
163
+ last = Math.max(last, next.when);
164
+ await next.act(await ctx.at(iso(last)), report);
165
+ }
166
+ return report;
167
+ }
168
+
169
+ /** A schedule's cron has fired: its message is published at that moment (a paused schedule's is not), and it is next
170
+ * due at the cron's following minute. */
171
+ async function fire(ctx: SemanticsContext, s: Row): Promise<void> {
172
+ const when = Number(s.nextScheduleTime);
173
+ if (s.isPaused !== true) {
174
+ const { 'upstash-cron': _cron, 'upstash-schedule-id': _id, 'upstash-queue-name': _q, ...headers } = (s._published ?? {}) as Record<string, string>;
175
+ const queue = typeof s._queue === 'string' ? s._queue : undefined;
176
+ if (queue) await ensureQueue(ctx, queue);
177
+ const destination = String(s.destination);
178
+ if (/^https?:\/\//.test(destination)) await storeMessage(ctx, { url: destination, headers, body: String(s.body ?? ''), owner: String(s._owner), scheduleId: String(s.scheduleId), queue });
179
+ else await acceptToUrlGroup(ctx, { destination, headers, body: String(s.body ?? ''), queue }, String(s._owner));
180
+ }
181
+ const next = nextFire(String(s.cron), when);
182
+ await ctx.write('Schedule', String(s.id), { lastScheduleTime: when, ...(next !== undefined ? { nextScheduleTime: next } : {}) }, 'schedule.fire');
183
+ }
184
+
185
+ /** A cancelled message reaches its next delivery time: it is CANCELLED, never delivered. */
186
+ async function cancelled(ctx: SemanticsContext, m: Row): Promise<void> {
187
+ const refused = ctx.legal('Message', 'state', 'delete_v2_messages_messageid', m.state, 'CANCELLED', String(m.id), 'time');
188
+ if (refused) throw new Error(refused.message);
189
+ await ctx.write('Message', String(m.id), { state: 'CANCELLED' }, 'message.cancelled');
190
+ }
191
+
192
+ /** Move a message, as its machine allows. */
193
+ async function move(ctx: SemanticsContext, m: Row, to: string, actor: 'time' | 'vendor', fields: Row): Promise<void> {
194
+ const refused = ctx.legal('Message', 'state', 'delete_v2_messages_messageid', m.state, to, String(m.id), actor);
195
+ if (refused) throw new Error(refused.message);
196
+ await ctx.write('Message', String(m.id), { ...fields, state: to }, `message.${to.toLowerCase()}`);
197
+ m.state = to;
198
+ }
199
+
200
+ /** One delivery attempt of a message, at the moment it is due. */
201
+ async function attempt(ctx: SemanticsContext, m: Row, report: CatchUpReport): Promise<void> {
202
+ const when = nowMs(ctx);
203
+ const tried = Number(m._attempts ?? 0);
204
+ await move(ctx, m, 'ACTIVE', 'time', { _attempts: tried });
205
+ const published = (m._published ?? {}) as Record<string, string>;
206
+ const step = typeof m._run === 'string';
207
+ const body = step ? JSON.stringify(rawSteps(ctx, m._run as string, Number(m._seq))) : String(m.body ?? '');
208
+ const base = published['upstash-workflow-runid'] !== undefined ? workflowHeaders(published) : {
209
+ ...(typeof m._content_type === 'string' ? { 'content-type': m._content_type } : {}),
210
+ ...Object.fromEntries(Object.entries(published).filter(([k]) => k.startsWith('upstash-forward-')).map(([k, v]) => [k.slice('upstash-forward-'.length), v])),
211
+ };
212
+ const url = String(m.url);
213
+ const key = accountOf(ctx, String(m._owner ?? 'world')).current;
214
+ const headers: Record<string, string> = {
215
+ ...base, 'upstash-message-id': String(m.messageId), 'upstash-retried': String(tried),
216
+ 'upstash-signature': sign(body, url, Math.floor(when / 1000), `jwt_${String(m.messageId)}_${tried}`, key),
217
+ };
218
+ const method = String(m.method ?? 'POST');
219
+ const outcome = stated(ctx, url, headers) ?? await post(url, method, headers, body);
220
+ await ctx.record(DELIVERY, { to: url, message_id: m.messageId, retried: tried, at: ctx.occurredAt, n: ctx.rowsRaw(DELIVERY).length, method, headers, body, status: outcome.status }, `delivery:${String(m.messageId)}:${tried}`);
221
+ const n = tried + 1;
222
+ const response = { _response_status: outcome.status, _response_body: outcome.body, _response_headers: outcome.headers };
223
+ if (outcome.status >= 200 && outcome.status < 300) {
224
+ await move(ctx, m, 'DELIVERED', 'vendor', { _attempts: n, ...response });
225
+ report.delivered.push({ messageId: m.messageId, url, status: outcome.status });
226
+ if (typeof m._callback_of === 'string') await failureCallSettled(ctx, m._callback_of, true);
227
+ else if (typeof m.callback === 'string') await callback(ctx, m, String(m.callback), outcome, n, undefined);
228
+ return;
229
+ }
230
+ const nonRetryable = outcome.status === 489 && outcome.headers['upstash-nonretryable-error'] === 'true';
231
+ if (n <= Number(m.maxRetries ?? 3) && !nonRetryable) {
232
+ const run = step ? ctx.rowsRaw('WorkflowRun').find((r) => r.workflowRunId === m._run) : undefined;
233
+ const expr = typeof m._retry_delay === 'string' ? m._retry_delay : typeof run?._retry_delay === 'string' ? run._retry_delay : undefined;
234
+ const wait = retryAfterMs(outcome.headers, when) ?? (expr !== undefined ? retryDelayMs(expr, tried) : undefined) ?? backoffMs(n);
235
+ await move(ctx, m, 'RETRY', 'vendor', { _attempts: n, _due: when + wait, ...response });
236
+ report.retried.push({ messageId: m.messageId, url, status: outcome.status, retried: n, nextDeliveryTime: when + wait });
237
+ return;
238
+ }
239
+ await move(ctx, m, 'FAILED', 'vendor', { _attempts: n, ...response });
240
+ report.failed.push({ messageId: m.messageId, url, status: outcome.status });
241
+ if (typeof m._callback_of === 'string') { await failureCallSettled(ctx, m._callback_of, false); return; }
242
+ if (step) { await failRun(ctx, { ...m, _attempts: n }, outcome); return; }
243
+ const dlqId = `${when}-${ctx.rowsRaw('DLQMessage', { withDeleted: true }).length}`;
244
+ const own = ctx.own(m);
245
+ await ctx.write('DLQMessage', dlqId, {
246
+ ...Object.fromEntries(Object.entries(own).filter(([k]) => !k.startsWith('_') && k !== 'state')),
247
+ dlqId, responseStatus: outcome.status, responseHeader: multiValue(outcome.headers), responseBody: outcome.body,
248
+ _filed_at: when, _published: published, _message_id: m.messageId, _owner: m._owner ?? 'world', ...(m._redact ? { _redact: m._redact } : {}),
249
+ }, 'dlq.create');
250
+ if (typeof m.failureCallback === 'string') await callback(ctx, m, String(m.failureCallback), outcome, n, dlqId);
251
+ if (typeof m.callback === 'string') await callback(ctx, m, String(m.callback), outcome, n, undefined);
252
+ }
253
+
254
+ /** A callback: "QStash calls your callback URL with the response"; its body is the callbacks page's (`status`, `header`,
255
+ * base64 `body`, `retried`, `maxRetries`, `sourceMessageId`, `url`, `method`, `sourceHeader`, base64 `sourceBody`,
256
+ * `notBefore`, `createdAt`, `callerIP`), and a failure callback's adds the `dlqId`. It is a message of its own. */
257
+ async function callback(ctx: SemanticsContext, m: Row, url: string, o: Outcome, attempts: number, dlqId: string | undefined): Promise<void> {
258
+ const body = JSON.stringify({
259
+ status: o.status, header: multiValue(o.headers), body: b64(o.body), retried: attempts - 1, maxRetries: Number(m.maxRetries ?? 3),
260
+ ...(dlqId ? { dlqId } : {}), sourceMessageId: m.messageId, url: m.url, method: m.method ?? 'POST', sourceHeader: m.header ?? {},
261
+ sourceBody: b64(String(m.body ?? '')), notBefore: m.notBefore, createdAt: m.createdAt, callerIP: '127.0.0.1',
262
+ });
263
+ await storeMessage(ctx, { url, headers: { 'content-type': 'application/json' }, body, owner: String(m._owner ?? 'world'), callbackOf: String(m.messageId) });
264
+ }
@@ -0,0 +1,4 @@
1
+ import type { Semantics, SemanticsContext } from '@volter/world-core';
2
+ import { type Row } from './shared.js';
3
+ export declare function dlqView(ctx: SemanticsContext, e: Row): Row;
4
+ export declare const dlqSemantics: Record<string, Semantics>;
@@ -0,0 +1,51 @@
1
+ import { ensureQueue, ownerFor, storeMessage } from "./messages.js";
2
+ import { idsOf, workflowDlq } from "./workflows.js";
3
+ import { matchesFilters, MESSAGE_FIELDS } from "./shared.js";
4
+ import { redacted } from "./messages.js";
5
+ const DLQ_FIELDS = [...MESSAGE_FIELDS, 'dlqId', 'responseStatus', 'responseHeader', 'responseBody', 'responseBodyBase64'];
6
+ export function dlqView(ctx, e) {
7
+ const own = ctx.own(e);
8
+ return redacted(Object.fromEntries(DLQ_FIELDS.filter((k) => own[k] !== undefined && own[k] !== null).map((k) => [k, own[k]])), e);
9
+ }
10
+ /** QStash's DLQ fields its list and bulk actions filter on, by query parameter (the spec's list and retry parameters). */
11
+ const FILTERS = { url: 'url', topicName: 'topicName', scheduleId: 'scheduleId', queueName: 'queueName', responseStatus: 'responseStatus', label: 'labels', flowControlKey: 'flowControlKey', messageId: 'messageId', callerIp: 'callerIP' };
12
+ /** The account's entries a request names: by `dlqIds`, else by its filters (all of them when it names none), oldest first. */
13
+ function selected(ctx) {
14
+ const owner = ownerFor(ctx);
15
+ const mine = ctx.rowsRaw('DLQMessage').filter((e) => e._owner === owner);
16
+ const ids = idsOf(ctx, 'dlqIds');
17
+ return (ids.length ? mine.filter((e) => ids.includes(String(e.dlqId))) : mine.filter((e) => matchesFilters(ctx, e, FILTERS, '_filed_at')))
18
+ .sort((a, b) => Number(a._filed_at) - Number(b._filed_at));
19
+ }
20
+ /** GET /v2/dlq: QStash's failed messages, the latest to fail first, `{messages}` ("If no cursor is returned, you have reached the
21
+ * end"); with `source=workflow`, the Workflow DLQ (the spec patch, from @upstash/workflow's `client.dlq.list`). */
22
+ const list = async (ctx) => {
23
+ if (new URL(ctx.call.request.url).searchParams.get('source') === 'workflow')
24
+ return workflowDlq(ctx);
25
+ const count = Number(new URL(ctx.call.request.url).searchParams.get('count') ?? 0);
26
+ const all = selected(ctx).reverse();
27
+ return ctx.reply({ messages: (count > 0 ? all.slice(0, count) : all).map((e) => dlqView(ctx, e)) });
28
+ };
29
+ /** POST /v2/dlq/retry: each entry the request names (by `dlqIds`, by its filters, or all of them) is republished as a new
30
+ * message (its destination, body and headers, in its queue) and leaves the DLQ; answered 201 `{responses: [{messageId}]}`.
31
+ * An id the DLQ does not hold answers 404 (the spec's 404). */
32
+ const retry = async (ctx) => {
33
+ const ids = idsOf(ctx, 'dlqIds');
34
+ const entries = selected(ctx);
35
+ const missing = ids.find((id) => !entries.some((e) => e.dlqId === id));
36
+ if (missing)
37
+ return ctx.refuse({ status: 404, message: `DLQ message ${missing} not found` });
38
+ const responses = [];
39
+ for (const e of entries) {
40
+ const published = (e._published ?? {});
41
+ const queue = typeof e.queueName === 'string' ? e.queueName : undefined;
42
+ if (queue)
43
+ await ensureQueue(ctx, queue);
44
+ const { 'upstash-delay': _delay, 'upstash-not-before': _notBefore, ...headers } = published;
45
+ const messageId = await storeMessage(ctx, { url: String(e.url), headers, body: String(e.body ?? ''), owner: ownerFor(ctx), queue });
46
+ await ctx.write('DLQMessage', String(e.id), { deleted: true }, 'dlq.retried');
47
+ responses.push({ messageId });
48
+ }
49
+ return ctx.reply({ responses }, 201);
50
+ };
51
+ export const dlqSemantics = { get_v2_dlq: list, post_v2_dlq_retry: retry };