@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,61 @@
1
+ // QStash's dead letter queue: "QStash automatically retries messages that fail due to a temporary issue but eventually
2
+ // stops and moves the message to a dead letter queue to be handled manually." "Retry - Republish the message and remove
3
+ // it from the dead letter queue. Republished messages are just like any other message and will be retried automatically if
4
+ // they fail." (https://upstash.com/docs/qstash/features/dlq). A message is filed here by the catch-up when its retries are
5
+ // spent (delivery.ts); an entry leaves when it is retried, or when its retention ends.
6
+ import type { Semantics, SemanticsContext } from '@volter/world-core';
7
+ import { ensureQueue, ownerFor, storeMessage } from './messages.ts';
8
+ import { idsOf, workflowDlq } from './workflows.ts';
9
+ import { matchesFilters, MESSAGE_FIELDS, type Row } from './shared.ts';
10
+ import { redacted } from './messages.ts';
11
+
12
+ const DLQ_FIELDS = [...MESSAGE_FIELDS, 'dlqId', 'responseStatus', 'responseHeader', 'responseBody', 'responseBodyBase64'];
13
+
14
+ export function dlqView(ctx: SemanticsContext, e: Row): Row {
15
+ const own = ctx.own(e);
16
+ return redacted(Object.fromEntries(DLQ_FIELDS.filter((k) => own[k] !== undefined && own[k] !== null).map((k) => [k, own[k]])), e);
17
+ }
18
+
19
+ /** QStash's DLQ fields its list and bulk actions filter on, by query parameter (the spec's list and retry parameters). */
20
+ const FILTERS = { url: 'url', topicName: 'topicName', scheduleId: 'scheduleId', queueName: 'queueName', responseStatus: 'responseStatus', label: 'labels', flowControlKey: 'flowControlKey', messageId: 'messageId', callerIp: 'callerIP' };
21
+
22
+ /** The account's entries a request names: by `dlqIds`, else by its filters (all of them when it names none), oldest first. */
23
+ function selected(ctx: SemanticsContext): Row[] {
24
+ const owner = ownerFor(ctx);
25
+ const mine = ctx.rowsRaw('DLQMessage').filter((e) => e._owner === owner);
26
+ const ids = idsOf(ctx, 'dlqIds');
27
+ return (ids.length ? mine.filter((e) => ids.includes(String(e.dlqId))) : mine.filter((e) => matchesFilters(ctx, e, FILTERS, '_filed_at')))
28
+ .sort((a, b) => Number(a._filed_at) - Number(b._filed_at));
29
+ }
30
+
31
+ /** GET /v2/dlq: QStash's failed messages, the latest to fail first, `{messages}` ("If no cursor is returned, you have reached the
32
+ * end"); with `source=workflow`, the Workflow DLQ (the spec patch, from @upstash/workflow's `client.dlq.list`). */
33
+ const list: Semantics = async (ctx) => {
34
+ if (new URL(ctx.call.request.url).searchParams.get('source') === 'workflow') return workflowDlq(ctx);
35
+ const count = Number(new URL(ctx.call.request.url).searchParams.get('count') ?? 0);
36
+ const all = selected(ctx).reverse();
37
+ return ctx.reply({ messages: (count > 0 ? all.slice(0, count) : all).map((e) => dlqView(ctx, e)) });
38
+ };
39
+
40
+ /** POST /v2/dlq/retry: each entry the request names (by `dlqIds`, by its filters, or all of them) is republished as a new
41
+ * message (its destination, body and headers, in its queue) and leaves the DLQ; answered 201 `{responses: [{messageId}]}`.
42
+ * An id the DLQ does not hold answers 404 (the spec's 404). */
43
+ const retry: Semantics = async (ctx) => {
44
+ const ids = idsOf(ctx, 'dlqIds');
45
+ const entries = selected(ctx);
46
+ const missing = ids.find((id) => !entries.some((e) => e.dlqId === id));
47
+ if (missing) return ctx.refuse({ status: 404, message: `DLQ message ${missing} not found` });
48
+ const responses: Row[] = [];
49
+ for (const e of entries as Row[]) {
50
+ const published = (e._published ?? {}) as Record<string, string>;
51
+ const queue = typeof e.queueName === 'string' ? e.queueName : undefined;
52
+ if (queue) await ensureQueue(ctx, queue);
53
+ const { 'upstash-delay': _delay, 'upstash-not-before': _notBefore, ...headers } = published;
54
+ const messageId = await storeMessage(ctx, { url: String(e.url), headers, body: String(e.body ?? ''), owner: ownerFor(ctx), queue });
55
+ await ctx.write('DLQMessage', String(e.id), { deleted: true }, 'dlq.retried');
56
+ responses.push({ messageId });
57
+ }
58
+ return ctx.reply({ responses }, 201);
59
+ };
60
+
61
+ export const dlqSemantics: Record<string, Semantics> = { get_v2_dlq: list, post_v2_dlq_retry: retry };
@@ -0,0 +1,2 @@
1
+ import type { Semantics } from '@volter/world-core';
2
+ export declare const qstashSemantics: Record<string, Semantics>;
@@ -0,0 +1,10 @@
1
+ import { dlqSemantics } from "./dlq.js";
2
+ import { keySemantics } from "./keys.js";
3
+ import { messageSemantics } from "./messages.js";
4
+ import { queueSemantics } from "./queues.js";
5
+ import { scheduleSemantics } from "./schedules.js";
6
+ import { workflowSemantics } from "./workflows.js";
7
+ import { urlGroupSemantics } from "./urlgroups.js";
8
+ export const qstashSemantics = {
9
+ ...messageSemantics, ...queueSemantics, ...scheduleSemantics, ...dlqSemantics, ...keySemantics, ...workflowSemantics, ...urlGroupSemantics,
10
+ };
@@ -0,0 +1,13 @@
1
+ // The QStash lane's handlers, by operationId (the IR's `method_path` slugs: neither document names its operations).
2
+ import type { Semantics } from '@volter/world-core';
3
+ import { dlqSemantics } from './dlq.ts';
4
+ import { keySemantics } from './keys.ts';
5
+ import { messageSemantics } from './messages.ts';
6
+ import { queueSemantics } from './queues.ts';
7
+ import { scheduleSemantics } from './schedules.ts';
8
+ import { workflowSemantics } from './workflows.ts';
9
+ import { urlGroupSemantics } from './urlgroups.ts';
10
+
11
+ export const qstashSemantics: Record<string, Semantics> = {
12
+ ...messageSemantics, ...queueSemantics, ...scheduleSemantics, ...dlqSemantics, ...keySemantics, ...workflowSemantics, ...urlGroupSemantics,
13
+ };
@@ -0,0 +1,2 @@
1
+ import type { Semantics } from '@volter/world-core';
2
+ export declare const keySemantics: Record<string, Semantics>;
@@ -0,0 +1,9 @@
1
+ import { accountOf, rollKeys } from "./account.js";
2
+ import { ownerFor } from "./messages.js";
3
+ export const keySemantics = {
4
+ get_v2_keys: async (ctx) => {
5
+ const a = accountOf(ctx, ownerFor(ctx));
6
+ return ctx.reply({ current: a.current, next: a.next });
7
+ },
8
+ post_v2_keys_rotate: async (ctx) => ctx.reply(await rollKeys(ctx, ownerFor(ctx))),
9
+ };
@@ -0,0 +1,14 @@
1
+ // The account's signing keys (account.ts): GET /v2/keys reads them, POST /v2/keys/rotate rolls them. "When you roll your
2
+ // keys, the current key will be replaced with the next key and a new next key will be generated."
3
+ // (https://upstash.com/docs/qstash/howto/roll-signing-keys); both answer `SigningKeys` `{current, next}`.
4
+ import type { Semantics } from '@volter/world-core';
5
+ import { accountOf, rollKeys } from './account.ts';
6
+ import { ownerFor } from './messages.ts';
7
+
8
+ export const keySemantics: Record<string, Semantics> = {
9
+ get_v2_keys: async (ctx) => {
10
+ const a = accountOf(ctx, ownerFor(ctx));
11
+ return ctx.reply({ current: a.current, next: a.next });
12
+ },
13
+ post_v2_keys_rotate: async (ctx) => ctx.reply(await rollKeys(ctx, ownerFor(ctx))),
14
+ };
@@ -0,0 +1,74 @@
1
+ import type { Semantics, SemanticsContext } from '@volter/world-core';
2
+ import { type Row } from './shared.js';
3
+ /** How QStash retries by default: "By default, we retry a failed delivery 3 times." (https://upstash.com/docs/qstash/features/retry) */
4
+ export declare const DEFAULT_RETRIES = 3;
5
+ /** The account the request acts as (the lane's gate has admitted it). */
6
+ export declare function ownerFor(ctx: SemanticsContext): string;
7
+ /** One message as published: its destination, its headers (lower-cased) and its body, and the queue it is enqueued in. */
8
+ export type Publication = {
9
+ destination: string;
10
+ headers: Record<string, string>;
11
+ body: string;
12
+ queue?: string | undefined;
13
+ };
14
+ /** What a publish answers for one message: its id, and whether it was a duplicate. */
15
+ export type Accepted = {
16
+ messageId: string;
17
+ deduplicated?: boolean;
18
+ } | Array<{
19
+ messageId: string;
20
+ url: string;
21
+ deduplicated?: boolean;
22
+ }> | {
23
+ error: string;
24
+ status: number;
25
+ };
26
+ /** The `Upstash-Forward-*` headers, their prefix stripped: what the destination receives. */
27
+ export declare function forwarded(headers: Record<string, string>): Record<string, string>;
28
+ /** A message's `header`: the headers sent to the API, its Content-Type and the ones it forwards, each name in its canonical
29
+ * form, as the message example of https://upstash.com/docs/qstash/overall/llms-txt answers them
30
+ * (`"header": { "Content-Type": ["application/json"] }`). */
31
+ export declare function sentHeader(headers: Record<string, string>): Record<string, string[]>;
32
+ /** Store one message: the vendor's Message fields, its state (CREATED) and what the lane keeps to deliver it. */
33
+ export declare function storeMessage(ctx: SemanticsContext, p: {
34
+ url: string;
35
+ headers: Record<string, string>;
36
+ body: string;
37
+ owner: string;
38
+ queue?: string | undefined;
39
+ scheduleId?: string | undefined;
40
+ callbackOf?: string | undefined;
41
+ run?: {
42
+ id: string;
43
+ seq: number;
44
+ call: string;
45
+ initial: boolean;
46
+ } | undefined;
47
+ retries?: number | undefined;
48
+ due?: number | undefined;
49
+ topic?: {
50
+ name: string;
51
+ endpoint?: string | undefined;
52
+ } | undefined;
53
+ }): Promise<string>;
54
+ /** A message's labels: `Upstash-Label` carries one, or several joined with commas (@upstash/qstash's `serializeLabel`:
55
+ * `label.join(",")`); the Message schema answers the first as `label` and all as `labels`. */
56
+ export declare function labelsOf(h: Record<string, string>): Row;
57
+ /** Accept one plain message (no workflow): refuse a destination that is not a URL, answer a duplicate's first id, and
58
+ * make the queue a first enqueue names. "Messages can be deduplicated... In case a message is a duplicate, we will accept
59
+ * the request and return the messageID of the existing message." (https://upstash.com/docs/qstash/features/deduplication) */
60
+ export declare function acceptPlain(ctx: SemanticsContext, p: Publication, owner: string): Promise<Accepted>;
61
+ /** A message to a URL group: "If the destination is a URL Group, a new message will be created for each endpoint in the
62
+ * group." (the spec's publish); the answer lists each endpoint's message and URL (`PublishToUrlGroupResponse`). A name the
63
+ * account has no URL group of answers 404 (the spec's publish 404 is the destination not found). */
64
+ export declare function acceptToUrlGroup(ctx: SemanticsContext, p: Publication, owner: string): Promise<Accepted>;
65
+ /** A queue a first enqueue names: "If the queue does not exist, it will be created automatically with default
66
+ * parallelism." (the spec's enqueue); a queue delivers in order, one message at a time
67
+ * (https://upstash.com/docs/qstash/features/queues). */
68
+ export declare function ensureQueue(ctx: SemanticsContext, name: string): Promise<void>;
69
+ export declare const messageSemantics: Record<string, Semantics>;
70
+ /** A message's fields as the API answers them after redaction: "QStash allows you to redact specific fields so they appear
71
+ * as `REDACTED:<SHA256>` in the dashboard and API. The original values are still used when delivering messages to your
72
+ * endpoint." (https://upstash.com/docs/qstash/howto/redact-fields); `Upstash-Redact-Fields` names `body`, `header` (all)
73
+ * or `header[<name>]`. Where the page stops and the lane decides: the SHA-256 is of the value, in hex. */
74
+ export declare function redacted(view: Row, row: Row): Row;
@@ -0,0 +1,233 @@
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 { gate } from "./account.js";
8
+ import { acceptWorkflowMessage, isWorkflowMessage } from "./workflows.js";
9
+ import { durationSeconds, everHeld, headersOf, lowerKeys, MESSAGE_FIELDS, mintId, multiValue, nowMs, QUEUE_NAME, refuse, vendorFields } from "./shared.js";
10
+ /** How QStash retries by default: "By default, we retry a failed delivery 3 times." (https://upstash.com/docs/qstash/features/retry) */
11
+ export const DEFAULT_RETRIES = 3;
12
+ /** The account the request acts as (the lane's gate has admitted it). */
13
+ export function ownerFor(ctx) {
14
+ const g = gate(ctx, ctx.call.request);
15
+ return 'owner' in g ? g.owner : 'world';
16
+ }
17
+ /** The `Upstash-Forward-*` headers, their prefix stripped: what the destination receives. */
18
+ export function forwarded(headers) {
19
+ const out = {};
20
+ for (const [k, v] of Object.entries(headers))
21
+ if (k.startsWith('upstash-forward-'))
22
+ out[k.slice('upstash-forward-'.length)] = v;
23
+ return out;
24
+ }
25
+ /** A message's `header`: the headers sent to the API, its Content-Type and the ones it forwards, each name in its canonical
26
+ * form, as the message example of https://upstash.com/docs/qstash/overall/llms-txt answers them
27
+ * (`"header": { "Content-Type": ["application/json"] }`). */
28
+ export function sentHeader(headers) {
29
+ const canonical = (k) => k.split('-').map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()).join('-');
30
+ const sent = { ...(headers['content-type'] ? { 'content-type': headers['content-type'] } : {}), ...forwarded(headers) };
31
+ return multiValue(Object.fromEntries(Object.entries(sent).map(([k, v]) => [canonical(k), v])));
32
+ }
33
+ /** `Upstash-Flow-Control-Value`'s settings ("parallelism=15, rate=10, period=1m"). */
34
+ function flowControl(value) {
35
+ const out = {};
36
+ for (const part of (value ?? '').split(',')) {
37
+ const [k, v] = part.split('=').map((s) => s.trim());
38
+ if (k === 'parallelism' || k === 'rate')
39
+ out[k] = Number(v);
40
+ if (k === 'period')
41
+ out.period = durationSeconds(v) ?? v;
42
+ }
43
+ return out;
44
+ }
45
+ /** Store one message: the vendor's Message fields, its state (CREATED) and what the lane keeps to deliver it. */
46
+ export async function storeMessage(ctx, p) {
47
+ const h = p.headers;
48
+ const ordinal = everHeld(ctx, 'Message');
49
+ const id = mintId(ctx, 'msg_', 'message', ordinal);
50
+ const created = nowMs(ctx);
51
+ const notBefore = h['upstash-not-before'] !== undefined && /^\d+$/.test(h['upstash-not-before']) ? Number(h['upstash-not-before']) * 1000 : undefined;
52
+ const delay = durationSeconds(h['upstash-delay']) ?? 0;
53
+ const due = p.due ?? notBefore ?? created + delay * 1000;
54
+ const retries = p.retries ?? (h['upstash-retries'] !== undefined && /^\d+$/.test(h['upstash-retries']) ? Number(h['upstash-retries']) : DEFAULT_RETRIES);
55
+ const fields = {
56
+ messageId: id, url: p.url, method: h['upstash-method'] ?? 'POST', header: sentHeader(h), body: p.body, maxRetries: retries,
57
+ notBefore: due, createdAt: created,
58
+ ...(h['upstash-callback'] ? { callback: h['upstash-callback'] } : {}),
59
+ ...(h['upstash-failure-callback'] ? { failureCallback: h['upstash-failure-callback'] } : {}),
60
+ ...(p.queue ? { queueName: p.queue } : {}),
61
+ ...(p.scheduleId ? { scheduleId: p.scheduleId } : {}),
62
+ ...labelsOf(h),
63
+ ...(p.topic ? { topicName: p.topic.name, ...(p.topic.endpoint ? { endpointName: p.topic.endpoint } : {}) } : {}),
64
+ ...(h['upstash-flow-control-key'] ? { flowControlKey: h['upstash-flow-control-key'], ...flowControl(h['upstash-flow-control-value']) } : {}),
65
+ state: 'CREATED',
66
+ _owner: p.owner, _due: due, _attempts: 0, _ordinal: ordinal, _published: h,
67
+ ...(h['content-type'] ? { _content_type: h['content-type'] } : {}),
68
+ ...(h['upstash-retry-delay'] ? { _retry_delay: h['upstash-retry-delay'] } : {}),
69
+ ...(h['upstash-deduplication-id'] ? { _dedup: h['upstash-deduplication-id'] } : h['upstash-content-based-deduplication'] === 'true' ? { _dedup: contentKey(p.url, p.body, h) } : {}),
70
+ ...(h['upstash-timeout'] ? { _timeout: durationSeconds(h['upstash-timeout']) ?? h['upstash-timeout'] } : {}),
71
+ ...(h['upstash-redact-fields'] ? { _redact: h['upstash-redact-fields'] } : {}),
72
+ ...(p.callbackOf ? { _callback_of: p.callbackOf } : {}),
73
+ ...(p.run ? { _run: p.run.id, _seq: p.run.seq, _call: p.run.call, _initial: p.run.initial } : {}),
74
+ };
75
+ await ctx.write('Message', id, fields, 'message.create');
76
+ return id;
77
+ }
78
+ /** A message's labels: `Upstash-Label` carries one, or several joined with commas (@upstash/qstash's `serializeLabel`:
79
+ * `label.join(",")`); the Message schema answers the first as `label` and all as `labels`. */
80
+ export function labelsOf(h) {
81
+ const labels = (h['upstash-label'] ?? '').split(',').map((l) => l.trim()).filter(Boolean);
82
+ return labels.length ? { label: labels[0], labels } : {};
83
+ }
84
+ /** Content-based deduplication: "If you want to deduplicate messages automatically, you can set the
85
+ * `Upstash-Content-Based-Deduplication` header to `true`." (https://upstash.com/docs/qstash/features/deduplication). Where
86
+ * the page stops and the lane decides: a message's content is its destination, its body and the headers it forwards. */
87
+ function contentKey(url, body, h) {
88
+ const fwd = Object.entries(h).filter(([k]) => k.startsWith('upstash-forward-')).sort();
89
+ return `content:${createHash('sha256').update(JSON.stringify([url, body, fwd])).digest('hex')}`;
90
+ }
91
+ /** "The deduplication window is 10 minutes. After that, messages with the same ID or content can be sent again."
92
+ * (https://upstash.com/docs/qstash/features/deduplication) */
93
+ const DEDUP_WINDOW_MS = 10 * 60_000;
94
+ /** Accept one plain message (no workflow): refuse a destination that is not a URL, answer a duplicate's first id, and
95
+ * make the queue a first enqueue names. "Messages can be deduplicated... In case a message is a duplicate, we will accept
96
+ * the request and return the messageID of the existing message." (https://upstash.com/docs/qstash/features/deduplication) */
97
+ export async function acceptPlain(ctx, p, owner) {
98
+ if (p.queue !== undefined && !QUEUE_NAME.test(p.queue))
99
+ return { error: 'Queue name is invalid. Queue names can only contain alphanumeric characters, hyphens, periods, and underscores.', status: 400 };
100
+ if (!/^https?:\/\//.test(p.destination))
101
+ return acceptToUrlGroup(ctx, p, owner);
102
+ const dedup = p.headers['upstash-deduplication-id'] ?? (p.headers['upstash-content-based-deduplication'] === 'true' ? contentKey(p.destination, p.body, p.headers) : undefined);
103
+ if (dedup) {
104
+ const prior = ctx.rowsRaw('Message').find((m) => m._dedup === dedup && m._owner === owner && nowMs(ctx) - Number(m.createdAt) < DEDUP_WINDOW_MS);
105
+ if (prior)
106
+ return { messageId: String(prior.messageId), deduplicated: true };
107
+ }
108
+ if (p.queue !== undefined)
109
+ await ensureQueue(ctx, p.queue);
110
+ const messageId = await storeMessage(ctx, { url: p.destination, headers: p.headers, body: p.body, owner, queue: p.queue });
111
+ return { messageId };
112
+ }
113
+ /** A message to a URL group: "If the destination is a URL Group, a new message will be created for each endpoint in the
114
+ * group." (the spec's publish); the answer lists each endpoint's message and URL (`PublishToUrlGroupResponse`). A name the
115
+ * account has no URL group of answers 404 (the spec's publish 404 is the destination not found). */
116
+ export async function acceptToUrlGroup(ctx, p, owner) {
117
+ const group = ctx.rowsRaw('URLGroup').find((g) => g.name === p.destination && g._owner === owner);
118
+ if (!group)
119
+ return { error: `URL Group ${p.destination} not found`, status: 404 };
120
+ if (p.queue !== undefined)
121
+ await ensureQueue(ctx, p.queue);
122
+ const out = [];
123
+ for (const e of (group.endpoints ?? [])) {
124
+ 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 } });
125
+ out.push({ messageId, url: e.url });
126
+ }
127
+ return out;
128
+ }
129
+ /** A queue a first enqueue names: "If the queue does not exist, it will be created automatically with default
130
+ * parallelism." (the spec's enqueue); a queue delivers in order, one message at a time
131
+ * (https://upstash.com/docs/qstash/features/queues). */
132
+ export async function ensureQueue(ctx, name) {
133
+ if (ctx.rowsRaw('Queue').some((q) => q.name === name))
134
+ return;
135
+ const at = nowMs(ctx);
136
+ await ctx.write('Queue', name, { name, createdAt: at, updatedAt: at, parallelism: 1, paused: false, lag: 0 }, 'queue.create');
137
+ }
138
+ async function accept(ctx, p, owner) {
139
+ return invalidDestination(ctx, p, owner) ?? (isWorkflowMessage(p.headers) ? acceptWorkflowMessage(ctx, p, owner) : acceptPlain(ctx, p, owner));
140
+ }
141
+ /** "Destination can either be a valid URL where the message gets sent to, or a URL Group name." (the spec's publish): a
142
+ * URL that does not parse (a page's `https://<YOUR_WORKFLOW_ENDPOINT>/<YOUR-WORKFLOW-ROUTE>`) is refused 400, and a
143
+ * 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
144
+ * looked up by acceptToUrlGroup). */
145
+ function invalidDestination(ctx, p, owner) {
146
+ if (/^https?:\/\//i.test(p.destination))
147
+ return URL.canParse(p.destination) ? undefined : { error: `invalid destination url: ${p.destination}`, status: 400 };
148
+ const group = ctx.rowsRaw('URLGroup').some((g) => g.name === p.destination && g._owner === owner);
149
+ return isWorkflowMessage(p.headers) && !group ? { error: `URL Group ${p.destination} not found`, status: 404 } : undefined;
150
+ }
151
+ const answer = (ctx, a) => !Array.isArray(a) && 'error' in a ? refuse(ctx, a.status, a.error) : ctx.reply(a, !Array.isArray(a) && a.deduplicated ? 202 : 200);
152
+ /** POST /v2/publish/{destination}. */
153
+ const publish = async (ctx) => {
154
+ const destination = String(ctx.call.params.destination ?? '');
155
+ return answer(ctx, await accept(ctx, { destination, headers: headersOf(ctx), body: ctx.text ?? '' }, ownerFor(ctx)));
156
+ };
157
+ /** POST /v2/enqueue/{queueName}/{destination}. */
158
+ const enqueue = async (ctx) => {
159
+ const destination = String(ctx.call.params.destination ?? '');
160
+ return answer(ctx, await accept(ctx, { destination, headers: headersOf(ctx), body: ctx.text ?? '', queue: String(ctx.call.params.queueName ?? '') }, ownerFor(ctx)));
161
+ };
162
+ /** POST /v2/batch: each entry `{destination, headers, body, queue}` is published as its own message, in order, and the
163
+ * answer lists each one's `PublishResponse`. Where the documents stop and the lane decides: an entry that cannot be
164
+ * published refuses the whole batch with its reason, before any later entry is published. */
165
+ const batch = async (ctx) => {
166
+ if (!Array.isArray(ctx.body))
167
+ return refuse(ctx, 400, 'The request body must be a JSON array of messages.');
168
+ const owner = ownerFor(ctx);
169
+ const out = [];
170
+ for (const entry of ctx.body) {
171
+ const destination = typeof entry?.destination === 'string' ? entry.destination : '';
172
+ if (!destination)
173
+ return refuse(ctx, 400, 'destination is required');
174
+ const body = typeof entry.body === 'string' ? entry.body : entry.body === undefined ? '' : JSON.stringify(entry.body);
175
+ const a = await accept(ctx, { destination, headers: lowerKeys(entry.headers), body, ...(typeof entry.queue === 'string' ? { queue: entry.queue } : {}) }, owner);
176
+ if (!Array.isArray(a) && 'error' in a)
177
+ return refuse(ctx, a.status, a.error);
178
+ out.push(a);
179
+ }
180
+ return ctx.reply(out);
181
+ };
182
+ /** A message still to be delivered: "Messages are removed from the database shortly after they're delivered, so you will
183
+ * not be able to retrieve a message after." (the spec's get). The lane answers 404 once a message is delivered, failed
184
+ * or cancelled. */
185
+ function pending(ctx, id) {
186
+ const m = ctx.rowsRaw('Message').find((r) => r.messageId === id);
187
+ return m && ['CREATED', 'ACTIVE', 'RETRY', 'CANCEL_REQUESTED'].includes(String(m.state)) ? m : undefined;
188
+ }
189
+ /** GET /v2/messages/{messageId}. */
190
+ const getMessage = async (ctx) => {
191
+ const m = pending(ctx, String(ctx.call.params.messageId));
192
+ return m ? ctx.reply(redacted(vendorFields(ctx, m, MESSAGE_FIELDS), m)) : refuse(ctx, 404, 'Message not found.');
193
+ };
194
+ /** DELETE /v2/messages/{messageId}: "Cancel a pending message"; it is CANCELLED at its next delivery time (debug-logs),
195
+ * never delivered. Answered 202 with no body (the spec's 202). */
196
+ const cancelMessage = async (ctx) => {
197
+ const id = String(ctx.call.params.messageId);
198
+ const m = ctx.rowsRaw('Message').find((r) => r.messageId === id);
199
+ if (!m)
200
+ return refuse(ctx, 404, 'Message not found.');
201
+ const refused = ctx.legal('Message', 'state', 'delete_v2_messages_messageid', m.state, 'CANCEL_REQUESTED', id);
202
+ if (refused)
203
+ return ctx.refuse(refused);
204
+ await ctx.write('Message', String(m.id), { state: 'CANCEL_REQUESTED' }, 'message.cancel');
205
+ return new Response(null, { status: 202 });
206
+ };
207
+ export const messageSemantics = {
208
+ post_v2_publish_destination: publish,
209
+ post_v2_enqueue_queuename_destination: enqueue,
210
+ post_v2_batch: batch,
211
+ get_v2_messages_messageid: getMessage,
212
+ delete_v2_messages_messageid: cancelMessage,
213
+ };
214
+ /** A message's fields as the API answers them after redaction: "QStash allows you to redact specific fields so they appear
215
+ * as `REDACTED:<SHA256>` in the dashboard and API. The original values are still used when delivering messages to your
216
+ * endpoint." (https://upstash.com/docs/qstash/howto/redact-fields); `Upstash-Redact-Fields` names `body`, `header` (all)
217
+ * or `header[<name>]`. Where the page stops and the lane decides: the SHA-256 is of the value, in hex. */
218
+ export function redacted(view, row) {
219
+ const spec = typeof row._redact === 'string' ? row._redact : '';
220
+ if (!spec)
221
+ return view;
222
+ const parts = spec.split(',').map((x) => x.trim());
223
+ const sha = (v) => `REDACTED:${createHash('sha256').update(v).digest('hex')}`;
224
+ const out = { ...view };
225
+ if (parts.includes('body') && typeof out.body === 'string')
226
+ out.body = sha(out.body);
227
+ const all = parts.includes('header') || parts.includes('headers');
228
+ const names = parts.map((x) => /^headers?\[(.+)\]$/.exec(x)?.[1]?.toLowerCase()).filter((x) => !!x);
229
+ if ((all || names.length) && out.header && typeof out.header === 'object') {
230
+ out.header = Object.fromEntries(Object.entries(out.header).map(([k, v]) => [k, all || names.includes(k.toLowerCase()) ? v.map(sha) : v]));
231
+ }
232
+ return out;
233
+ }