@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.
- package/LICENSE +202 -0
- package/README.md +202 -0
- package/api/src/fetch.ts +54 -0
- package/api/src/generated/surface.gen.json +1 -0
- package/api/src/generated/ui.gen.json +1 -0
- package/api/src/index.ts +19 -0
- package/api/src/key-gate.ts +30 -0
- package/api/src/manifest.ts +103 -0
- package/api/src/screens/developer-api.tsx +106 -0
- package/api/src/screens/qstash.tsx +99 -0
- package/api/src/screens/session.tsx +125 -0
- package/api/src/screens/teams.tsx +114 -0
- package/api/src/semantics/backups.ts +90 -0
- package/api/src/semantics/index.ts +191 -0
- package/api/src/semantics/shared.ts +42 -0
- package/api/src/semantics/teams.ts +108 -0
- package/api/src/semantics/time.ts +40 -0
- package/dist/api/src/fetch.d.ts +15 -0
- package/dist/api/src/fetch.js +44 -0
- package/dist/api/src/fetch.ts +54 -0
- package/dist/api/src/generated/surface.gen.json +1 -0
- package/dist/api/src/generated/ui.gen.json +1 -0
- package/dist/api/src/index.ts +19 -0
- package/dist/api/src/key-gate.d.ts +3 -0
- package/dist/api/src/key-gate.js +30 -0
- package/dist/api/src/key-gate.ts +30 -0
- package/dist/api/src/manifest.d.ts +2 -0
- package/dist/api/src/manifest.js +81 -0
- package/dist/api/src/manifest.ts +103 -0
- package/dist/api/src/screens/developer-api.d.ts +3 -0
- package/dist/api/src/screens/developer-api.js +101 -0
- package/dist/api/src/screens/developer-api.tsx +106 -0
- package/dist/api/src/screens/qstash.d.ts +3 -0
- package/dist/api/src/screens/qstash.js +92 -0
- package/dist/api/src/screens/qstash.tsx +99 -0
- package/dist/api/src/screens/session.d.ts +9 -0
- package/dist/api/src/screens/session.js +118 -0
- package/dist/api/src/screens/session.tsx +125 -0
- package/dist/api/src/screens/teams.d.ts +3 -0
- package/dist/api/src/screens/teams.js +99 -0
- package/dist/api/src/screens/teams.tsx +114 -0
- package/dist/api/src/semantics/backups.d.ts +7 -0
- package/dist/api/src/semantics/backups.js +75 -0
- package/dist/api/src/semantics/backups.ts +90 -0
- package/dist/api/src/semantics/index.d.ts +10 -0
- package/dist/api/src/semantics/index.js +191 -0
- package/dist/api/src/semantics/index.ts +191 -0
- package/dist/api/src/semantics/shared.d.ts +21 -0
- package/dist/api/src/semantics/shared.js +34 -0
- package/dist/api/src/semantics/shared.ts +42 -0
- package/dist/api/src/semantics/teams.d.ts +13 -0
- package/dist/api/src/semantics/teams.js +100 -0
- package/dist/api/src/semantics/teams.ts +108 -0
- package/dist/api/src/semantics/time.d.ts +2 -0
- package/dist/api/src/semantics/time.js +34 -0
- package/dist/api/src/semantics/time.ts +40 -0
- package/dist/qstash/src/doors.d.ts +6 -0
- package/dist/qstash/src/doors.js +33 -0
- package/dist/qstash/src/doors.ts +51 -0
- package/dist/qstash/src/egress.d.ts +7 -0
- package/dist/qstash/src/egress.js +66 -0
- package/dist/qstash/src/egress.ts +58 -0
- package/dist/qstash/src/fetch.d.ts +7 -0
- package/dist/qstash/src/fetch.js +48 -0
- package/dist/qstash/src/fetch.ts +46 -0
- package/dist/qstash/src/generated/surface.gen.json +1 -0
- package/dist/qstash/src/generated/ui.gen.json +1 -0
- package/dist/qstash/src/index.ts +35 -0
- package/dist/qstash/src/manifest.d.ts +10 -0
- package/dist/qstash/src/manifest.js +105 -0
- package/dist/qstash/src/manifest.ts +134 -0
- package/dist/qstash/src/semantics/account.d.ts +29 -0
- package/dist/qstash/src/semantics/account.js +91 -0
- package/dist/qstash/src/semantics/account.ts +98 -0
- package/dist/qstash/src/semantics/delivery.d.ts +17 -0
- package/dist/qstash/src/semantics/delivery.js +274 -0
- package/dist/qstash/src/semantics/delivery.ts +264 -0
- package/dist/qstash/src/semantics/dlq.d.ts +4 -0
- package/dist/qstash/src/semantics/dlq.js +51 -0
- package/dist/qstash/src/semantics/dlq.ts +61 -0
- package/dist/qstash/src/semantics/index.d.ts +2 -0
- package/dist/qstash/src/semantics/index.js +10 -0
- package/dist/qstash/src/semantics/index.ts +13 -0
- package/dist/qstash/src/semantics/keys.d.ts +2 -0
- package/dist/qstash/src/semantics/keys.js +9 -0
- package/dist/qstash/src/semantics/keys.ts +14 -0
- package/dist/qstash/src/semantics/messages.d.ts +74 -0
- package/dist/qstash/src/semantics/messages.js +233 -0
- package/dist/qstash/src/semantics/messages.ts +249 -0
- package/dist/qstash/src/semantics/queues.d.ts +2 -0
- package/dist/qstash/src/semantics/queues.js +60 -0
- package/dist/qstash/src/semantics/queues.ts +66 -0
- package/dist/qstash/src/semantics/schedules.d.ts +19 -0
- package/dist/qstash/src/semantics/schedules.js +125 -0
- package/dist/qstash/src/semantics/schedules.ts +132 -0
- package/dist/qstash/src/semantics/shared.d.ts +45 -0
- package/dist/qstash/src/semantics/shared.js +115 -0
- package/dist/qstash/src/semantics/shared.ts +121 -0
- package/dist/qstash/src/semantics/urlgroups.d.ts +2 -0
- package/dist/qstash/src/semantics/urlgroups.js +58 -0
- package/dist/qstash/src/semantics/urlgroups.ts +69 -0
- package/dist/qstash/src/semantics/workflows.d.ts +44 -0
- package/dist/qstash/src/semantics/workflows.js +379 -0
- package/dist/qstash/src/semantics/workflows.ts +401 -0
- package/dist/qstash/src/signing.d.ts +4 -0
- package/dist/qstash/src/signing.js +16 -0
- package/dist/qstash/src/signing.ts +19 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +35 -0
- package/dist/src/generated/surface.gen.json +1 -0
- package/dist/src/index.d.ts +18 -0
- package/dist/src/index.js +124 -0
- package/dist/src/manifest.d.ts +14 -0
- package/dist/src/manifest.js +8 -0
- package/dist/src/upstash-budget.d.ts +85 -0
- package/dist/src/upstash-budget.js +440 -0
- package/dist/src/upstash-capabilities.d.ts +4 -0
- package/dist/src/upstash-capabilities.js +1286 -0
- package/dist/src/upstash-conformance.d.ts +7 -0
- package/dist/src/upstash-conformance.js +119 -0
- package/dist/src/upstash-connector.d.ts +115 -0
- package/dist/src/upstash-connector.js +309 -0
- package/dist/src/upstash-lua.d.ts +140 -0
- package/dist/src/upstash-lua.js +1229 -0
- package/dist/src/upstash-server.d.ts +29 -0
- package/dist/src/upstash-server.js +81 -0
- package/dist/src/upstash-store.d.ts +114 -0
- package/dist/src/upstash-store.js +1663 -0
- package/dist/src/upstash-twin.d.ts +73 -0
- package/dist/src/upstash-twin.js +437 -0
- package/package.json +59 -0
- package/qstash/src/doors.ts +51 -0
- package/qstash/src/egress.ts +58 -0
- package/qstash/src/fetch.ts +46 -0
- package/qstash/src/generated/surface.gen.json +1 -0
- package/qstash/src/generated/ui.gen.json +1 -0
- package/qstash/src/index.ts +35 -0
- package/qstash/src/manifest.ts +134 -0
- package/qstash/src/semantics/account.ts +98 -0
- package/qstash/src/semantics/delivery.ts +264 -0
- package/qstash/src/semantics/dlq.ts +61 -0
- package/qstash/src/semantics/index.ts +13 -0
- package/qstash/src/semantics/keys.ts +14 -0
- package/qstash/src/semantics/messages.ts +249 -0
- package/qstash/src/semantics/queues.ts +66 -0
- package/qstash/src/semantics/schedules.ts +132 -0
- package/qstash/src/semantics/shared.ts +121 -0
- package/qstash/src/semantics/urlgroups.ts +69 -0
- package/qstash/src/semantics/workflows.ts +401 -0
- package/qstash/src/signing.ts +19 -0
- package/src/cli.ts +36 -0
- package/src/generated/surface.gen.json +1 -0
- package/src/index.ts +203 -0
- package/src/manifest.ts +26 -0
- package/src/upstash-budget.ts +486 -0
- package/src/upstash-capabilities.ts +1418 -0
- package/src/upstash-conformance.ts +131 -0
- package/src/upstash-connector.ts +340 -0
- package/src/upstash-lua.ts +1120 -0
- package/src/upstash-server.ts +103 -0
- package/src/upstash-store.ts +1437 -0
- package/src/upstash-twin.ts +465 -0
|
@@ -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,45 @@
|
|
|
1
|
+
import type { SemanticsContext } from '@volter/world-core';
|
|
2
|
+
export type Row = Record<string, unknown>;
|
|
3
|
+
/** A refusal in the documents' `Error` schema, `{"error": "<message>"}`. */
|
|
4
|
+
export declare const refuse: (ctx: SemanticsContext, status: number, message: string) => Response;
|
|
5
|
+
/** The request's headers, lower-cased. */
|
|
6
|
+
export declare function headersOf(ctx: SemanticsContext): Record<string, string>;
|
|
7
|
+
export declare function lowerKeys(h: Record<string, unknown> | undefined): Record<string, string>;
|
|
8
|
+
/** A header map as the documents answer one: each name to its list of values. */
|
|
9
|
+
export declare function multiValue(h: Record<string, string> | undefined): Record<string, string[]>;
|
|
10
|
+
/** A multi-valued map to single values (the first of each). */
|
|
11
|
+
export declare function singleValue(h: Record<string, unknown> | undefined): Record<string, string>;
|
|
12
|
+
/** A duration as the delay page writes it: "The format for the duration is `<number><unit>`" (10s, 3m, 2h, 7d;
|
|
13
|
+
* https://upstash.com/docs/qstash/features/delay), in seconds. A bare number is taken as seconds (the lane's decision:
|
|
14
|
+
* the client's `delay` option is a number of seconds, which it writes as `<n>s`). Anything else is undefined. */
|
|
15
|
+
export declare function durationSeconds(raw: unknown): number | undefined;
|
|
16
|
+
/** The World's instant as Unix milliseconds. */
|
|
17
|
+
export declare const nowMs: (ctx: SemanticsContext) => number;
|
|
18
|
+
export declare const iso: (ms: number) => string;
|
|
19
|
+
/** An id of QStash's form (`msg_…`, `scd_…`, `wfr_…`) derived from the World's instant, the kind and how many of it
|
|
20
|
+
* the World already holds, so a World mints the same ids every run. The characters are the twin's. */
|
|
21
|
+
export declare function mintId(ctx: SemanticsContext, prefix: string, kind: string, count: number): string;
|
|
22
|
+
/** Every subject of a stored type, the tombstoned ones too (an id is never minted twice). */
|
|
23
|
+
export declare const everHeld: (ctx: SemanticsContext, resource: string) => number;
|
|
24
|
+
/** A stored subject's vendor fields: none of the kernel's, none of the lane's bookkeeping (`_`), and `state` only
|
|
25
|
+
* where the document's schema carries it. */
|
|
26
|
+
export declare function vendorFields(ctx: SemanticsContext, row: Row, keep: readonly string[]): Row;
|
|
27
|
+
/** The `Message` schema's fields (the qstash document). */
|
|
28
|
+
export declare const MESSAGE_FIELDS: readonly ["messageId", "url", "topicName", "endpointName", "method", "header", "body", "bodyBase64", "maxRetries", "notBefore", "createdAt", "callback", "failureCallback", "queueName", "scheduleId", "callerIP", "label", "labels", "flowControlKey", "rate", "period", "parallelism"];
|
|
29
|
+
/** A queue name: "Queue names can only contain alphanumeric characters, hyphens, periods, and underscores." (the spec's
|
|
30
|
+
* pause and resume 400). */
|
|
31
|
+
export declare const QUEUE_NAME: RegExp;
|
|
32
|
+
/** The body of a request as the text it was sent. */
|
|
33
|
+
export declare const textOf: (ctx: SemanticsContext) => string;
|
|
34
|
+
/** Base64 of a text, as QStash carries bodies and step results. */
|
|
35
|
+
export declare const b64: (s: string) => string;
|
|
36
|
+
/** A query parameter's values, repeated or comma-separated ("Multiple values can be passed either by repeating the query
|
|
37
|
+
* parameter (`label=label_1&label=label_2`) or as a single comma-separated value (`label=label_1,label_2`)", the spec's bulk
|
|
38
|
+
* cancel). */
|
|
39
|
+
export declare function queryValues(ctx: SemanticsContext, name: string): string[];
|
|
40
|
+
/** A bulk request's filter over stored subjects: each filter the request names, by the field it reads (a date range reads
|
|
41
|
+
* `dateField`), "a workflow run matches if its value equals any of the given values (OR logic), and multiple filters are
|
|
42
|
+
* combined with AND logic" (the spec's bulk cancel). `prefix` names the fields matched by prefix (a workflow URL without
|
|
43
|
+
* `workflowUrlExactMatch`). Where the documentation stops and the lane decides: a filter whose field a subject lacks
|
|
44
|
+
* excludes it. */
|
|
45
|
+
export declare function matchesFilters(ctx: SemanticsContext, row: Row, fields: Record<string, string>, dateField: string, prefix?: string[]): boolean;
|
|
@@ -0,0 +1,115 @@
|
|
|
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
|
+
/** A refusal in the documents' `Error` schema, `{"error": "<message>"}`. */
|
|
5
|
+
export const refuse = (ctx, status, message) => ctx.refuse({ status, message });
|
|
6
|
+
/** The request's headers, lower-cased. */
|
|
7
|
+
export function headersOf(ctx) {
|
|
8
|
+
const out = {};
|
|
9
|
+
ctx.call.request.headers.forEach((v, k) => { out[k.toLowerCase()] = v; });
|
|
10
|
+
return out;
|
|
11
|
+
}
|
|
12
|
+
export function lowerKeys(h) {
|
|
13
|
+
const out = {};
|
|
14
|
+
for (const [k, v] of Object.entries(h ?? {}))
|
|
15
|
+
if (v !== undefined && v !== null)
|
|
16
|
+
out[k.toLowerCase()] = String(v);
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
/** A header map as the documents answer one: each name to its list of values. */
|
|
20
|
+
export function multiValue(h) {
|
|
21
|
+
return Object.fromEntries(Object.entries(h ?? {}).map(([k, v]) => [k, [v]]));
|
|
22
|
+
}
|
|
23
|
+
/** A multi-valued map to single values (the first of each). */
|
|
24
|
+
export function singleValue(h) {
|
|
25
|
+
const out = {};
|
|
26
|
+
for (const [k, v] of Object.entries(h ?? {})) {
|
|
27
|
+
const first = Array.isArray(v) ? v[0] : v;
|
|
28
|
+
if (first !== undefined && first !== null)
|
|
29
|
+
out[k.toLowerCase()] = String(first);
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
/** A duration as the delay page writes it: "The format for the duration is `<number><unit>`" (10s, 3m, 2h, 7d;
|
|
34
|
+
* https://upstash.com/docs/qstash/features/delay), in seconds. A bare number is taken as seconds (the lane's decision:
|
|
35
|
+
* the client's `delay` option is a number of seconds, which it writes as `<n>s`). Anything else is undefined. */
|
|
36
|
+
export function durationSeconds(raw) {
|
|
37
|
+
if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0)
|
|
38
|
+
return raw;
|
|
39
|
+
if (typeof raw !== 'string')
|
|
40
|
+
return undefined;
|
|
41
|
+
const s = raw.trim();
|
|
42
|
+
if (/^\d+$/.test(s))
|
|
43
|
+
return Number(s);
|
|
44
|
+
const m = /^(?:(\d+)d)?(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/.exec(s);
|
|
45
|
+
if (!m || s === '')
|
|
46
|
+
return undefined;
|
|
47
|
+
return Number(m[1] ?? 0) * 86_400 + Number(m[2] ?? 0) * 3600 + Number(m[3] ?? 0) * 60 + Number(m[4] ?? 0);
|
|
48
|
+
}
|
|
49
|
+
/** The World's instant as Unix milliseconds. */
|
|
50
|
+
export const nowMs = (ctx) => Date.parse(ctx.occurredAt);
|
|
51
|
+
export const iso = (ms) => new Date(ms).toISOString();
|
|
52
|
+
const hex = (s) => createHash('sha256').update(s).digest('hex');
|
|
53
|
+
const ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
|
54
|
+
/** An id of QStash's form (`msg_…`, `scd_…`, `wfr_…`) derived from the World's instant, the kind and how many of it
|
|
55
|
+
* the World already holds, so a World mints the same ids every run. The characters are the twin's. */
|
|
56
|
+
export function mintId(ctx, prefix, kind, count) {
|
|
57
|
+
const h = hex(`qstash:${kind}:${ctx.occurredAt}:${count}`);
|
|
58
|
+
let out = '';
|
|
59
|
+
for (let i = 0; out.length < 26; i += 1)
|
|
60
|
+
out += ALPHABET[parseInt(h.slice((i * 2) % 60, (i * 2) % 60 + 2), 16) % ALPHABET.length];
|
|
61
|
+
return `${prefix}${out}`;
|
|
62
|
+
}
|
|
63
|
+
/** Every subject of a stored type, the tombstoned ones too (an id is never minted twice). */
|
|
64
|
+
export const everHeld = (ctx, resource) => ctx.rowsRaw(resource, { withDeleted: true }).length;
|
|
65
|
+
/** A stored subject's vendor fields: none of the kernel's, none of the lane's bookkeeping (`_`), and `state` only
|
|
66
|
+
* where the document's schema carries it. */
|
|
67
|
+
export function vendorFields(ctx, row, keep) {
|
|
68
|
+
const own = ctx.own(row);
|
|
69
|
+
const out = {};
|
|
70
|
+
for (const k of keep)
|
|
71
|
+
if (own[k] !== undefined && own[k] !== null)
|
|
72
|
+
out[k] = own[k];
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
/** The `Message` schema's fields (the qstash document). */
|
|
76
|
+
export const MESSAGE_FIELDS = ['messageId', 'url', 'topicName', 'endpointName', 'method', 'header', 'body', 'bodyBase64', 'maxRetries', 'notBefore', 'createdAt',
|
|
77
|
+
'callback', 'failureCallback', 'queueName', 'scheduleId', 'callerIP', 'label', 'labels', 'flowControlKey', 'rate', 'period', 'parallelism'];
|
|
78
|
+
/** A queue name: "Queue names can only contain alphanumeric characters, hyphens, periods, and underscores." (the spec's
|
|
79
|
+
* pause and resume 400). */
|
|
80
|
+
export const QUEUE_NAME = /^[A-Za-z0-9._-]+$/;
|
|
81
|
+
/** The body of a request as the text it was sent. */
|
|
82
|
+
export const textOf = (ctx) => ctx.text ?? '';
|
|
83
|
+
/** Base64 of a text, as QStash carries bodies and step results. */
|
|
84
|
+
export const b64 = (s) => Buffer.from(s, 'utf8').toString('base64');
|
|
85
|
+
/** A query parameter's values, repeated or comma-separated ("Multiple values can be passed either by repeating the query
|
|
86
|
+
* parameter (`label=label_1&label=label_2`) or as a single comma-separated value (`label=label_1,label_2`)", the spec's bulk
|
|
87
|
+
* cancel). */
|
|
88
|
+
export function queryValues(ctx, name) {
|
|
89
|
+
return new URL(ctx.call.request.url).searchParams.getAll(name).flatMap((v) => v.split(',')).map((s) => s.trim()).filter(Boolean);
|
|
90
|
+
}
|
|
91
|
+
/** A bulk request's filter over stored subjects: each filter the request names, by the field it reads (a date range reads
|
|
92
|
+
* `dateField`), "a workflow run matches if its value equals any of the given values (OR logic), and multiple filters are
|
|
93
|
+
* combined with AND logic" (the spec's bulk cancel). `prefix` names the fields matched by prefix (a workflow URL without
|
|
94
|
+
* `workflowUrlExactMatch`). Where the documentation stops and the lane decides: a filter whose field a subject lacks
|
|
95
|
+
* excludes it. */
|
|
96
|
+
export function matchesFilters(ctx, row, fields, dateField, prefix = []) {
|
|
97
|
+
const q = new URL(ctx.call.request.url).searchParams;
|
|
98
|
+
for (const [param, field] of Object.entries(fields)) {
|
|
99
|
+
const wanted = queryValues(ctx, param);
|
|
100
|
+
if (!wanted.length)
|
|
101
|
+
continue;
|
|
102
|
+
const have = Array.isArray(row[field]) ? row[field].map(String) : row[field] === undefined || row[field] === null ? [] : [String(row[field])];
|
|
103
|
+
const hit = prefix.includes(param) ? have.some((h) => wanted.some((w) => h.startsWith(w))) : have.some((h) => wanted.includes(h));
|
|
104
|
+
if (!hit)
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
const at = Number(row[dateField]);
|
|
108
|
+
const from = q.get('fromDate');
|
|
109
|
+
const to = q.get('toDate');
|
|
110
|
+
if (from !== null && !(at >= Number(from)))
|
|
111
|
+
return false;
|
|
112
|
+
if (to !== null && !(at <= Number(to)))
|
|
113
|
+
return false;
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
@@ -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,58 @@
|
|
|
1
|
+
import { ownerFor } from "./messages.js";
|
|
2
|
+
import { nowMs, refuse } from "./shared.js";
|
|
3
|
+
const groupOf = (ctx, name, owner) => ctx.rowsRaw('URLGroup').find((g) => g.name === name && g._owner === owner);
|
|
4
|
+
function view(ctx, g) {
|
|
5
|
+
const own = ctx.own(g);
|
|
6
|
+
return { name: own.name, createdAt: own.createdAt, updatedAt: own.updatedAt, endpoints: own.endpoints ?? [] };
|
|
7
|
+
}
|
|
8
|
+
function endpointsOf(ctx) {
|
|
9
|
+
const list = ctx.body?.endpoints;
|
|
10
|
+
if (!Array.isArray(list))
|
|
11
|
+
return undefined;
|
|
12
|
+
return list.map((e) => e).filter((e) => typeof e.url === 'string' || typeof e.name === 'string')
|
|
13
|
+
.map((e) => ({ ...(typeof e.url === 'string' ? { url: e.url } : { url: '' }), ...(typeof e.name === 'string' ? { name: e.name } : {}) }));
|
|
14
|
+
}
|
|
15
|
+
/** POST /v2/topics/{urlGroupName}/endpoints: "Add one or multiple endpoints to a URL Group. If the URL Group does not
|
|
16
|
+
* exist, it will be created." (the spec). An endpoint the
|
|
17
|
+
* group already has (the same URL) is replaced by the one sent. Answered 200 with no body. */
|
|
18
|
+
const upsert = async (ctx) => {
|
|
19
|
+
const name = String(ctx.call.params.urlGroupName);
|
|
20
|
+
const sent = endpointsOf(ctx);
|
|
21
|
+
if (!sent || !sent.length || sent.some((e) => !/^https?:\/\//.test(e.url)))
|
|
22
|
+
return refuse(ctx, 400, 'endpoints must be a non-empty list of {url, name?} with an http or https url');
|
|
23
|
+
const owner = ownerFor(ctx);
|
|
24
|
+
const g = groupOf(ctx, name, owner);
|
|
25
|
+
const kept = (g?.endpoints ?? []).filter((e) => !sent.some((s) => s.url === e.url));
|
|
26
|
+
const at = nowMs(ctx);
|
|
27
|
+
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');
|
|
28
|
+
return new Response(null, { status: 200 });
|
|
29
|
+
};
|
|
30
|
+
/** DELETE /v2/topics/{urlGroupName}/endpoints: the endpoints named (by URL or by name) leave the group. Answered 200. */
|
|
31
|
+
const removeEndpoints = async (ctx) => {
|
|
32
|
+
const g = groupOf(ctx, String(ctx.call.params.urlGroupName), ownerFor(ctx));
|
|
33
|
+
if (!g)
|
|
34
|
+
return refuse(ctx, 404, `URL Group ${String(ctx.call.params.urlGroupName)} not found`);
|
|
35
|
+
const sent = endpointsOf(ctx) ?? [];
|
|
36
|
+
const endpoints = (g.endpoints ?? []).filter((e) => !sent.some((s) => (s.url && s.url === e.url) || (s.name !== undefined && s.name === e.name)));
|
|
37
|
+
await ctx.write('URLGroup', String(g.id), { endpoints, updatedAt: nowMs(ctx) }, 'url_group.update');
|
|
38
|
+
return new Response(null, { status: 200 });
|
|
39
|
+
};
|
|
40
|
+
export const urlGroupSemantics = {
|
|
41
|
+
post_v2_topics_urlgroupname_endpoints: upsert,
|
|
42
|
+
delete_v2_topics_urlgroupname_endpoints: removeEndpoints,
|
|
43
|
+
get_v2_topics_urlgroupname: async (ctx) => {
|
|
44
|
+
const g = groupOf(ctx, String(ctx.call.params.urlGroupName), ownerFor(ctx));
|
|
45
|
+
return g ? ctx.reply(view(ctx, g)) : refuse(ctx, 404, `URL Group ${String(ctx.call.params.urlGroupName)} not found`);
|
|
46
|
+
},
|
|
47
|
+
get_v2_topics: async (ctx) => {
|
|
48
|
+
const owner = ownerFor(ctx);
|
|
49
|
+
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)));
|
|
50
|
+
},
|
|
51
|
+
delete_v2_topics_urlgroupname: async (ctx) => {
|
|
52
|
+
const g = groupOf(ctx, String(ctx.call.params.urlGroupName), ownerFor(ctx));
|
|
53
|
+
if (!g)
|
|
54
|
+
return refuse(ctx, 404, `URL Group ${String(ctx.call.params.urlGroupName)} not found`);
|
|
55
|
+
await ctx.write('URLGroup', String(g.id), { deleted: true }, 'url_group.delete');
|
|
56
|
+
return new Response(null, { status: 200 });
|
|
57
|
+
},
|
|
58
|
+
};
|
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { Semantics, SemanticsContext } from '@volter/world-core';
|
|
2
|
+
import { type Accepted, type Publication } from './messages.js';
|
|
3
|
+
import { type Row } from './shared.js';
|
|
4
|
+
export declare const isWorkflowMessage: (h: Record<string, string>) => boolean;
|
|
5
|
+
/** A run's messages, in the order the run made them. */
|
|
6
|
+
export declare function runMessages(ctx: SemanticsContext, runId: string): Row[];
|
|
7
|
+
/** The steps a delivery of the run's message `seq` carries: the steps a resumed run kept, then the run's own messages up
|
|
8
|
+
* to `seq`, each as `{messageId, body: <base64 of what was published>, callType}` (`processRawSteps`). */
|
|
9
|
+
export declare function rawSteps(ctx: SemanticsContext, runId: string, seq: number): Row[];
|
|
10
|
+
/** What a workflow's delivery carries in its headers: every `Upstash-Forward-*` header with the prefix stripped (the
|
|
11
|
+
* run's `Upstash-Workflow-Sdk-Version` among them, which is how `serve()` tells a delivery from its own first
|
|
12
|
+
* invocation), and the run's own workflow headers as published. */
|
|
13
|
+
export declare function workflowHeaders(published: Record<string, string>): Record<string, string>;
|
|
14
|
+
/** One batch or publish entry of a workflow: the first of a run starts it, a later one is a step of an active run. A run
|
|
15
|
+
* id the World already holds is not started twice: the client reads `deduplicated` and warns "Workflow run … already
|
|
16
|
+
* exists" (`triggerFirstInvocation`). A step for a run no longer active is refused 400, which the client reads as "Tried
|
|
17
|
+
* to append to a cancelled workflow" (`triggerRouteFunction`). */
|
|
18
|
+
export declare function acceptWorkflowMessage(ctx: SemanticsContext, p: Publication, owner: string): Promise<Accepted>;
|
|
19
|
+
/** A wait whose timeout has come: the step comes back to its timeout URL timed out, with the headers the run gave for it
|
|
20
|
+
* (the catch-up, delivery.ts). */
|
|
21
|
+
export declare function timeOut(ctx: SemanticsContext, w: Row): Promise<void>;
|
|
22
|
+
/** The ids a query names, whether repeated or comma-separated ("Multiple values can be passed either by repeating the
|
|
23
|
+
* query parameter ... or as a single comma-separated value", the spec's bulk cancel). */
|
|
24
|
+
export declare function idsOf(ctx: SemanticsContext, name: string): string[];
|
|
25
|
+
export declare function workflowDlqView(ctx: SemanticsContext, e: Row): Row;
|
|
26
|
+
/** A step's message has failed after its retries: "When a workflow step fails and exhausts all configured retries,
|
|
27
|
+
* Upstash Workflow automatically moves the failed run to the DLQ." (https://upstash.com/docs/workflow/features/dlq); the
|
|
28
|
+
* run is RUN_FAILED, and its failure function is called ("a failure function ... executes custom logic when a workflow
|
|
29
|
+
* run fails after exhausting all retry attempts", https://upstash.com/docs/workflow/features/failure-callback) as a
|
|
30
|
+
* QStash failure callback to the failure URL, with the callback body plus the run's id and creation time, which the
|
|
31
|
+
* client's `serve()` reads, and the `Upstash-Failure-Callback-*` headers the run was started with (their prefix made
|
|
32
|
+
* `Upstash-`). */
|
|
33
|
+
export declare function failRun(ctx: SemanticsContext, m: Row, outcome: {
|
|
34
|
+
status: number;
|
|
35
|
+
body: string;
|
|
36
|
+
headers: Record<string, string>;
|
|
37
|
+
}): Promise<void>;
|
|
38
|
+
/** The failure call's outcome on its DLQ entry: CALLBACK_SUCCESS once it is answered, CALLBACK_FAIL once its retries are
|
|
39
|
+
* spent (the workflow document's `failureFunctionState` filter lists CALLBACK_INPROGRESS, CALLBACK_SUCCESS, CALLBACK_FAIL). */
|
|
40
|
+
export declare function failureCallSettled(ctx: SemanticsContext, callbackOf: string, delivered: boolean): Promise<void>;
|
|
41
|
+
/** The Workflow DLQ, newest first, as `{messages}`: "If no cursor is returned, you have reached the end". Answers
|
|
42
|
+
* `GET /v2/workflows/dlq` and the client's `GET /v2/dlq?source=workflow` (the spec patch). */
|
|
43
|
+
export declare function workflowDlq(ctx: SemanticsContext): Response;
|
|
44
|
+
export declare const workflowSemantics: Record<string, Semantics>;
|