@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,73 @@
|
|
|
1
|
+
import { type RedisValue } from './upstash-store.js';
|
|
2
|
+
export type UpstashRedisRequest = {
|
|
3
|
+
method: string;
|
|
4
|
+
path: string;
|
|
5
|
+
body?: string;
|
|
6
|
+
headers?: Record<string, string>;
|
|
7
|
+
occurredAt?: string;
|
|
8
|
+
root?: string;
|
|
9
|
+
readOnly?: boolean;
|
|
10
|
+
/**
|
|
11
|
+
* The token this twin accepts. When set, the request's credential must match it EXACTLY.
|
|
12
|
+
* When omitted the twin accepts any NON-EMPTY credential and still 401s a missing/blank one —
|
|
13
|
+
* the honest default for a local twin whose whole point is that you do not hold a real secret.
|
|
14
|
+
*/
|
|
15
|
+
token?: string;
|
|
16
|
+
};
|
|
17
|
+
export type UpstashRedisResponse = {
|
|
18
|
+
status: number;
|
|
19
|
+
body: unknown;
|
|
20
|
+
headers?: Record<string, string>;
|
|
21
|
+
};
|
|
22
|
+
/** The literal 401 body the real service returns — LIVE-PROBED, byte for byte, including the
|
|
23
|
+
* `docs.upstash.com` host (NOT `upstash.com/docs`) and the trailing period. The SAME body and
|
|
24
|
+
* status answer a missing header, a blank token and a wrong token: the vendor draws no
|
|
25
|
+
* distinction, so neither does this twin. */
|
|
26
|
+
export declare const UPSTASH_UNAUTHORIZED_ERROR = "WRONGPASS invalid or missing auth token. See https://docs.upstash.com/redis/troubleshooting/http_unauthorized for details.";
|
|
27
|
+
/**
|
|
28
|
+
* Extract the caller's credential. THREE accepted forms, all confirmed against the live service:
|
|
29
|
+
* • `Authorization: Bearer <token>` (documented, what the SDK sends)
|
|
30
|
+
* • `Authorization: <token>` (UNDOCUMENTED but accepted — probed directly)
|
|
31
|
+
* • `?_token=<token>` (documented)
|
|
32
|
+
* When both a header and `?_token=` are present, the QUERY PARAM WINS: probing showed a request
|
|
33
|
+
* with a bogus header and a valid `?_token=` succeeds.
|
|
34
|
+
*/
|
|
35
|
+
export declare function extractUpstashCredential(headers: Record<string, string> | undefined, query: URLSearchParams): string | null;
|
|
36
|
+
/**
|
|
37
|
+
* Apply `Upstash-Encoding: base64`, exactly as the real service does.
|
|
38
|
+
*
|
|
39
|
+
* THE RULE, established by probing rather than from the docs (which only say "all strings … except
|
|
40
|
+
* for the "OK" response"): every STRING in `result` is base64-encoded with `=` padding, recursively
|
|
41
|
+
* at any array depth — EXCEPT the simple-status reply whose value is exactly `OK`, which passes
|
|
42
|
+
* through raw. Integers, `null` and empty arrays are untouched. The `error` field is NEVER encoded.
|
|
43
|
+
*
|
|
44
|
+
* The exemption is scoped to the STATUS `OK`, not to the string `"OK"`. Probed evidence:
|
|
45
|
+
* SET → status OK → `{"result":"OK"}` (raw)
|
|
46
|
+
* GET of a key holding "OK" → bulk string → `{"result":"T0s="}` (encoded)
|
|
47
|
+
* PING → status PONG → `{"result":"UE9ORw=="}` (encoded — so it is not "statuses are raw")
|
|
48
|
+
* That is why the store carries a `RedisStatus` wrapper at all.
|
|
49
|
+
*/
|
|
50
|
+
export declare function encodeResult(v: RedisValue, base64: boolean): unknown;
|
|
51
|
+
/**
|
|
52
|
+
* The read-your-writes sync token. LIVE-PROBED behaviour: a base-32 counter (`0-9a-v`) that ADVANCES
|
|
53
|
+
* on a write and is ECHOED BACK unchanged on a read; an unparseable value degrades to `"0"`; the
|
|
54
|
+
* header is absent on a 401.
|
|
55
|
+
*
|
|
56
|
+
* The twin's counter is derived from the number of WRITE ACTIONS this request appended plus the
|
|
57
|
+
* caller's own token, so it is monotonic per client without any wall clock or process-global state.
|
|
58
|
+
* The SDK has a documented off-by-one (it snapshots request headers before storing the new token,
|
|
59
|
+
* so request N carries response N-2's value) and can send a missing, empty or stale token — this
|
|
60
|
+
* must therefore be PERMISSIVE and never 4xx on a bad one.
|
|
61
|
+
*/
|
|
62
|
+
export declare function nextSyncToken(incoming: string | undefined, wrote: boolean): string;
|
|
63
|
+
export type UpstashRedisSurface = 'command' | 'pipeline' | 'multi-exec' | 'path-command';
|
|
64
|
+
/** Which endpoint does this path address? Exported so a test can pin the routing table itself. */
|
|
65
|
+
export declare function routeUpstashRedisSurface(path: string): UpstashRedisSurface;
|
|
66
|
+
export declare function handleUpstashRedisTwinRequest(req: UpstashRedisRequest): Promise<UpstashRedisResponse>;
|
|
67
|
+
export declare const UPSTASH_RESOURCE_TYPES: readonly ["key", "script"];
|
|
68
|
+
export type UpstashRedisResourceType = typeof UPSTASH_RESOURCE_TYPES[number];
|
|
69
|
+
export type UpstashRedisTwinSnapshot = {
|
|
70
|
+
resourceTypes: readonly UpstashRedisResourceType[];
|
|
71
|
+
implementedEndpoints: readonly string[];
|
|
72
|
+
};
|
|
73
|
+
export declare function upstashTwinSnapshot(): UpstashRedisTwinSnapshot;
|
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
// UPSTASH REDIS REST TWIN — THE REQUEST HANDLER. Contract:
|
|
2
|
+
// handleUpstashRedisTwinRequest({ method, path, body, headers, root, occurredAt, readOnly, token })
|
|
3
|
+
// -> { status, body, headers }
|
|
4
|
+
//
|
|
5
|
+
// This file is the TRANSPORT half; every Redis semantic lives in `upstash-store.ts` and every
|
|
6
|
+
// Lua semantic in `upstash-lua.ts`. What is modeled here is Upstash's REST protocol itself,
|
|
7
|
+
// which is a real surface with real quirks:
|
|
8
|
+
//
|
|
9
|
+
// POST / body `["SET","k","v","EX",100]` one command
|
|
10
|
+
// GET|POST|PUT|HEAD /CMD/a/b path-style, args percent-decoded one command
|
|
11
|
+
// POST /set/k?EX=100 the RAW BODY becomes the last arg one command
|
|
12
|
+
// POST /pipeline body `[["SET","a","1"],["GET","a"]]` N commands, non-atomic
|
|
13
|
+
// POST /multi-exec same body shape N commands, transaction
|
|
14
|
+
//
|
|
15
|
+
// GROUNDED (2026-08-19) three ways — see spec-sources.json and the store module's header: the REST
|
|
16
|
+
// docs page, the ACTUALLY-INSTALLED `@upstash/redis@1.35.7` compiled source, and LIVE PROBES of a
|
|
17
|
+
// real ephemeral Upstash database. Every status code, error string and header rule below came off
|
|
18
|
+
// that live server, not out of a guess.
|
|
19
|
+
//
|
|
20
|
+
// ── THREE THINGS A NAIVE TWIN GETS WRONG, ALL OF THEM FATAL TO THE REAL SDK ────────────────────
|
|
21
|
+
//
|
|
22
|
+
// 1. `/pipeline` IS NOT OPTIONAL. `@upstash/redis` has `enableAutoPipelining: true` BY DEFAULT
|
|
23
|
+
// (confirmed in the installed 1.35.7 source), so a plain `await redis.get("k")` arrives as
|
|
24
|
+
// `POST /pipeline` with body `[["get","k"]]` — NOT as `POST /`. A twin serving only `POST /`
|
|
25
|
+
// fails against a default-configured client. (A fixed list of commands — `scan keys flushdb
|
|
26
|
+
// flushall dbsize hscan hgetall hkeys lrange sscan smembers xrange xrevrange zscan zrange exec` —
|
|
27
|
+
// bypasses auto-pipelining and does arrive at `POST /`.)
|
|
28
|
+
//
|
|
29
|
+
// 2. A COMMAND ERROR IS HTTP 400, NOT 200. The SDK has two error paths: on `!res.ok` it throws
|
|
30
|
+
// `UpstashError("<error>, command was: <json>")`, and on a 2xx carrying `{error}` it throws the
|
|
31
|
+
// bare message. Upstash always uses 400, so the suffixed form is what a real integrator sees —
|
|
32
|
+
// serving 200 silently changes the error text every consumer's tests assert on.
|
|
33
|
+
//
|
|
34
|
+
// 3. `Upstash-Encoding: base64` IS ON BY DEFAULT and its rule is subtler than the docs say. See
|
|
35
|
+
// `encodeResult` below: every string is base64'd at any array depth EXCEPT the simple-status
|
|
36
|
+
// reply `OK`. Getting this wrong is invisible in a curl session and catastrophic through the
|
|
37
|
+
// SDK, whose `base64decode` SWALLOWS failures and returns the raw input — so an unencoded
|
|
38
|
+
// `"PONG"` (valid base64!) decodes to mojibake rather than erroring.
|
|
39
|
+
import { commandShapeError, execRedisRun, RedisCommandError, RedisStatus, ReadOnlyError, } from "./upstash-store.js";
|
|
40
|
+
import { ownFields, twinResources } from '@volter/world-core';
|
|
41
|
+
import { SERVICE } from "./upstash-store.js";
|
|
42
|
+
/** The literal 401 body the real service returns — LIVE-PROBED, byte for byte, including the
|
|
43
|
+
* `docs.upstash.com` host (NOT `upstash.com/docs`) and the trailing period. The SAME body and
|
|
44
|
+
* status answer a missing header, a blank token and a wrong token: the vendor draws no
|
|
45
|
+
* distinction, so neither does this twin. */
|
|
46
|
+
export const UPSTASH_UNAUTHORIZED_ERROR = 'WRONGPASS invalid or missing auth token. See https://docs.upstash.com/redis/troubleshooting/http_unauthorized for details.';
|
|
47
|
+
/** LIVE-PROBED response headers. `server` carries the real service's own banner shape. */
|
|
48
|
+
const BASE_HEADERS = {
|
|
49
|
+
'content-type': 'application/json; charset=utf-8',
|
|
50
|
+
'access-control-allow-credentials': 'true',
|
|
51
|
+
server: 'Upstash Redis Database (1.17.11)',
|
|
52
|
+
};
|
|
53
|
+
/** LIVE-PROBED: 10 MB, enforced with HTTP 413 (a status the REST docs do not even list). */
|
|
54
|
+
const MAX_REQUEST_BYTES = 10_485_760;
|
|
55
|
+
function lowerHeaders(h) {
|
|
56
|
+
const out = {};
|
|
57
|
+
for (const [k, v] of Object.entries(h ?? {}))
|
|
58
|
+
out[k.toLowerCase()] = v;
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
62
|
+
// AUTH
|
|
63
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
64
|
+
/**
|
|
65
|
+
* Extract the caller's credential. THREE accepted forms, all confirmed against the live service:
|
|
66
|
+
* • `Authorization: Bearer <token>` (documented, what the SDK sends)
|
|
67
|
+
* • `Authorization: <token>` (UNDOCUMENTED but accepted — probed directly)
|
|
68
|
+
* • `?_token=<token>` (documented)
|
|
69
|
+
* When both a header and `?_token=` are present, the QUERY PARAM WINS: probing showed a request
|
|
70
|
+
* with a bogus header and a valid `?_token=` succeeds.
|
|
71
|
+
*/
|
|
72
|
+
export function extractUpstashCredential(headers, query) {
|
|
73
|
+
const fromQuery = query.get('_token');
|
|
74
|
+
if (fromQuery !== null && fromQuery !== '')
|
|
75
|
+
return fromQuery;
|
|
76
|
+
const raw = lowerHeaders(headers).authorization;
|
|
77
|
+
if (raw === undefined)
|
|
78
|
+
return null;
|
|
79
|
+
const trimmed = raw.trim();
|
|
80
|
+
// The optional-group form matters: `Authorization: Bearer ` (the scheme with an EMPTY token)
|
|
81
|
+
// trims to the bare word "Bearer". A `/^bearer\s+(.*)$/` pattern does not match it, so a naive
|
|
82
|
+
// implementation falls through and treats the literal string "Bearer" as the credential —
|
|
83
|
+
// authenticating a request that carries no token at all. Matching the scheme with an optional
|
|
84
|
+
// remainder is what makes an empty bearer resolve to `null` and get the vendor's 401.
|
|
85
|
+
const bearer = /^bearer(?:\s+(.*))?$/i.exec(trimmed);
|
|
86
|
+
const token = bearer ? (bearer[1] ?? '').trim() : trimmed;
|
|
87
|
+
return token === '' ? null : token;
|
|
88
|
+
}
|
|
89
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
90
|
+
// RESPONSE ENCODING
|
|
91
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
92
|
+
/**
|
|
93
|
+
* Apply `Upstash-Encoding: base64`, exactly as the real service does.
|
|
94
|
+
*
|
|
95
|
+
* THE RULE, established by probing rather than from the docs (which only say "all strings … except
|
|
96
|
+
* for the "OK" response"): every STRING in `result` is base64-encoded with `=` padding, recursively
|
|
97
|
+
* at any array depth — EXCEPT the simple-status reply whose value is exactly `OK`, which passes
|
|
98
|
+
* through raw. Integers, `null` and empty arrays are untouched. The `error` field is NEVER encoded.
|
|
99
|
+
*
|
|
100
|
+
* The exemption is scoped to the STATUS `OK`, not to the string `"OK"`. Probed evidence:
|
|
101
|
+
* SET → status OK → `{"result":"OK"}` (raw)
|
|
102
|
+
* GET of a key holding "OK" → bulk string → `{"result":"T0s="}` (encoded)
|
|
103
|
+
* PING → status PONG → `{"result":"UE9ORw=="}` (encoded — so it is not "statuses are raw")
|
|
104
|
+
* That is why the store carries a `RedisStatus` wrapper at all.
|
|
105
|
+
*/
|
|
106
|
+
export function encodeResult(v, base64) {
|
|
107
|
+
if (v === null || typeof v === 'number')
|
|
108
|
+
return v;
|
|
109
|
+
if (v instanceof RedisStatus) {
|
|
110
|
+
if (!base64)
|
|
111
|
+
return v.value;
|
|
112
|
+
return v.value === 'OK' ? 'OK' : Buffer.from(v.value, 'utf8').toString('base64');
|
|
113
|
+
}
|
|
114
|
+
if (typeof v === 'string')
|
|
115
|
+
return base64 ? Buffer.from(v, 'utf8').toString('base64') : v;
|
|
116
|
+
return v.map((item) => encodeResult(item, base64));
|
|
117
|
+
}
|
|
118
|
+
function itemToJson(item, base64) {
|
|
119
|
+
return 'error' in item ? { error: item.error } : { result: encodeResult(item.result, base64) };
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* The read-your-writes sync token. LIVE-PROBED behaviour: a base-32 counter (`0-9a-v`) that ADVANCES
|
|
123
|
+
* on a write and is ECHOED BACK unchanged on a read; an unparseable value degrades to `"0"`; the
|
|
124
|
+
* header is absent on a 401.
|
|
125
|
+
*
|
|
126
|
+
* The twin's counter is derived from the number of WRITE ACTIONS this request appended plus the
|
|
127
|
+
* caller's own token, so it is monotonic per client without any wall clock or process-global state.
|
|
128
|
+
* The SDK has a documented off-by-one (it snapshots request headers before storing the new token,
|
|
129
|
+
* so request N carries response N-2's value) and can send a missing, empty or stale token — this
|
|
130
|
+
* must therefore be PERMISSIVE and never 4xx on a bad one.
|
|
131
|
+
*/
|
|
132
|
+
export function nextSyncToken(incoming, wrote) {
|
|
133
|
+
const parsed = incoming !== undefined && /^[0-9a-v]+$/.test(incoming) ? Number.parseInt(incoming, 32) : 0;
|
|
134
|
+
const base = Number.isFinite(parsed) ? parsed : 0;
|
|
135
|
+
return (wrote ? base + 1 : base).toString(32);
|
|
136
|
+
}
|
|
137
|
+
const fail = (error) => ({ error });
|
|
138
|
+
const isFailure = (v) => typeof v === 'object' && v !== null && 'error' in v;
|
|
139
|
+
/**
|
|
140
|
+
* Coerce one JSON element to a command argument.
|
|
141
|
+
*
|
|
142
|
+
* LIVE-PROBED acceptance: strings, JSON numbers and JSON booleans are all valid args (`["setex",
|
|
143
|
+
* "k",60,"v"]` works). `null`, objects and nested arrays are REFUSED, each with its own message —
|
|
144
|
+
* and the object/array message quotes the offending JSON delimiter, which is a Go `json.Delim`
|
|
145
|
+
* leaking through the real server's decoder. Reproduced verbatim: a consumer matching on the real
|
|
146
|
+
* text must match here too.
|
|
147
|
+
*/
|
|
148
|
+
function toArg(v) {
|
|
149
|
+
if (typeof v === 'string')
|
|
150
|
+
return v;
|
|
151
|
+
return typeof v === 'number' || typeof v === 'boolean' ? String(v) : argRefusal(v);
|
|
152
|
+
}
|
|
153
|
+
/** An argument that is not a string, number or boolean: null, an array or an object, each with its live-probed text. */
|
|
154
|
+
function argRefusal(v) {
|
|
155
|
+
if (v === null)
|
|
156
|
+
return fail('ERR null args are not supported');
|
|
157
|
+
if (Array.isArray(v))
|
|
158
|
+
return fail('ERR unsupported arg type: "[": json.Delim');
|
|
159
|
+
return fail('ERR unsupported arg type: "{": json.Delim');
|
|
160
|
+
}
|
|
161
|
+
function toCommand(raw) {
|
|
162
|
+
const out = [];
|
|
163
|
+
for (const item of raw) {
|
|
164
|
+
const arg = toArg(item);
|
|
165
|
+
if (isFailure(arg))
|
|
166
|
+
return arg;
|
|
167
|
+
out.push(arg);
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
/** Which endpoint does this path address? Exported so a test can pin the routing table itself. */
|
|
172
|
+
export function routeUpstashRedisSurface(path) {
|
|
173
|
+
const clean = (path.split('?')[0] ?? '/').replace(/\/+$/, '') || '/';
|
|
174
|
+
if (clean === '/pipeline')
|
|
175
|
+
return 'pipeline';
|
|
176
|
+
if (clean === '/multi-exec')
|
|
177
|
+
return 'multi-exec';
|
|
178
|
+
if (clean === '/')
|
|
179
|
+
return 'command';
|
|
180
|
+
return 'path-command';
|
|
181
|
+
}
|
|
182
|
+
export async function handleUpstashRedisTwinRequest(req) {
|
|
183
|
+
const method = req.method.toUpperCase();
|
|
184
|
+
const [rawPath, rawQuery] = req.path.split('?');
|
|
185
|
+
const query = new URLSearchParams(rawQuery ?? '');
|
|
186
|
+
const path = (rawPath ?? '/').replace(/\/+$/, '') || '/';
|
|
187
|
+
const headers = lowerHeaders(req.headers);
|
|
188
|
+
const occurredAt = req.occurredAt ?? new Date().toISOString();
|
|
189
|
+
// CORS preflight: LIVE-PROBED as 200 with an empty body, echoing the request's own origin and
|
|
190
|
+
// requested headers — NOT a 405, despite the docs listing only HEAD/GET/POST/PUT.
|
|
191
|
+
if (method === 'OPTIONS')
|
|
192
|
+
return preflight(headers);
|
|
193
|
+
// LIVE-PROBED: a disallowed method answers 405 with an EMPTY body and no content-type.
|
|
194
|
+
if (!['GET', 'POST', 'PUT', 'HEAD'].includes(method))
|
|
195
|
+
return methodNotAllowed();
|
|
196
|
+
// ── auth, before anything else. A 401 carries NO sync-token header (probed). ────────────────
|
|
197
|
+
const credential = extractUpstashCredential(req.headers, query);
|
|
198
|
+
// a token the Developer API lane issued names its database (issuedDatabase); any other is the World's own database's
|
|
199
|
+
const issued = credential === null ? undefined : issuedDatabase(req.root, credential);
|
|
200
|
+
const authorized = issued !== undefined ? issued !== 'refused' : req.token === undefined ? credential !== null : credential === req.token;
|
|
201
|
+
if (!authorized) {
|
|
202
|
+
return { status: 401, body: { error: UPSTASH_UNAUTHORIZED_ERROR }, headers: { ...BASE_HEADERS } };
|
|
203
|
+
}
|
|
204
|
+
const database = issued === undefined || issued === 'refused' ? undefined : issued;
|
|
205
|
+
const base64 = headers['upstash-encoding'] !== undefined ? headers['upstash-encoding'] : null;
|
|
206
|
+
// LIVE-PROBED and CASE-SENSITIVE: `BASE64` and `hex` are both rejected by the real service.
|
|
207
|
+
if (base64 !== null && base64 !== 'base64')
|
|
208
|
+
return badEncoding(base64);
|
|
209
|
+
const encodeBase64 = base64 === 'base64';
|
|
210
|
+
if ((req.body ?? '').length > MAX_REQUEST_BYTES)
|
|
211
|
+
return tooLarge((req.body ?? '').length);
|
|
212
|
+
const surface = routeUpstashRedisSurface(path);
|
|
213
|
+
const respond = (status, body, wrote) => ({
|
|
214
|
+
status,
|
|
215
|
+
body,
|
|
216
|
+
headers: { ...BASE_HEADERS, ...(status < 400 ? { 'upstash-sync-token': nextSyncToken(headers['upstash-sync-token'], wrote) } : {}) },
|
|
217
|
+
});
|
|
218
|
+
const ctx = {
|
|
219
|
+
occurredAt,
|
|
220
|
+
...(req.root !== undefined ? { root: req.root } : {}),
|
|
221
|
+
...(req.readOnly !== undefined || database?.readOnly ? { readOnly: req.readOnly === true || database?.readOnly === true } : {}),
|
|
222
|
+
...(database !== undefined ? { database: database.id } : {}),
|
|
223
|
+
};
|
|
224
|
+
return (async () => {
|
|
225
|
+
if (surface === 'pipeline' || surface === 'multi-exec') {
|
|
226
|
+
const isTx = surface === 'multi-exec';
|
|
227
|
+
const parsed = parseBatch(req.body, isTx);
|
|
228
|
+
if (isFailure(parsed))
|
|
229
|
+
return respond(400, { error: parsed.error }, false);
|
|
230
|
+
// QUEUE-TIME validation, and the ONE place pipeline and multi-exec genuinely diverge.
|
|
231
|
+
// LIVE-PROBED: an unavailable command or a bad arity discards the WHOLE batch pre-execution
|
|
232
|
+
// (HTTP 400, a single `{error}` object, nothing applied) — but the two endpoints word it
|
|
233
|
+
// differently, and a transaction additionally names the index it choked on.
|
|
234
|
+
for (const [index, cmd] of parsed.entries()) {
|
|
235
|
+
const shape = commandShapeError(cmd);
|
|
236
|
+
if (shape !== null)
|
|
237
|
+
return respond(400, { error: batchRefusal(isTx, cmd, index, shape) }, false);
|
|
238
|
+
}
|
|
239
|
+
const { items, wrote } = await execRedisRun(parsed, ctx);
|
|
240
|
+
return respond(200, items.map((i) => itemToJson(i, encodeBase64)), wrote);
|
|
241
|
+
}
|
|
242
|
+
const command = surface === 'command' ? parseSingleBody(req.body) : parsePathCommand(path, query, req.body, method);
|
|
243
|
+
if (isFailure(command))
|
|
244
|
+
return respond(400, { error: command.error }, false);
|
|
245
|
+
const shape = commandShapeError(command);
|
|
246
|
+
if (shape !== null)
|
|
247
|
+
return respond(400, { error: shape }, false);
|
|
248
|
+
const { items, wrote } = await execRedisRun([command], ctx);
|
|
249
|
+
const only = items[0];
|
|
250
|
+
if ('error' in only)
|
|
251
|
+
return respond(400, { error: only.error }, false);
|
|
252
|
+
// HEAD answers 200 with no body at all (probed).
|
|
253
|
+
if (method === 'HEAD')
|
|
254
|
+
return respond(200, null, wrote);
|
|
255
|
+
return respond(200, { result: encodeResult(only.result, encodeBase64) }, wrote);
|
|
256
|
+
})().catch((e) => runFault(e, respond));
|
|
257
|
+
}
|
|
258
|
+
/** A CORS preflight: LIVE-PROBED as 200 with an empty body, echoing the request's own origin and requested headers. */
|
|
259
|
+
function preflight(headers) {
|
|
260
|
+
return {
|
|
261
|
+
status: 200,
|
|
262
|
+
body: null,
|
|
263
|
+
headers: {
|
|
264
|
+
'access-control-allow-origin': headers.origin ?? '*',
|
|
265
|
+
'access-control-allow-methods': 'POST',
|
|
266
|
+
'access-control-allow-headers': headers['access-control-request-headers'] ?? 'authorization,content-type',
|
|
267
|
+
'access-control-allow-credentials': 'true',
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
/** A method the REST API does not take: 405 with an EMPTY body and no content-type (live-probed). */
|
|
272
|
+
function methodNotAllowed() {
|
|
273
|
+
return { status: 405, body: null };
|
|
274
|
+
}
|
|
275
|
+
/** An `Upstash-Encoding` other than `base64` (live-probed, case-sensitive). */
|
|
276
|
+
function badEncoding(value) {
|
|
277
|
+
return { status: 400, body: { error: `ERR invalid Upstash-Encoding header: "${value}"` }, headers: { ...BASE_HEADERS } };
|
|
278
|
+
}
|
|
279
|
+
/** A body over the 10 MB request limit: 413 (live-probed; upstash.com/docs/redis/troubleshooting/max_request_size_exceeded). */
|
|
280
|
+
function tooLarge(size) {
|
|
281
|
+
return {
|
|
282
|
+
status: 413,
|
|
283
|
+
body: { error: `ERR max request size exceeded. Limit: ${MAX_REQUEST_BYTES} bytes, Actual: ${size} bytes. See https://upstash.com/docs/redis/troubleshooting/max_request_size_exceeded for details` },
|
|
284
|
+
headers: { ...BASE_HEADERS },
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
/** A batch with a command refused at queue time (an unavailable command, a bad arity): the whole batch is discarded, a
|
|
288
|
+
* pipeline with the command's error, a transaction naming it as Redis's EXECABORT does, or by its index (live-probed). */
|
|
289
|
+
function batchRefusal(isTx, cmd, index, shape) {
|
|
290
|
+
if (!isTx)
|
|
291
|
+
return shape;
|
|
292
|
+
return shape.startsWith('ERR wrong number of arguments')
|
|
293
|
+
? `EXECABORT Transaction discarded because of previous errors: ['${String(cmd[0] ?? '').toUpperCase()}': ${shape}]`
|
|
294
|
+
: `${shape} at index ${index}, discarding transaction`;
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* A run that threw: a write on a read-only twin (405), a command error raised outside a command's own item (400), or a
|
|
298
|
+
* fault inside the twin, answered as a loud, twin-attributed 500 so a caller always gets an answer it can reason about.
|
|
299
|
+
*/
|
|
300
|
+
function runFault(e, respond) {
|
|
301
|
+
if (e instanceof ReadOnlyError)
|
|
302
|
+
return { status: 405, body: { error: e.message }, headers: { ...BASE_HEADERS } };
|
|
303
|
+
if (e instanceof RedisCommandError)
|
|
304
|
+
return respond(400, { error: e.message }, false);
|
|
305
|
+
return {
|
|
306
|
+
status: 500,
|
|
307
|
+
body: { error: `ERR twin: internal fault handling this command — ${e instanceof Error ? e.message : String(e)}` },
|
|
308
|
+
headers: { ...BASE_HEADERS },
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
/** A body that does not parse as JSON, refused with `message`. */
|
|
312
|
+
function notJson(message) {
|
|
313
|
+
return fail(message);
|
|
314
|
+
}
|
|
315
|
+
/** A path segment with a malformed percent escape, passed through as it was sent rather than turned into a 500. */
|
|
316
|
+
function undecodable(segment) {
|
|
317
|
+
return segment;
|
|
318
|
+
}
|
|
319
|
+
/** `POST /` — the whole command as one JSON array. */
|
|
320
|
+
function parseSingleBody(body) {
|
|
321
|
+
if (body === undefined || body.trim() === '')
|
|
322
|
+
return fail('EOF'); // LIVE-PROBED: no `ERR ` prefix
|
|
323
|
+
let parsed;
|
|
324
|
+
// LIVE-PROBED: a body that is not JSON answers `expected JSON array`, with no `ERR ` prefix
|
|
325
|
+
try {
|
|
326
|
+
parsed = JSON.parse(body);
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
return notJson('expected JSON array');
|
|
330
|
+
}
|
|
331
|
+
if (!Array.isArray(parsed))
|
|
332
|
+
return fail('expected JSON array');
|
|
333
|
+
if (parsed.length === 0)
|
|
334
|
+
return fail('ERR empty command');
|
|
335
|
+
return toCommand(parsed);
|
|
336
|
+
}
|
|
337
|
+
/** `POST /pipeline` and `POST /multi-exec` — an array of command arrays. */
|
|
338
|
+
function parseBatch(body, isTx) {
|
|
339
|
+
const what = isTx ? 'transaction' : 'pipeline';
|
|
340
|
+
if (body === undefined || body.trim() === '')
|
|
341
|
+
return fail(`ERR failed to parse ${what} request`);
|
|
342
|
+
let parsed;
|
|
343
|
+
try {
|
|
344
|
+
parsed = JSON.parse(body);
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
return notJson(`ERR failed to parse ${what} request`);
|
|
348
|
+
}
|
|
349
|
+
if (!Array.isArray(parsed))
|
|
350
|
+
return fail(`ERR failed to parse ${what} request`);
|
|
351
|
+
if (parsed.length === 0)
|
|
352
|
+
return fail(`ERR empty ${what} request`);
|
|
353
|
+
const out = [];
|
|
354
|
+
for (const [index, entry] of parsed.entries()) {
|
|
355
|
+
// A 1-D array (someone POSTing a single command to /pipeline) is named by INDEX, and the
|
|
356
|
+
// transaction endpoint uses the generic parse message instead — both probed.
|
|
357
|
+
if (!Array.isArray(entry))
|
|
358
|
+
return fail(isTx ? `ERR failed to parse ${what} request` : `ERR failed to parse pipeline command at index ${index}`);
|
|
359
|
+
const cmd = toCommand(entry);
|
|
360
|
+
if (isFailure(cmd))
|
|
361
|
+
return cmd;
|
|
362
|
+
out.push(cmd);
|
|
363
|
+
}
|
|
364
|
+
return out;
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Path-style: `/{COMMAND}/{arg1}/…/{argN}`, args PERCENT-DECODED, command name case-insensitive.
|
|
368
|
+
*
|
|
369
|
+
* `POST /set/foo` with a raw body appends that body as the command's LAST argument, and any query
|
|
370
|
+
* parameters become trailing option tokens (`POST /set/foo?EX=100` ⇒ `SET foo <body> EX 100`) —
|
|
371
|
+
* both documented and probed. `_token` is stripped: it is the credential, not an argument.
|
|
372
|
+
*/
|
|
373
|
+
function parsePathCommand(path, query, body, method) {
|
|
374
|
+
const segments = path.replace(/^\/+/, '').split('/');
|
|
375
|
+
const command = [];
|
|
376
|
+
for (const seg of segments) {
|
|
377
|
+
try {
|
|
378
|
+
command.push(decodeURIComponent(seg));
|
|
379
|
+
}
|
|
380
|
+
catch {
|
|
381
|
+
command.push(undecodable(seg));
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
if (command.length === 0 || command[0] === '')
|
|
385
|
+
return fail('ERR empty command');
|
|
386
|
+
if ((method === 'POST' || method === 'PUT') && body !== undefined && body !== '')
|
|
387
|
+
command.push(body);
|
|
388
|
+
for (const [key, value] of query.entries()) {
|
|
389
|
+
if (key === '_token')
|
|
390
|
+
continue;
|
|
391
|
+
command.push(key);
|
|
392
|
+
if (value !== '')
|
|
393
|
+
command.push(value);
|
|
394
|
+
}
|
|
395
|
+
return command;
|
|
396
|
+
}
|
|
397
|
+
// ── the self-referential conformance snapshot ──────────────────────────────────────────────────
|
|
398
|
+
// TWO kernel subject types, not one. 'script' was added when the EVAL script cache moved out of
|
|
399
|
+
// the keyspace (§9 self-audit) — and §9 round 1 then caught that this inventory still said 'key'
|
|
400
|
+
// alone, which made the conformance check certify a stale number. The inventory is what the
|
|
401
|
+
// conformance probe is FOR, so it has to name everything the twin actually writes.
|
|
402
|
+
/**
|
|
403
|
+
* The database a REST token names, when the Developer API lane (../api) issued it: the database's id, and whether it is
|
|
404
|
+
* the Read Only token ("Read Only token permits access to the read commands only",
|
|
405
|
+
* upstash.com/docs/redis/features/restapi). A token of a deleted database, or one a password reset revoked, is refused with the vendor's 401, as a wrong
|
|
406
|
+
* one is. A token no lane database holds is the World's own database's, as every token was before the lane existed: an
|
|
407
|
+
* application pointed at the World (Dub) carries whatever token its own configuration holds. That rule, and a Read Only
|
|
408
|
+
* token's write answering the twin's read-only refusal (the grounding database exposed no Read Only token to probe), are
|
|
409
|
+
* the twin's decisions.
|
|
410
|
+
*/
|
|
411
|
+
function issuedDatabase(root, token) {
|
|
412
|
+
const row = twinResources(SERVICE, root).filter((r) => r.type === '_database').map((r) => ownFields(r))
|
|
413
|
+
.find((d) => d.rest_token === token || d.read_only_rest_token === token || (Array.isArray(d._revoked_tokens) && d._revoked_tokens.includes(token)));
|
|
414
|
+
if (row === undefined)
|
|
415
|
+
return undefined;
|
|
416
|
+
// a token its database's password reset revoked ("You can revoke both Standard and Read Only tokens by resetting
|
|
417
|
+
// password of your database", upstash.com/docs/redis/features/restapi) is refused as a wrong one is
|
|
418
|
+
if (row.rest_token !== token && row.read_only_rest_token !== token)
|
|
419
|
+
return 'refused';
|
|
420
|
+
if (row._deleted_at)
|
|
421
|
+
return 'refused';
|
|
422
|
+
return { id: String(row.database_id), readOnly: row.read_only_rest_token === token };
|
|
423
|
+
}
|
|
424
|
+
export const UPSTASH_RESOURCE_TYPES = ['key', 'script'];
|
|
425
|
+
export function upstashTwinSnapshot() {
|
|
426
|
+
return {
|
|
427
|
+
resourceTypes: UPSTASH_RESOURCE_TYPES,
|
|
428
|
+
implementedEndpoints: [
|
|
429
|
+
'POST / (one command as a JSON array body)',
|
|
430
|
+
'GET /{COMMAND}/{args...} (path-style, percent-decoded)',
|
|
431
|
+
'POST /{COMMAND}/{args...} (path-style; the raw body becomes the last argument)',
|
|
432
|
+
'POST /pipeline (array of command arrays, non-atomic, per-item {result}/{error})',
|
|
433
|
+
'POST /multi-exec (array of command arrays, transaction, queue-time EXECABORT)',
|
|
434
|
+
'OPTIONS / (CORS preflight)',
|
|
435
|
+
],
|
|
436
|
+
};
|
|
437
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@volter/twin-upstash",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Local Upstash Redis (REST API) twin: a real stateful Redis command core (strings/counters/TTL/hashes/sets/sorted-sets/keyspace) served over Upstash's per-database REST protocol — single-command POST /, path-style GET /get/key, POST /pipeline, POST /multi-exec, Bearer-token auth, the {result}/{error} envelope, Upstash-Encoding: base64 — plus an EVAL/EVALSHA Lua-subset interpreter that runs @upstash/ratelimit's real fixed-window/sliding-window/token-bucket scripts. Built on @volter/world-core.",
|
|
5
|
+
"author": "Volter (https://github.com/volter-ai)",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"files": [
|
|
8
|
+
"src",
|
|
9
|
+
"api/src",
|
|
10
|
+
"qstash/src",
|
|
11
|
+
"README.md",
|
|
12
|
+
"LICENSE",
|
|
13
|
+
"!**/*.test.ts",
|
|
14
|
+
"dist"
|
|
15
|
+
],
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/volter-ai/twin.git",
|
|
19
|
+
"directory": "packages/twin/upstash"
|
|
20
|
+
},
|
|
21
|
+
"homepage": "https://github.com/volter-ai/twin/tree/main/packages/twin/upstash#readme",
|
|
22
|
+
"type": "module",
|
|
23
|
+
"exports": {
|
|
24
|
+
".": {
|
|
25
|
+
"types": "./dist/src/index.d.ts",
|
|
26
|
+
"default": "./dist/src/index.js"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"bin": {
|
|
30
|
+
"world-upstash": "dist/src/cli.js"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"test": "bun test src/*.test.ts",
|
|
34
|
+
"typecheck": "tsc --noEmit",
|
|
35
|
+
"build": "node ../../../scripts/publish/build.mjs",
|
|
36
|
+
"prepack": "node ../../../scripts/publish/prepare-publish.mjs prepack",
|
|
37
|
+
"postpack": "node ../../../scripts/publish/prepare-publish.mjs postpack"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@volter/world-ui": "0.1.0",
|
|
41
|
+
"react": "^19.2.7",
|
|
42
|
+
"react-dom": "^19.2.7"
|
|
43
|
+
},
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"@volter/world-core": "2.0.0"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@types/bun": "^1.2.20",
|
|
49
|
+
"@types/node": "^24.0.0",
|
|
50
|
+
"@volter/world-core": "2.0.0",
|
|
51
|
+
"@volter/world-tooling": "0.1.0",
|
|
52
|
+
"@upstash/redis": "1.35.7",
|
|
53
|
+
"@upstash/ratelimit": "2.0.8",
|
|
54
|
+
"typescript": "^5.9.0"
|
|
55
|
+
},
|
|
56
|
+
"engines": {
|
|
57
|
+
"node": ">=22.3"
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// The World's doors in front of the QStash lane (docs/contributing/architecture.md, "Doors, screens and the gap"). QStash
|
|
2
|
+
// delivers to the customer's own HTTP endpoints, which are outside Upstash: what an endpoint received and how it answers
|
|
3
|
+
// are the World's, read and stated here, as Slack's app deliveries are read (packages/twin/slack/src/slack-interactions.ts,
|
|
4
|
+
// `GET /_twin/deliveries?to=`) and as an outside mail server's answer is stated (packages/twin/resend/src/resend-doors.ts,
|
|
5
|
+
// `POST /_twin/recipients/:domain`).
|
|
6
|
+
//
|
|
7
|
+
// - GET /_twin/deliveries?to=<url> → {to, deliveries}: every request QStash made to that URL, oldest first, each as the
|
|
8
|
+
// endpoint received it (its method, headers and body) and the status it answered.
|
|
9
|
+
// - POST /_twin/destinations {url, status, body?, headers?, when?} → 204: the World's word on how the endpoints under a URL
|
|
10
|
+
// answer, and, with `when` (header names and values), how they answer a delivery carrying those headers (a workflow's
|
|
11
|
+
// route answers its failure call, `Upstash-Workflow-Is-Failure: true`, apart from its steps). The statement that names
|
|
12
|
+
// headers the delivery carries decides before one that names none, then the longest prefix. A fact about the World, like a person's password: QStash's own
|
|
13
|
+
// delivery attempt reads it (semantics/delivery.ts), and retries an endpoint that answers an error. A destination the
|
|
14
|
+
// World states nothing for is asked over HTTP where the World lets the request out.
|
|
15
|
+
// - POST /v2/_twin/drain → the catch-up's report: the World's runner (a served World's drainer) asks the lane to run
|
|
16
|
+
// what the World clock has made due now. Every request the lane answers runs the same catch-up first; this one asks for
|
|
17
|
+
// nothing else. It is not QStash's API and answers only here.
|
|
18
|
+
import type { DerivedOperation, SemanticsContext } from '@volter/world-core';
|
|
19
|
+
import { DELIVERY, DESTINATION, type CatchUpReport } from './semantics/delivery.ts';
|
|
20
|
+
|
|
21
|
+
/** A statement without an http(s) URL and a numeric status: refused, the World unchanged. */
|
|
22
|
+
function malformedStatement(): Response {
|
|
23
|
+
return Response.json({ error: 'a url (http or https) and a status (a number) are required' }, { status: 400 });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The operation a door's context is named by: no spec operation is. */
|
|
27
|
+
export const DOOR: DerivedOperation = { id: 'TwinDoor', method: 'POST', path: '/_twin', class: 'action' };
|
|
28
|
+
|
|
29
|
+
/** A door's answer, or undefined for a request that is not a door's. `report` is what the catch-up in front did. */
|
|
30
|
+
export async function door(ctx: SemanticsContext, request: Request, report: CatchUpReport): Promise<Response | undefined> {
|
|
31
|
+
const url = new URL(request.url);
|
|
32
|
+
const path = url.pathname.replace(/\/+$/, '');
|
|
33
|
+
if (request.method === 'POST' && path === '/v2/_twin/drain') return Response.json(report);
|
|
34
|
+
if (request.method === 'GET' && path === '/_twin/deliveries') {
|
|
35
|
+
const to = url.searchParams.get('to') ?? '';
|
|
36
|
+
if (!to) return Response.json({ error: 'to is required' }, { status: 400 });
|
|
37
|
+
const deliveries = ctx.rowsRaw(DELIVERY).filter((d) => d.to === to)
|
|
38
|
+
.sort((a, b) => String(a.at).localeCompare(String(b.at)) || Number(a.n) - Number(b.n))
|
|
39
|
+
.map((d) => ({ messageId: d.message_id, retried: d.retried, at: d.at, method: d.method, headers: d.headers, body: d.body, status: d.status }));
|
|
40
|
+
return Response.json({ to, deliveries });
|
|
41
|
+
}
|
|
42
|
+
if (request.method === 'POST' && path === '/_twin/destinations') {
|
|
43
|
+
const b = (ctx.body && typeof ctx.body === 'object' ? ctx.body : {}) as Record<string, unknown>;
|
|
44
|
+
if (typeof b.url !== 'string' || !/^https?:\/\//.test(b.url) || typeof b.status !== 'number') return malformedStatement();
|
|
45
|
+
const when = b.when && typeof b.when === 'object' ? (b.when as Record<string, string>) : {};
|
|
46
|
+
const id = `${b.url} ${Object.entries(when).map(([k, v]) => `${k.toLowerCase()}=${v}`).sort().join('&')}`.trim();
|
|
47
|
+
await ctx.record(DESTINATION, { url: b.url, status: b.status, body: typeof b.body === 'string' ? b.body : '', headers: b.headers ?? {}, when }, id);
|
|
48
|
+
return new Response(null, { status: 204 });
|
|
49
|
+
}
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|