@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,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,132 @@
1
+ // Schedules: "you can create a schedule, and we will publish the message in the given period. To create a schedule, you
2
+ // simply need to add the `Upstash-Cron` header to your `publish` request." "By default, we evaluate cron expressions in
3
+ // `UTC`." (https://upstash.com/docs/qstash/features/schedules). The catch-up publishes a schedule's message each time
4
+ // its cron fires (delivery.ts); a paused schedule's trigger "will simply be ignored" (the spec's pause).
5
+ //
6
+ // "If you want to run your schedule in a specific timezone, see the section on Timezones": a `CRON_TZ=<IANA zone>` prefix
7
+ // evaluates the expression in that zone ("CRON_TZ=America/New_York 0 4 * * *", the same page). `Upstash-Queue-Name` enqueues
8
+ // each message the schedule publishes into that queue (the page's "Scheduling to a Queue").
9
+ // Where the documentation stops and the lane decides: the cron is the five standard fields (minute, hour, day of month,
10
+ // month, day of week) with `*`, lists, ranges and steps; a zone the runtime does not know answers 400; a
11
+ // schedule is first due at its cron's first minute after it is made ("It can take up to 60 seconds for the schedule to be
12
+ // loaded on an active node and triggered for the first time": the lane loads it at once).
13
+ import type { Semantics, SemanticsContext } from '@volter/world-core';
14
+ import { ownerFor, labelsOf, sentHeader } from './messages.ts';
15
+ import { durationSeconds, everHeld, headersOf, mintId, nowMs, refuse, type Row } from './shared.ts';
16
+
17
+ type Field = Set<number>;
18
+
19
+ function field(spec: string, min: number, max: number): Field | undefined {
20
+ const out = new Set<number>();
21
+ for (const part of spec.split(',')) {
22
+ const m = /^(\*|\d+(?:-\d+)?)(?:\/(\d+))?$/.exec(part);
23
+ if (!m) return undefined;
24
+ const [lo, hi] = m[1] === '*' ? [min, max] : m[1]!.includes('-') ? m[1]!.split('-').map(Number) as [number, number] : [Number(m[1]), m[2] ? max : Number(m[1])];
25
+ const step = m[2] ? Number(m[2]) : 1;
26
+ if (lo < min || hi > max || lo > hi || step < 1) return undefined;
27
+ for (let v = lo; v <= hi; v += step) out.add(v);
28
+ }
29
+ return out;
30
+ }
31
+
32
+ type Cron = { minute: Field; hour: Field; dom: Field; month: Field; dow: Field; domAny: boolean; dowAny: boolean; zone: string };
33
+
34
+ /** A cron expression's fields and zone, or undefined when it is not one the lane reads. */
35
+ export function parseCron(expr: string): Cron | undefined {
36
+ const tz = /^CRON_TZ=(\S+)\s+/.exec(expr.trim());
37
+ const zone = tz?.[1] ?? 'UTC';
38
+ try { new Intl.DateTimeFormat('en-US', { timeZone: zone }); } catch { return undefined; }
39
+ const parts = expr.trim().replace(/^CRON_TZ=\S+\s+/, '').split(/\s+/);
40
+ if (parts.length !== 5) return undefined;
41
+ const [mi, h, dom, mo, dow] = parts as [string, string, string, string, string];
42
+ 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) };
43
+ if (!f.minute || !f.hour || !f.dom || !f.month || !f.dow) return undefined;
44
+ return { minute: f.minute, hour: f.hour, dom: f.dom, month: f.month, dow: f.dow, domAny: dom === '*', dowAny: dow === '*', zone };
45
+ }
46
+
47
+ /** The wall clock in a zone at an instant: month, day of month, day of week, hour, minute. */
48
+ function wall(ms: number, zone: string): { month: number; dom: number; dow: number; hour: number; minute: number } {
49
+ const parts = Object.fromEntries(new Intl.DateTimeFormat('en-US', { timeZone: zone, hourCycle: 'h23', month: 'numeric', day: 'numeric', weekday: 'short', hour: 'numeric', minute: 'numeric' })
50
+ .formatToParts(new Date(ms)).map((x) => [x.type, x.value]));
51
+ const dow = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].indexOf(String(parts.weekday));
52
+ return { month: Number(parts.month), dom: Number(parts.day), dow, hour: Number(parts.hour) % 24, minute: Number(parts.minute) };
53
+ }
54
+
55
+ /** The first minute after `afterMs` the cron fires at, its fields read on the wall clock of its zone. Day of month and day
56
+ * of week, both restricted, fire on either (the classic cron rule). A day or hour that cannot match is skipped whole. */
57
+ export function nextFire(expr: string, afterMs: number): number | undefined {
58
+ const c = parseCron(expr);
59
+ if (!c) return undefined;
60
+ let t = Math.floor(afterMs / 60_000) * 60_000 + 60_000;
61
+ const end = t + 366 * 86_400_000;
62
+ for (let skip = skipFrom(c, t); skip > 0 && t < end; skip = skipFrom(c, t)) t += skip;
63
+ return t < end ? t : neverFires();
64
+ }
65
+
66
+ /** 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
67
+ * cannot match, the rest of the hour when the hour cannot, else a minute. */
68
+ function skipFrom(c: Cron, t: number): number {
69
+ const w = wall(t, c.zone);
70
+ 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);
71
+ if (!c.month.has(w.month) || !dayOk) return (60 - w.minute) * 60_000 + (23 - w.hour) * 3_600_000;
72
+ if (!c.hour.has(w.hour)) return (60 - w.minute) * 60_000;
73
+ return c.minute.has(w.minute) ? 0 : 60_000;
74
+ }
75
+
76
+ /** A cron no minute of the coming year meets (`0 0 30 2 *`, the 30th of February): it has no next time. */
77
+ function neverFires(): undefined {
78
+ return undefined;
79
+ }
80
+
81
+ /** POST /v2/schedules/{destination}: a schedule of the message the request describes (its body, its `Upstash-*` headers),
82
+ * answered `{scheduleId}`. `Upstash-Schedule-Id` names a schedule to create or overwrite (the spec's parameter). */
83
+ const createSchedule: Semantics = async (ctx) => {
84
+ const destination = String(ctx.call.params.destination ?? '');
85
+ const h = headersOf(ctx);
86
+ const cron = h['upstash-cron'];
87
+ if (!cron) return refuse(ctx, 400, 'Upstash-Cron header is required');
88
+ const owner = ownerFor(ctx);
89
+ if (!/^https?:\/\//.test(destination) && !ctx.rowsRaw('URLGroup').some((g) => g.name === destination && g._owner === owner)) return refuse(ctx, 404, `URL Group ${destination} not found`);
90
+ const next = nextFire(cron, nowMs(ctx));
91
+ if (next === undefined) return refuse(ctx, 400, `invalid cron expression: ${cron}`);
92
+ const id = h['upstash-schedule-id'] ?? mintId(ctx, 'scd_', 'schedule', everHeld(ctx, 'Schedule'));
93
+ const retries = h['upstash-retries'] !== undefined && /^\d+$/.test(h['upstash-retries']) ? Number(h['upstash-retries']) : 3;
94
+ const fields: Row = {
95
+ scheduleId: id, cron, destination, createdAt: nowMs(ctx), method: h['upstash-method'] ?? 'POST', header: sentHeader(h), body: ctx.text ?? '',
96
+ retries, ...(durationSeconds(h['upstash-delay']) ? { delay: durationSeconds(h['upstash-delay']) } : {}),
97
+ ...(h['upstash-callback'] ? { callback: h['upstash-callback'] } : {}), ...(h['upstash-failure-callback'] ? { failureCallback: h['upstash-failure-callback'] } : {}),
98
+ isPaused: false, nextScheduleTime: next, ...labelsOf(h),
99
+ ...(h['upstash-queue-name'] ? { _queue: h['upstash-queue-name'] } : {}),
100
+ _owner: owner, _published: h,
101
+ };
102
+ await ctx.write('Schedule', id, fields, 'schedule.create');
103
+ return ctx.reply({ scheduleId: id });
104
+ };
105
+
106
+ const scheduleOf = (ctx: SemanticsContext): Row | undefined => ctx.rowsRaw('Schedule').find((s) => s.scheduleId === String(ctx.call.params.scheduleId));
107
+
108
+ /** POST /v2/schedules/{scheduleId}/pause, and the PATCH the vendor's clients send (the spec patch): its cron is ignored from
109
+ * now on. Answered 200 with no body. */
110
+ const pause: Semantics = async (ctx) => {
111
+ const s = scheduleOf(ctx);
112
+ if (!s) return refuse(ctx, 404, 'Schedule not found.');
113
+ const refused = ctx.legal('Schedule', 'isPaused', ctx.call.operation.id, s.isPaused === true);
114
+ if (refused) return ctx.refuse(refused);
115
+ if (s.isPaused !== true) await ctx.write('Schedule', String(s.id), { isPaused: true }, 'schedule.pause');
116
+ return new Response(null, { status: 200 });
117
+ };
118
+
119
+ /** DELETE /v2/schedules/{scheduleId}: it fires no more. Answered 200 with no body. */
120
+ const remove: Semantics = async (ctx) => {
121
+ const s = scheduleOf(ctx);
122
+ if (!s) return refuse(ctx, 404, 'Schedule not found.');
123
+ await ctx.write('Schedule', String(s.id), { deleted: true }, 'schedule.delete');
124
+ return new Response(null, { status: 200 });
125
+ };
126
+
127
+ export const scheduleSemantics: Record<string, Semantics> = {
128
+ post_v2_schedules_destination: createSchedule,
129
+ post_v2_schedules_scheduleid_pause: pause,
130
+ patch_v2_schedules_scheduleid_pause: pause,
131
+ delete_v2_schedules_scheduleid: remove,
132
+ };
@@ -0,0 +1,121 @@
1
+ // What the QStash lane's handlers share: the refusals, the request's headers, durations, ids and the views of stored
2
+ // subjects in the documents' shapes. Where the documents stop and the lane decides, the comment beside the rule says so.
3
+ import { createHash } from 'node:crypto';
4
+ import type { SemanticsContext } from '@volter/world-core';
5
+
6
+ export type Row = Record<string, unknown>;
7
+
8
+ /** A refusal in the documents' `Error` schema, `{"error": "<message>"}`. */
9
+ export const refuse = (ctx: SemanticsContext, status: number, message: string): Response => ctx.refuse({ status, message });
10
+
11
+ /** The request's headers, lower-cased. */
12
+ export function headersOf(ctx: SemanticsContext): Record<string, string> {
13
+ const out: Record<string, string> = {};
14
+ ctx.call.request.headers.forEach((v, k) => { out[k.toLowerCase()] = v; });
15
+ return out;
16
+ }
17
+
18
+ export function lowerKeys(h: Record<string, unknown> | undefined): Record<string, string> {
19
+ const out: Record<string, string> = {};
20
+ for (const [k, v] of Object.entries(h ?? {})) if (v !== undefined && v !== null) out[k.toLowerCase()] = String(v);
21
+ return out;
22
+ }
23
+
24
+ /** A header map as the documents answer one: each name to its list of values. */
25
+ export function multiValue(h: Record<string, string> | undefined): Record<string, string[]> {
26
+ return Object.fromEntries(Object.entries(h ?? {}).map(([k, v]) => [k, [v]]));
27
+ }
28
+
29
+ /** A multi-valued map to single values (the first of each). */
30
+ export function singleValue(h: Record<string, unknown> | undefined): Record<string, string> {
31
+ const out: Record<string, string> = {};
32
+ for (const [k, v] of Object.entries(h ?? {})) {
33
+ const first = Array.isArray(v) ? v[0] : v;
34
+ if (first !== undefined && first !== null) out[k.toLowerCase()] = String(first);
35
+ }
36
+ return out;
37
+ }
38
+
39
+ /** A duration as the delay page writes it: "The format for the duration is `<number><unit>`" (10s, 3m, 2h, 7d;
40
+ * https://upstash.com/docs/qstash/features/delay), in seconds. A bare number is taken as seconds (the lane's decision:
41
+ * the client's `delay` option is a number of seconds, which it writes as `<n>s`). Anything else is undefined. */
42
+ export function durationSeconds(raw: unknown): number | undefined {
43
+ if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) return raw;
44
+ if (typeof raw !== 'string') return undefined;
45
+ const s = raw.trim();
46
+ if (/^\d+$/.test(s)) return Number(s);
47
+ const m = /^(?:(\d+)d)?(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/.exec(s);
48
+ if (!m || s === '') return undefined;
49
+ return Number(m[1] ?? 0) * 86_400 + Number(m[2] ?? 0) * 3600 + Number(m[3] ?? 0) * 60 + Number(m[4] ?? 0);
50
+ }
51
+
52
+ /** The World's instant as Unix milliseconds. */
53
+ export const nowMs = (ctx: SemanticsContext): number => Date.parse(ctx.occurredAt);
54
+ export const iso = (ms: number): string => new Date(ms).toISOString();
55
+
56
+ const hex = (s: string): string => createHash('sha256').update(s).digest('hex');
57
+ const ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
58
+
59
+ /** An id of QStash's form (`msg_…`, `scd_…`, `wfr_…`) derived from the World's instant, the kind and how many of it
60
+ * the World already holds, so a World mints the same ids every run. The characters are the twin's. */
61
+ export function mintId(ctx: SemanticsContext, prefix: string, kind: string, count: number): string {
62
+ const h = hex(`qstash:${kind}:${ctx.occurredAt}:${count}`);
63
+ let out = '';
64
+ for (let i = 0; out.length < 26; i += 1) out += ALPHABET[parseInt(h.slice((i * 2) % 60, (i * 2) % 60 + 2), 16) % ALPHABET.length];
65
+ return `${prefix}${out}`;
66
+ }
67
+
68
+ /** Every subject of a stored type, the tombstoned ones too (an id is never minted twice). */
69
+ export const everHeld = (ctx: SemanticsContext, resource: string): number => ctx.rowsRaw(resource, { withDeleted: true }).length;
70
+
71
+ /** A stored subject's vendor fields: none of the kernel's, none of the lane's bookkeeping (`_`), and `state` only
72
+ * where the document's schema carries it. */
73
+ export function vendorFields(ctx: SemanticsContext, row: Row, keep: readonly string[]): Row {
74
+ const own = ctx.own(row);
75
+ const out: Row = {};
76
+ for (const k of keep) if (own[k] !== undefined && own[k] !== null) out[k] = own[k];
77
+ return out;
78
+ }
79
+
80
+ /** The `Message` schema's fields (the qstash document). */
81
+ export const MESSAGE_FIELDS = ['messageId', 'url', 'topicName', 'endpointName', 'method', 'header', 'body', 'bodyBase64', 'maxRetries', 'notBefore', 'createdAt',
82
+ 'callback', 'failureCallback', 'queueName', 'scheduleId', 'callerIP', 'label', 'labels', 'flowControlKey', 'rate', 'period', 'parallelism'] as const;
83
+
84
+ /** A queue name: "Queue names can only contain alphanumeric characters, hyphens, periods, and underscores." (the spec's
85
+ * pause and resume 400). */
86
+ export const QUEUE_NAME = /^[A-Za-z0-9._-]+$/;
87
+
88
+ /** The body of a request as the text it was sent. */
89
+ export const textOf = (ctx: SemanticsContext): string => ctx.text ?? '';
90
+
91
+ /** Base64 of a text, as QStash carries bodies and step results. */
92
+ export const b64 = (s: string): string => Buffer.from(s, 'utf8').toString('base64');
93
+
94
+ /** A query parameter's values, repeated or comma-separated ("Multiple values can be passed either by repeating the query
95
+ * parameter (`label=label_1&label=label_2`) or as a single comma-separated value (`label=label_1,label_2`)", the spec's bulk
96
+ * cancel). */
97
+ export function queryValues(ctx: SemanticsContext, name: string): string[] {
98
+ return new URL(ctx.call.request.url).searchParams.getAll(name).flatMap((v) => v.split(',')).map((s) => s.trim()).filter(Boolean);
99
+ }
100
+
101
+ /** A bulk request's filter over stored subjects: each filter the request names, by the field it reads (a date range reads
102
+ * `dateField`), "a workflow run matches if its value equals any of the given values (OR logic), and multiple filters are
103
+ * combined with AND logic" (the spec's bulk cancel). `prefix` names the fields matched by prefix (a workflow URL without
104
+ * `workflowUrlExactMatch`). Where the documentation stops and the lane decides: a filter whose field a subject lacks
105
+ * excludes it. */
106
+ export function matchesFilters(ctx: SemanticsContext, row: Row, fields: Record<string, string>, dateField: string, prefix: string[] = []): boolean {
107
+ const q = new URL(ctx.call.request.url).searchParams;
108
+ for (const [param, field] of Object.entries(fields)) {
109
+ const wanted = queryValues(ctx, param);
110
+ if (!wanted.length) continue;
111
+ const have = Array.isArray(row[field]) ? (row[field] as unknown[]).map(String) : row[field] === undefined || row[field] === null ? [] : [String(row[field])];
112
+ const hit = prefix.includes(param) ? have.some((h) => wanted.some((w) => h.startsWith(w))) : have.some((h) => wanted.includes(h));
113
+ if (!hit) return false;
114
+ }
115
+ const at = Number(row[dateField]);
116
+ const from = q.get('fromDate'); const to = q.get('toDate');
117
+ if (from !== null && !(at >= Number(from))) return false;
118
+ if (to !== null && !(at <= Number(to))) return false;
119
+ return true;
120
+ }
121
+
@@ -0,0 +1,69 @@
1
+ // URL groups (the API calls them topics): "a URL Group is just a namespace where you can publish messages to, the same way
2
+ // as publishing a message to an endpoint directly" and "When you publish a message to a URL Group, it will be fanned out and
3
+ // sent to all the subscribed endpoints." (https://upstash.com/docs/qstash/features/url-groups). A message published to a group
4
+ // is one message per endpoint (messages.ts); the lane serves the group's upsert of endpoints, its read, its list, the
5
+ // removal of endpoints and the group's deletion.
6
+ import type { Semantics, SemanticsContext } from '@volter/world-core';
7
+ import { ownerFor } from './messages.ts';
8
+ import { nowMs, refuse, type Row } from './shared.ts';
9
+
10
+ type Endpoint = { url: string; name?: string };
11
+
12
+ const groupOf = (ctx: SemanticsContext, name: string, owner: string): Row | undefined =>
13
+ ctx.rowsRaw('URLGroup').find((g) => g.name === name && g._owner === owner);
14
+
15
+ function view(ctx: SemanticsContext, g: Row): Row {
16
+ const own = ctx.own(g);
17
+ return { name: own.name, createdAt: own.createdAt, updatedAt: own.updatedAt, endpoints: own.endpoints ?? [] };
18
+ }
19
+
20
+ function endpointsOf(ctx: SemanticsContext): Endpoint[] | undefined {
21
+ const list = (ctx.body as { endpoints?: unknown } | undefined)?.endpoints;
22
+ if (!Array.isArray(list)) return undefined;
23
+ return list.map((e) => e as Row).filter((e) => typeof e.url === 'string' || typeof e.name === 'string')
24
+ .map((e) => ({ ...(typeof e.url === 'string' ? { url: e.url } : { url: '' }), ...(typeof e.name === 'string' ? { name: e.name } : {}) }));
25
+ }
26
+
27
+ /** POST /v2/topics/{urlGroupName}/endpoints: "Add one or multiple endpoints to a URL Group. If the URL Group does not
28
+ * exist, it will be created." (the spec). An endpoint the
29
+ * group already has (the same URL) is replaced by the one sent. Answered 200 with no body. */
30
+ const upsert: Semantics = async (ctx) => {
31
+ const name = String(ctx.call.params.urlGroupName);
32
+ const sent = endpointsOf(ctx);
33
+ if (!sent || !sent.length || sent.some((e) => !/^https?:\/\//.test(e.url))) return refuse(ctx, 400, 'endpoints must be a non-empty list of {url, name?} with an http or https url');
34
+ const owner = ownerFor(ctx);
35
+ const g = groupOf(ctx, name, owner);
36
+ const kept = ((g?.endpoints ?? []) as Endpoint[]).filter((e) => !sent.some((s) => s.url === e.url));
37
+ const at = nowMs(ctx);
38
+ await ctx.write('URLGroup', g ? String(g.id) : `${owner}:${name}`, { name, createdAt: g?.createdAt ?? at, updatedAt: at, endpoints: [...kept, ...sent], _owner: owner }, g ? 'url_group.update' : 'url_group.create');
39
+ return new Response(null, { status: 200 });
40
+ };
41
+
42
+ /** DELETE /v2/topics/{urlGroupName}/endpoints: the endpoints named (by URL or by name) leave the group. Answered 200. */
43
+ const removeEndpoints: Semantics = async (ctx) => {
44
+ const g = groupOf(ctx, String(ctx.call.params.urlGroupName), ownerFor(ctx));
45
+ if (!g) return refuse(ctx, 404, `URL Group ${String(ctx.call.params.urlGroupName)} not found`);
46
+ const sent = endpointsOf(ctx) ?? [];
47
+ const endpoints = ((g.endpoints ?? []) as Endpoint[]).filter((e) => !sent.some((s) => (s.url && s.url === e.url) || (s.name !== undefined && s.name === e.name)));
48
+ await ctx.write('URLGroup', String(g.id), { endpoints, updatedAt: nowMs(ctx) }, 'url_group.update');
49
+ return new Response(null, { status: 200 });
50
+ };
51
+
52
+ export const urlGroupSemantics: Record<string, Semantics> = {
53
+ post_v2_topics_urlgroupname_endpoints: upsert,
54
+ delete_v2_topics_urlgroupname_endpoints: removeEndpoints,
55
+ get_v2_topics_urlgroupname: async (ctx) => {
56
+ const g = groupOf(ctx, String(ctx.call.params.urlGroupName), ownerFor(ctx));
57
+ return g ? ctx.reply(view(ctx, g)) : refuse(ctx, 404, `URL Group ${String(ctx.call.params.urlGroupName)} not found`);
58
+ },
59
+ get_v2_topics: async (ctx) => {
60
+ const owner = ownerFor(ctx);
61
+ return ctx.reply(ctx.rowsRaw('URLGroup').filter((g) => g._owner === owner).sort((a, b) => String(a.name).localeCompare(String(b.name))).map((g) => view(ctx, g)));
62
+ },
63
+ delete_v2_topics_urlgroupname: async (ctx) => {
64
+ const g = groupOf(ctx, String(ctx.call.params.urlGroupName), ownerFor(ctx));
65
+ if (!g) return refuse(ctx, 404, `URL Group ${String(ctx.call.params.urlGroupName)} not found`);
66
+ await ctx.write('URLGroup', String(g.id), { deleted: true }, 'url_group.delete');
67
+ return new Response(null, { status: 200 });
68
+ },
69
+ };