@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,1437 @@
|
|
|
1
|
+
// THE REDIS COMMAND CORE — a real, stateful Redis data model folded out of the @volter/world-core kernel
|
|
2
|
+
// action log. This is the file that makes this pack a twin rather than a stub: `GET` returns what
|
|
3
|
+
// `SET` actually wrote, `INCR` on a non-numeric string raises the vendor's own
|
|
4
|
+
// "ERR value is not an integer or out of range", `LPUSH` against a string raises `WRONGTYPE`, and
|
|
5
|
+
// a key with a TTL stops existing once the request clock passes its deadline.
|
|
6
|
+
//
|
|
7
|
+
// GROUNDED (2026-08-19) against three independent sources, all cross-checked — see spec-sources.json:
|
|
8
|
+
// (a) upstash.com/docs/redis/features/restapi (the REST protocol page);
|
|
9
|
+
// (b) the ACTUALLY-INSTALLED `@upstash/redis@1.35.7` package's own compiled source
|
|
10
|
+
// (node_modules/@upstash/redis/chunk-TBGBPMGD.mjs — its HttpClient, Command, Pipeline and
|
|
11
|
+
// per-command argument builders), read read-only;
|
|
12
|
+
// (c) LIVE PROBES of a real ephemeral Upstash database (`upstash.com/start-redis`), which is where
|
|
13
|
+
// every literal error string, HTTP status and base64 rule below comes from. Where the docs and
|
|
14
|
+
// the live server disagreed, the live server won (e.g. the docs' "value is not an int" is
|
|
15
|
+
// really "value is not an integer").
|
|
16
|
+
//
|
|
17
|
+
// ── STATE LIVES IN THE KERNEL, NOT IN A MAP ───────────────────────────────────────────────────
|
|
18
|
+
// Every Redis key is ONE kernel subject (`type:'key'`, id `key:<name>`), written through
|
|
19
|
+
// `applyTwinWrite` and read back through `projectResources`. There is no side-store: kill the
|
|
20
|
+
// process, point a new one at the same root, and `GET` still answers.
|
|
21
|
+
//
|
|
22
|
+
// ── THE CLOCK IS INJECTED, NEVER READ FROM THE WALL ───────────────────────────────────────────
|
|
23
|
+
// TTLs need a clock, and this repo forbids `Date.now()` inside twin behaviour (a verify must be
|
|
24
|
+
// bit-reproducible). So expiry is computed against the CURRENT REQUEST'S `occurredAt` — the same
|
|
25
|
+
// timestamp the kernel stamps the action with. A verify pins `occurredAt` and gets deterministic
|
|
26
|
+
// TTL arithmetic; to test EXPIRY it issues the next request with a LATER pinned `occurredAt`, which
|
|
27
|
+
// is a clock advance expressed as data. `createUpstashRedisTwinServer({ now })` exposes the same
|
|
28
|
+
// seam over HTTP for a test that drives the real SDK.
|
|
29
|
+
//
|
|
30
|
+
// Expiration is LAZY, exactly like Redis: an expired key is not erased by a sweep, it simply stops
|
|
31
|
+
// being visible to every read and is overwritten by the next write. `DBSIZE`, `KEYS`, `SCAN`,
|
|
32
|
+
// `EXISTS`, `TTL` and `TYPE` all agree with `GET` about which keys are alive at a given instant.
|
|
33
|
+
//
|
|
34
|
+
// ── ONE REQUEST = ONE SYNCHRONOUS RUN OVER A KEYSPACE SNAPSHOT ────────────────────────────────
|
|
35
|
+
// `execRedisRun` folds the action log ONCE into a `KeySpace` (a plain synchronous map), executes
|
|
36
|
+
// every command in the request against it, then flushes the keys that were actually touched back
|
|
37
|
+
// into the kernel. Two things fall out of that shape and both matter:
|
|
38
|
+
// • `EVAL` becomes possible at all. `redis.call` is SYNCHRONOUS in Lua and cannot await a kernel
|
|
39
|
+
// append; because the command core is synchronous over the snapshot, a script's calls run
|
|
40
|
+
// inline and its writes are visible to its own later reads — which @upstash/ratelimit's scripts
|
|
41
|
+
// depend on (`if r == tonumber(incrementBy)` branches on the result of its own INCRBY).
|
|
42
|
+
// • A script is ATOMIC. Its writes only reach the log at flush time, so a script that raises
|
|
43
|
+
// part-way leaves nothing behind.
|
|
44
|
+
// The DISCLOSED cost: within one request, N writes to the same key collapse into ONE kernel action
|
|
45
|
+
// carrying the final value, so the action log records the request's net effect rather than every
|
|
46
|
+
// intermediate. State is identical either way; only the granularity of the audit trail differs.
|
|
47
|
+
//
|
|
48
|
+
// ── COMMANDS BY REDIS'S TABLE ─────────────────────────────────────────────────────────────────
|
|
49
|
+
// Every command is named by its id in Redis's command table (spec/commands, derived into
|
|
50
|
+
// generated/surface.gen.json): a container's subcommand by both words (`SCRIPT LOAD`). Its arity is the
|
|
51
|
+
// table's (`arity`: the argument count with the name, negative for "at least"), refused with Redis's own
|
|
52
|
+
// "wrong number of arguments". A command the table has but this twin does not serve is the gap, and it
|
|
53
|
+
// answers as Upstash answers a command it does not know — as does a command the table does not have:
|
|
54
|
+
// `ERR Command is not available: 'FOO'. See https://upstash.com/docs/redis/overall/rediscompatibility
|
|
55
|
+
// for details` (live-probed; the compatibility page lists what Upstash serves). `upstashRedisOwners`
|
|
56
|
+
// counts the two. Nothing here ever invents a success for an operation it does not implement.
|
|
57
|
+
import { createHash } from 'node:crypto';
|
|
58
|
+
import { applyTwinWrite, projectResources } from '@volter/world-core';
|
|
59
|
+
import surface from './generated/surface.gen.json' with { type: 'json' };
|
|
60
|
+
import { luaScriptSha1, LuaError, LuaTable, runLua, type LuaValue } from './upstash-lua.ts';
|
|
61
|
+
|
|
62
|
+
export const SERVICE = 'upstash';
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A Redis SIMPLE STATUS reply, kept distinct from a bulk string.
|
|
66
|
+
*
|
|
67
|
+
* This is not pedantry — it is required for `Upstash-Encoding: base64` fidelity. LIVE-PROBED rule:
|
|
68
|
+
* with that header on, Upstash base64-encodes every string in `result` at any array depth EXCEPT
|
|
69
|
+
* the simple-status reply `OK`, which is passed through raw. `PING`'s status `PONG` IS encoded
|
|
70
|
+
* (`UE9ORw==`), and so is a BULK STRING whose value happens to be `OK` (`GET okkey` → `T0s=`). So
|
|
71
|
+
* the exemption is scoped to the status reply `OK` specifically, and a twin that exempted any
|
|
72
|
+
* string equal to `"OK"` would send raw bytes for `GET okkey`. Hence this wrapper.
|
|
73
|
+
*/
|
|
74
|
+
export class RedisStatus {
|
|
75
|
+
constructor(readonly value: string) {}
|
|
76
|
+
}
|
|
77
|
+
const OK = new RedisStatus('OK');
|
|
78
|
+
|
|
79
|
+
/** The JSON projection of a RESP reply — what Upstash puts in `{"result": …}`. */
|
|
80
|
+
export type RedisValue = null | number | string | RedisStatus | RedisValue[];
|
|
81
|
+
|
|
82
|
+
/** A command-level failure. `message` is the vendor's literal error string. Maps to HTTP 400. */
|
|
83
|
+
export class RedisCommandError extends Error {
|
|
84
|
+
constructor(message: string) {
|
|
85
|
+
super(message);
|
|
86
|
+
this.name = 'RedisCommandError';
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Thrown when a write is attempted against a read-only twin. Mapped to HTTP 405 by the handler. */
|
|
91
|
+
export class ReadOnlyError extends Error {
|
|
92
|
+
constructor() {
|
|
93
|
+
super('read_only: this twin was started read-only; omit readOnly to accept writes');
|
|
94
|
+
this.name = 'ReadOnlyError';
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export const KEY_TYPES = ['string', 'list', 'set', 'hash', 'zset', 'stream'] as const;
|
|
99
|
+
export type KeyType = typeof KEY_TYPES[number];
|
|
100
|
+
|
|
101
|
+
type HashPairs = Array<[string, string]>;
|
|
102
|
+
type ZSetPairs = Array<[string, number]>;
|
|
103
|
+
/**
|
|
104
|
+
* How a zset is STORED. Scores persist as the STRING Redis itself would print, never as raw JSON
|
|
105
|
+
* numbers — because `JSON.stringify(Infinity)` is `null`, and `ZADD key +inf member` is ordinary
|
|
106
|
+
* Redis (leaderboard sentinels, "pin this to the top" idioms). §9 round 1 caught the bug this
|
|
107
|
+
* prevents: an in-memory `+inf` survived within one request and came back as the literal bulk
|
|
108
|
+
* string "null" on the next, whereupon `ZRANGEBYSCORE key 0 100` cheerfully returned the member —
|
|
109
|
+
* a plausible-looking WRONG answer over persisted state, which is the exact failure mode this pack
|
|
110
|
+
* forbids. `fmtScore`/`toFloat` round-trip inf, -inf and every finite double faithfully.
|
|
111
|
+
*/
|
|
112
|
+
type StoredZSet = Array<[string, string]>;
|
|
113
|
+
type StreamEntries = Array<[string, HashPairs]>;
|
|
114
|
+
|
|
115
|
+
/** A point-in-time copy of a run's whole mutable state, for script rollback. */
|
|
116
|
+
type Snapshot = { rows: Map<string, KeyRow>; touched: Map<string, string>; scripts: Map<string, string>; touchedScripts: Map<string, string> };
|
|
117
|
+
|
|
118
|
+
type KeyRow = {
|
|
119
|
+
name: string;
|
|
120
|
+
kind: KeyType;
|
|
121
|
+
/** string → string; list/set → string[]; hash → [f,v][]; zset → [member,score][]; stream → [id,[f,v][]][] */
|
|
122
|
+
v: string | string[] | HashPairs | ZSetPairs | StreamEntries;
|
|
123
|
+
/** Absolute expiry deadline in epoch ms, or null for "no TTL". */
|
|
124
|
+
pexpireAt: number | null;
|
|
125
|
+
/** Stream only: the last id this stream minted, so `*` stays monotonic. */
|
|
126
|
+
lastId?: string;
|
|
127
|
+
/**
|
|
128
|
+
* A per-key WRITE ORDINAL, incremented on every flush. No command ever reads it and it never
|
|
129
|
+
* leaves this module — it exists solely to defeat the kernel's action dedupe.
|
|
130
|
+
*
|
|
131
|
+
* THE BUG IT FIXES (found by self-attack after §9 round 1): `applyTwinWrite` dedupes by
|
|
132
|
+
* (content + occurredAt millisecond), so writing a key back to a value it ALREADY HELD at the
|
|
133
|
+
* same instant produced an actionId identical to the earlier action and was silently dropped as
|
|
134
|
+
* `replayed`. The projection then folded to the LATER surviving action, so the key kept the
|
|
135
|
+
* WRONG value while the command's reply reported the right one — reply and state disagreeing,
|
|
136
|
+
* which is the worst shape a wrong answer can take. Concretely, with a pinned clock:
|
|
137
|
+
* ZADD e 10 m ; ZADD e INCR 5 m → 15 ; ZADD e XX INCR 5 m → 20 ; ZADD e GT INCR 5 m → 25 ;
|
|
138
|
+
* ZADD e LT INCR -5 m → answered 20, but ZSCORE still said 25
|
|
139
|
+
* because a `[["m","20"]]` action already existed at that instant. The same hazard reaches any
|
|
140
|
+
* `SET k a ; SET k b ; SET k a` landing inside one millisecond. Bumping an ordinal makes every
|
|
141
|
+
* write's content unique, so nothing can be mistaken for a replay.
|
|
142
|
+
*/
|
|
143
|
+
_rev?: number;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
/** Everything a run needs. `root` scopes the kernel state; `occurredAt` IS the clock. */
|
|
147
|
+
export type RedisContext = {
|
|
148
|
+
root?: string;
|
|
149
|
+
occurredAt: string;
|
|
150
|
+
/** When true, any write command raises `ReadOnlyError` (the handler answers 405). */
|
|
151
|
+
readOnly?: boolean;
|
|
152
|
+
/** The database a Developer API lane made (its `database_id`), whose keys and scripts are its own: subjects
|
|
153
|
+
* `db:<id>:key:<name>` and `db:<id>:script:<sha1>`. Absent, the World's own database: `key:<name>`, `script:<sha1>`. */
|
|
154
|
+
database?: string;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/** The subject-id prefix of a context's database. */
|
|
158
|
+
const scopeOf = (ctx: RedisContext): string => (ctx.database === undefined ? '' : `db:${ctx.database}:`);
|
|
159
|
+
|
|
160
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
161
|
+
// ERROR STRINGS — every one of these is LIVE-PROBED off a real Upstash database, byte for byte.
|
|
162
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
163
|
+
const WRONGTYPE = 'WRONGTYPE Operation against a key holding the wrong kind of value';
|
|
164
|
+
const NOT_INT = 'ERR value is not an integer or out of range';
|
|
165
|
+
const NOT_FLOAT = 'ERR value is not a valid float';
|
|
166
|
+
const SYNTAX = 'ERR syntax error';
|
|
167
|
+
const NO_SUCH_KEY = 'ERR no such key';
|
|
168
|
+
const NOSCRIPT = 'NOSCRIPT No matching script. Please use EVAL.';
|
|
169
|
+
|
|
170
|
+
/** Upstash's own unavailable-command message — NOT stock Redis's "unknown command". LIVE-PROBED. */
|
|
171
|
+
export function unavailableCommand(name: string): string {
|
|
172
|
+
return `ERR Command is not available: '${name.toUpperCase()}'. See https://upstash.com/docs/redis/overall/rediscompatibility for details`;
|
|
173
|
+
}
|
|
174
|
+
/** Commands the REST layer refuses outright — LIVE-PROBED message, name upper-cased in quotes. */
|
|
175
|
+
export function restRestricted(name: string): string {
|
|
176
|
+
return `ERR Command "${name.toUpperCase()}" is not allowed in REST or it has a special context path`;
|
|
177
|
+
}
|
|
178
|
+
export function wrongArity(name: string): string {
|
|
179
|
+
return `ERR wrong number of arguments for '${name.toLowerCase()}' command`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Commands the REST surface rejects even though Redis has them: connection/transaction/pub-sub
|
|
184
|
+
* control that has no meaning over stateless HTTP. LIVE-PROBED (`AUTH`, `MULTI`, `WATCH`,
|
|
185
|
+
* `SUBSCRIBE`, `CLIENT` all answered the `not allowed in REST` message).
|
|
186
|
+
*/
|
|
187
|
+
export const REST_RESTRICTED_COMMANDS = new Set([
|
|
188
|
+
'AUTH', 'MULTI', 'EXEC', 'DISCARD', 'WATCH', 'UNWATCH', 'HELLO', 'RESET',
|
|
189
|
+
'SUBSCRIBE', 'UNSUBSCRIBE', 'PSUBSCRIBE', 'PUNSUBSCRIBE', 'CLIENT', 'MONITOR',
|
|
190
|
+
]);
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The commands this twin serves, each with the most arguments (after the name) its own parsing takes,
|
|
194
|
+
* `-1` for no bound: `[min, max]`, where `min` is the table's arity (checked equal for every entry) and
|
|
195
|
+
* `max` is what the command itself refuses past, with the same "wrong number of arguments". One table for
|
|
196
|
+
* both callers, `execOne` and `commandShapeError` (which lets `/multi-exec` reject a malformed batch at
|
|
197
|
+
* QUEUE time, as Redis's `EXECABORT` does). SCRIPT is a container: its subcommands are the table's ids.
|
|
198
|
+
*/
|
|
199
|
+
export const SERVED_COMMANDS: Record<string, [number, number]> = {
|
|
200
|
+
PING: [0, 1], ECHO: [1, 1], DBSIZE: [0, 0], TYPE: [1, 1], FLUSHDB: [0, 1], FLUSHALL: [0, 1],
|
|
201
|
+
KEYS: [1, 1], RANDOMKEY: [0, 0], SCAN: [1, -1], SELECT: [1, 1],
|
|
202
|
+
EXISTS: [1, -1], TOUCH: [1, -1], DEL: [1, -1], UNLINK: [1, -1],
|
|
203
|
+
EXPIRE: [2, -1], PEXPIRE: [2, -1], EXPIREAT: [2, -1], PEXPIREAT: [2, -1],
|
|
204
|
+
TTL: [1, 1], PTTL: [1, 1], PERSIST: [1, 1], RENAME: [2, 2], RENAMENX: [2, 2],
|
|
205
|
+
GET: [1, 1], GETDEL: [1, 1], GETSET: [2, 2], GETEX: [1, 3], SET: [2, -1], SETNX: [2, 2],
|
|
206
|
+
SETEX: [3, 3], PSETEX: [3, 3], MGET: [1, -1], MSET: [2, -1], MSETNX: [2, -1],
|
|
207
|
+
APPEND: [2, 2], STRLEN: [1, 1],
|
|
208
|
+
INCR: [1, 1], DECR: [1, 1], INCRBY: [2, 2], DECRBY: [2, 2], INCRBYFLOAT: [2, 2],
|
|
209
|
+
HSET: [3, -1], HMSET: [3, -1], HSETNX: [3, 3], HGET: [2, 2], HMGET: [2, -1], HGETALL: [1, 1],
|
|
210
|
+
HKEYS: [1, 1], HVALS: [1, 1], HLEN: [1, 1], HEXISTS: [2, 2], HDEL: [2, -1],
|
|
211
|
+
HINCRBY: [3, 3], HINCRBYFLOAT: [3, 3],
|
|
212
|
+
SADD: [2, -1], SREM: [2, -1], SMEMBERS: [1, 1], SCARD: [1, 1], SISMEMBER: [2, 2],
|
|
213
|
+
SMISMEMBER: [2, -1], SPOP: [1, 2], SRANDMEMBER: [1, 2],
|
|
214
|
+
LPUSH: [2, -1], RPUSH: [2, -1], LPOP: [1, 2], RPOP: [1, 2], LLEN: [1, 1], LINDEX: [2, 2],
|
|
215
|
+
LSET: [3, 3], LRANGE: [3, 3], LTRIM: [3, 3], LREM: [3, 3],
|
|
216
|
+
ZADD: [3, -1], ZINCRBY: [3, 3], ZSCORE: [2, 2], ZCARD: [1, 1], ZCOUNT: [3, 3],
|
|
217
|
+
ZRANK: [2, 3], ZREVRANK: [2, 3], ZREM: [2, -1], ZREMRANGEBYSCORE: [3, 3], ZREMRANGEBYRANK: [3, 3],
|
|
218
|
+
ZRANGE: [3, -1], ZREVRANGE: [3, -1], ZRANGEBYSCORE: [3, -1], ZREVRANGEBYSCORE: [3, -1],
|
|
219
|
+
XADD: [4, -1], XLEN: [1, 1], XRANGE: [3, -1], XREVRANGE: [3, -1], XDEL: [2, -1],
|
|
220
|
+
SCRIPT: [1, -1], EVAL: [2, -1], EVAL_RO: [2, -1], EVALSHA: [2, -1], EVALSHA_RO: [2, -1],
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
/** Commands that mutate state — refused by a read-only twin and inside `EVAL_RO`/`EVALSHA_RO`. */
|
|
224
|
+
const WRITE_COMMANDS = new Set([
|
|
225
|
+
'SET', 'SETEX', 'PSETEX', 'SETNX', 'MSET', 'MSETNX', 'GETSET', 'GETDEL', 'GETEX', 'APPEND',
|
|
226
|
+
'DEL', 'UNLINK', 'INCR', 'DECR', 'INCRBY', 'DECRBY', 'INCRBYFLOAT',
|
|
227
|
+
'EXPIRE', 'PEXPIRE', 'EXPIREAT', 'PEXPIREAT', 'PERSIST', 'RENAME', 'RENAMENX',
|
|
228
|
+
'FLUSHDB', 'FLUSHALL', 'HSET', 'HSETNX', 'HMSET', 'HDEL', 'HINCRBY', 'HINCRBYFLOAT',
|
|
229
|
+
'SADD', 'SREM', 'SPOP', 'LPUSH', 'RPUSH', 'LPOP', 'RPOP', 'LREM', 'LSET', 'LTRIM',
|
|
230
|
+
'ZADD', 'ZINCRBY', 'ZREM', 'ZREMRANGEBYSCORE', 'ZREMRANGEBYRANK', 'XADD', 'XDEL',
|
|
231
|
+
// NB: 'SCRIPT' is deliberately ABSENT — it is classified per SUBCOMMAND below, because
|
|
232
|
+
// `SCRIPT EXISTS` is a pure read and a read-only twin must serve every read (§9 round 1 found
|
|
233
|
+
// it answering 405). Likewise `EVAL_RO`/`EVALSHA_RO` are reads by definition.
|
|
234
|
+
'EVAL', 'EVALSHA',
|
|
235
|
+
]);
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Does this command MUTATE? Takes the whole argv, not just the name, because two commands are
|
|
239
|
+
* only writes for some of their subcommands. A read-only twin and `EVAL_RO` both key off this,
|
|
240
|
+
* and getting it wrong in either direction is a bug: too broad refuses legitimate reads (§9 round
|
|
241
|
+
* 1), too narrow lets a write through a read-only twin.
|
|
242
|
+
*/
|
|
243
|
+
export function isWriteCommand(name: string, args?: readonly string[]): boolean {
|
|
244
|
+
const cmd = name.toUpperCase();
|
|
245
|
+
if (cmd === 'SCRIPT') {
|
|
246
|
+
// FAIL SAFE when the caller gave no subcommand. §9 round 2: with `args` defaulting to `[]`,
|
|
247
|
+
// `isWriteCommand('SCRIPT')` answered FALSE — so a consumer building their own read-only gate
|
|
248
|
+
// on this exported function would classify `SCRIPT LOAD` as a read. An unknown subcommand is
|
|
249
|
+
// treated as a write for the same reason: the cost of being wrong is asymmetric.
|
|
250
|
+
if (args === undefined || args.length === 0) return true;
|
|
251
|
+
const sub = String(args[0]).toUpperCase();
|
|
252
|
+
return sub !== 'EXISTS'; // EXISTS is the only read subcommand this twin serves
|
|
253
|
+
}
|
|
254
|
+
return WRITE_COMMANDS.has(cmd);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* QUEUE-TIME validation: is this command well-formed enough for Redis to accept it into a MULTI?
|
|
259
|
+
* Returns the vendor's error string, or `null` when the command is fine. Only structural faults
|
|
260
|
+
* live here (unknown command, REST-restricted command, wrong arity) — a WRONGTYPE or a bad integer
|
|
261
|
+
* is a RUNTIME error, which (LIVE-PROBED) does NOT abort a transaction.
|
|
262
|
+
*/
|
|
263
|
+
export function commandShapeError(argv: unknown[]): string | null {
|
|
264
|
+
if (argv.length === 0) return 'ERR empty command';
|
|
265
|
+
const raw = argv[0];
|
|
266
|
+
if (typeof raw !== 'string') return unavailableCommand(String(raw));
|
|
267
|
+
const name = raw.toUpperCase();
|
|
268
|
+
if (REST_RESTRICTED_COMMANDS.has(name)) return restRestricted(name);
|
|
269
|
+
const id = commandId(argv.map(String));
|
|
270
|
+
// the gap: a command of Redis's table this twin does not serve answers as Upstash answers an unknown one
|
|
271
|
+
if (id !== undefined && !SERVED_IDS.has(id)) return unavailableCommand(name);
|
|
272
|
+
if (id !== undefined && wrongTableArity(TABLE.get(id)!.arity, argv.length)) return wrongArity(name);
|
|
273
|
+
const arity = SERVED_COMMANDS[name];
|
|
274
|
+
if (!arity) return unavailableCommand(name);
|
|
275
|
+
const n = argv.length - 1;
|
|
276
|
+
if (n < arity[0] || (arity[1] !== -1 && n > arity[1])) return wrongArity(name);
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Redis's command table (spec/commands at redis 8.4.7), by id. */
|
|
281
|
+
const TABLE = new Map(surface.commands.map((c) => [c.id, c]));
|
|
282
|
+
|
|
283
|
+
/** The table id a command line names: a container's subcommand (`SCRIPT LOAD`) when the table has it. */
|
|
284
|
+
export function commandId(argv: string[]): string | undefined {
|
|
285
|
+
const name = argv[0]!.toUpperCase();
|
|
286
|
+
const sub = argv.length > 1 ? `${name} ${argv[1]!.toUpperCase()}` : undefined;
|
|
287
|
+
if (sub !== undefined && TABLE.has(sub)) return sub;
|
|
288
|
+
return TABLE.has(name) ? name : undefined;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Redis's arity rule: `arity` counts the name; a negative one is a minimum. */
|
|
292
|
+
const wrongTableArity = (arity: number, argc: number): boolean => (arity >= 0 ? argc !== arity : argc < -arity);
|
|
293
|
+
|
|
294
|
+
/** The table ids this twin serves: every served command, and the SCRIPT subcommands `execOne` answers. */
|
|
295
|
+
const SERVED_IDS = new Set([...Object.keys(SERVED_COMMANDS).filter((c) => TABLE.has(c)), 'SCRIPT LOAD', 'SCRIPT EXISTS', 'SCRIPT FLUSH']);
|
|
296
|
+
|
|
297
|
+
/** Per id of Redis's table, who answers it: the command core (`handler`) or the gap. */
|
|
298
|
+
export function upstashRedisOwners(): Record<string, 'handler' | 'gap'> {
|
|
299
|
+
return Object.fromEntries(surface.commands.map((c) => [c.id, SERVED_IDS.has(c.id) ? 'handler' : 'gap'] as const));
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
303
|
+
// THE KEYSPACE SNAPSHOT
|
|
304
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* A synchronous, in-memory image of the keyspace for the duration of ONE request.
|
|
308
|
+
*
|
|
309
|
+
* Seeded from `projectResources` (the kernel IS the source of truth); every mutation records the
|
|
310
|
+
* key name in `touched`, and `flushKeySpace` writes exactly those keys back. Nothing here outlives
|
|
311
|
+
* the request — there is no cache, no singleton, no state that a later request could inherit.
|
|
312
|
+
*/
|
|
313
|
+
class KeySpace {
|
|
314
|
+
private readonly rows = new Map<string, KeyRow>();
|
|
315
|
+
private readonly touched = new Map<string, string>(); // key → the operation label to record
|
|
316
|
+
private readonly scripts = new Map<string, string>(); // sha1 → script source (a SEPARATE namespace)
|
|
317
|
+
private readonly touchedScripts = new Map<string, string>();
|
|
318
|
+
/** name → the last write ordinal seen, INCLUDING for keys currently deleted or expired. */
|
|
319
|
+
private readonly revs = new Map<string, number>();
|
|
320
|
+
constructor(readonly ctx: RedisContext, readonly nowMs: number) {
|
|
321
|
+
const scriptPrefix = `${scopeOf(ctx)}script:`;
|
|
322
|
+
const keyPrefix = `${scopeOf(ctx)}key:`;
|
|
323
|
+
for (const r of projectResources(SERVICE, ctx.root)) {
|
|
324
|
+
if (r.type === 'script' && r.id.startsWith(scriptPrefix)) {
|
|
325
|
+
const rec = r as unknown as Record<string, unknown>;
|
|
326
|
+
if (rec.gone !== true && typeof rec.body === 'string') this.scripts.set(r.id.slice(scriptPrefix.length), rec.body);
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
if (r.type !== 'key' || !r.id.startsWith(keyPrefix)) continue;
|
|
330
|
+
const rec = r as unknown as Record<string, unknown>;
|
|
331
|
+
// Record the ordinal FIRST, for deleted and expired rows too: a key that is later recreated
|
|
332
|
+
// must CONTINUE the sequence rather than restart it, or the collision could simply recur.
|
|
333
|
+
if (typeof rec._rev === 'number') this.revs.set(r.id.slice(keyPrefix.length), rec._rev);
|
|
334
|
+
if (rec.gone === true) continue;
|
|
335
|
+
const pexpireAt = typeof rec.pexpire_at === 'number' ? rec.pexpire_at : null;
|
|
336
|
+
// Redis's `keyIsExpired` is `now > when`, so a key is still ALIVE at exactly its deadline
|
|
337
|
+
// (§9 round 1: the twin was one millisecond early). EXPIRE's own immediate-delete path below
|
|
338
|
+
// deliberately keeps `<=`, matching Redis's `when <= mstime()` there.
|
|
339
|
+
if (pexpireAt !== null && pexpireAt < nowMs) continue; // lazily expired — invisible to every read
|
|
340
|
+
const name = r.id.slice(keyPrefix.length);
|
|
341
|
+
this.rows.set(name, {
|
|
342
|
+
name,
|
|
343
|
+
kind: rec.kind as KeyType,
|
|
344
|
+
v: rec.v as KeyRow['v'],
|
|
345
|
+
pexpireAt,
|
|
346
|
+
...(typeof rec.last_id === 'string' && rec.last_id !== '' ? { lastId: rec.last_id } : {}),
|
|
347
|
+
...(typeof rec._rev === 'number' ? { _rev: rec._rev } : {}),
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Is this row expired AS OF NOW? Checked on every read, not merely when the snapshot was built.
|
|
353
|
+
*
|
|
354
|
+
* §9 round 2: a deadline set INTO THE PAST inside a batch (`SET k v PXAT 1`, `GETEX k EXAT 1`)
|
|
355
|
+
* stayed visible for the rest of that batch, and `TTL` answered a huge negative number that real
|
|
356
|
+
* Redis can never return. Filtering only at construction time made the twin disagree with itself
|
|
357
|
+
* within one request; re-checking here makes every read path agree at every instant.
|
|
358
|
+
*/
|
|
359
|
+
private live(row: KeyRow | undefined): KeyRow | undefined {
|
|
360
|
+
if (!row) return undefined;
|
|
361
|
+
return row.pexpireAt !== null && row.pexpireAt < this.nowMs ? undefined : row;
|
|
362
|
+
}
|
|
363
|
+
all(): KeyRow[] { return [...this.rows.values()].filter((r) => this.live(r) !== undefined); }
|
|
364
|
+
// ── the EVAL script cache ────────────────────────────────────────────────────────────────
|
|
365
|
+
// Kept in its OWN kernel subject type ('script'), NOT under a magic key prefix in the keyspace.
|
|
366
|
+
// An earlier draft stored it as a key named ` twin:script:<sha>` and filtered that prefix out of
|
|
367
|
+
// KEYS/SCAN/DBSIZE, which was a real collision bug: a caller who wrote a key with that exact name
|
|
368
|
+
// (a legal Redis key — a leading space is fine) had it silently vanish from every listing AND
|
|
369
|
+
// became able to run it as a script via EVALSHA. Separate subject types make the collision
|
|
370
|
+
// impossible rather than filtered, so no keyspace command needs to know scripts exist.
|
|
371
|
+
scriptFor(sha: string): string | undefined { return this.scripts.get(sha); }
|
|
372
|
+
hasScript(sha: string): boolean { return this.scripts.has(sha); }
|
|
373
|
+
putScript(sha: string, body: string): void {
|
|
374
|
+
if (this.ctx.readOnly) throw new ReadOnlyError();
|
|
375
|
+
this.scripts.set(sha, body);
|
|
376
|
+
this.touchedScripts.set(sha, 'script.load');
|
|
377
|
+
}
|
|
378
|
+
flushScripts(): void {
|
|
379
|
+
if (this.ctx.readOnly) throw new ReadOnlyError();
|
|
380
|
+
for (const sha of [...this.scripts.keys()]) { this.scripts.delete(sha); this.touchedScripts.set(sha, 'script.flush'); }
|
|
381
|
+
}
|
|
382
|
+
pendingScriptWrites(): Array<{ sha: string; operation: string; body: string | null }> {
|
|
383
|
+
return [...this.touchedScripts.entries()].map(([sha, operation]) => ({ sha, operation, body: this.scripts.get(sha) ?? null }));
|
|
384
|
+
}
|
|
385
|
+
get(name: string): KeyRow | undefined { return this.live(this.rows.get(name)); }
|
|
386
|
+
put(row: KeyRow, operation: string): void {
|
|
387
|
+
if (this.ctx.readOnly) throw new ReadOnlyError();
|
|
388
|
+
this.rows.set(row.name, row);
|
|
389
|
+
this.touched.set(row.name, operation);
|
|
390
|
+
}
|
|
391
|
+
remove(name: string, operation: string): void {
|
|
392
|
+
if (this.ctx.readOnly) throw new ReadOnlyError();
|
|
393
|
+
this.rows.delete(name);
|
|
394
|
+
this.touched.set(name, operation);
|
|
395
|
+
}
|
|
396
|
+
/** A point-in-time copy, so a failed script can be rolled back to exactly where it started. */
|
|
397
|
+
snapshot(): Snapshot {
|
|
398
|
+
return { rows: new Map(this.rows), touched: new Map(this.touched), scripts: new Map(this.scripts), touchedScripts: new Map(this.touchedScripts) };
|
|
399
|
+
}
|
|
400
|
+
restore(s: Snapshot): void {
|
|
401
|
+
this.rows.clear();
|
|
402
|
+
for (const [k, v] of s.rows) this.rows.set(k, v);
|
|
403
|
+
this.touched.clear();
|
|
404
|
+
for (const [k, v] of s.touched) this.touched.set(k, v);
|
|
405
|
+
this.scripts.clear();
|
|
406
|
+
for (const [k, v] of s.scripts) this.scripts.set(k, v);
|
|
407
|
+
this.touchedScripts.clear();
|
|
408
|
+
for (const [k, v] of s.touchedScripts) this.touchedScripts.set(k, v);
|
|
409
|
+
}
|
|
410
|
+
pendingWrites(): Array<{ name: string; operation: string; row: KeyRow | null }> {
|
|
411
|
+
return [...this.touched.entries()].map(([name, operation]) => ({ name, operation, row: this.rows.get(name) ?? null }));
|
|
412
|
+
}
|
|
413
|
+
/** The next write ordinal for a key — see `KeyRow._rev`. Survives deletes and expiry. */
|
|
414
|
+
nextRev(name: string): number {
|
|
415
|
+
const next = (this.revs.get(name) ?? 0) + 1;
|
|
416
|
+
this.revs.set(name, next);
|
|
417
|
+
return next;
|
|
418
|
+
}
|
|
419
|
+
/** Did this run write anything? Used to decide whether the sync token advances. */
|
|
420
|
+
get dirty(): boolean { return this.touched.size > 0 || this.touchedScripts.size > 0; }
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** One key as a backup holds it: its name, type, value, deadline and a stream's last id. */
|
|
424
|
+
export type KeyImage = { name: string; kind: string; v: unknown; pexpireAt: number | null; lastId?: string };
|
|
425
|
+
|
|
426
|
+
/** A database's live keys as they stand at `ctx.occurredAt`: what a backup of it holds (the Developer API lane's
|
|
427
|
+
* backups, ../api/src/semantics/backups.ts). */
|
|
428
|
+
export function keyspaceImage(ctx: RedisContext): KeyImage[] {
|
|
429
|
+
return new KeySpace(ctx, Date.parse(ctx.occurredAt)).all().map((r) => ({ name: r.name, kind: r.kind, v: r.v, pexpireAt: r.pexpireAt, ...(r.lastId ? { lastId: r.lastId } : {}) }));
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** Replace a database's keys with a backup's: every live key is deleted, then the image's keys are written ("All
|
|
433
|
+
* existing data in the target database will be deleted before the restore operation begins",
|
|
434
|
+
* https://upstash.com/docs/redis/features/backup). */
|
|
435
|
+
export async function restoreKeyspace(image: readonly KeyImage[], ctx: RedisContext): Promise<void> {
|
|
436
|
+
const space = new KeySpace(ctx, Date.parse(ctx.occurredAt));
|
|
437
|
+
for (const r of space.all()) space.remove(r.name, 'key.restore');
|
|
438
|
+
for (const k of image) space.put({ name: k.name, kind: k.kind as KeyType, v: k.v as KeyRow['v'], pexpireAt: k.pexpireAt, ...(k.lastId ? { lastId: k.lastId } : {}) }, 'key.restore');
|
|
439
|
+
await flushKeySpace(space);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
async function flushKeySpace(space: KeySpace): Promise<void> {
|
|
443
|
+
for (const { name, operation, row } of space.pendingWrites()) {
|
|
444
|
+
await applyTwinWrite(
|
|
445
|
+
SERVICE,
|
|
446
|
+
{
|
|
447
|
+
operation,
|
|
448
|
+
subjectType: 'key',
|
|
449
|
+
subjectId: `${scopeOf(space.ctx)}key:${name}`,
|
|
450
|
+
// The kernel MERGES fields, so a delete must write EVERY field back to its "nothing here"
|
|
451
|
+
// value — leaving `v`/`pexpire_at` behind would let a later recreate inherit a dead value
|
|
452
|
+
// or a dead TTL.
|
|
453
|
+
fields: row === null
|
|
454
|
+
? { name, kind: 'string', v: '', pexpire_at: null, last_id: '', gone: true, _rev: space.nextRev(name) }
|
|
455
|
+
: { name, kind: row.kind, v: row.v as never, pexpire_at: row.pexpireAt, last_id: row.lastId ?? '', gone: false, _rev: space.nextRev(name) },
|
|
456
|
+
occurredAt: space.ctx.occurredAt,
|
|
457
|
+
actor: { kind: 'agent' },
|
|
458
|
+
},
|
|
459
|
+
space.ctx.root,
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
for (const { sha, operation, body } of space.pendingScriptWrites()) {
|
|
463
|
+
await applyTwinWrite(
|
|
464
|
+
SERVICE,
|
|
465
|
+
{
|
|
466
|
+
operation,
|
|
467
|
+
subjectType: 'script',
|
|
468
|
+
subjectId: `${scopeOf(space.ctx)}script:${sha}`,
|
|
469
|
+
fields: body === null ? { sha, body: '', gone: true } : { sha, body, gone: false },
|
|
470
|
+
occurredAt: space.ctx.occurredAt,
|
|
471
|
+
actor: { kind: 'agent' },
|
|
472
|
+
},
|
|
473
|
+
space.ctx.root,
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// ── typed value accessors (each asserts the key's type first) ─────────────────────────────────
|
|
479
|
+
function expectType(row: KeyRow | undefined, kind: KeyType): void {
|
|
480
|
+
if (row && row.kind !== kind) throw new RedisCommandError(WRONGTYPE);
|
|
481
|
+
}
|
|
482
|
+
const asString = (row: KeyRow | undefined): string | undefined => { expectType(row, 'string'); expectShape(row, typeof row?.v === 'string'); return row ? (row.v as string) : undefined; };
|
|
483
|
+
/**
|
|
484
|
+
* Every accessor below asserts the STORED SHAPE, not just the declared type.
|
|
485
|
+
*
|
|
486
|
+
* §9 round 2: a connector-pulled stream was stored with `kind:'stream'` but a JSON STRING body,
|
|
487
|
+
* so `asStream`'s `.map` threw a raw `TypeError` that escaped `handleUpstashRedisTwinRequest`
|
|
488
|
+
* entirely (it only catches `RedisCommandError`/`ReadOnlyError`) and surfaced as an unhandled
|
|
489
|
+
* rejection instead of any HTTP response. A twin must fail like the vendor even when its own
|
|
490
|
+
* stored state is wrong.
|
|
491
|
+
*/
|
|
492
|
+
function expectShape(row: KeyRow | undefined, ok: boolean): void {
|
|
493
|
+
if (row && !ok) throw new RedisCommandError(`ERR twin: key '${row.name}' is stored as ${row.kind} but its value is malformed — the state it was written from is not usable`);
|
|
494
|
+
}
|
|
495
|
+
const asList = (row: KeyRow | undefined): string[] => { expectType(row, 'list'); expectShape(row, Array.isArray(row?.v)); return row ? [...(row.v as string[])] : []; };
|
|
496
|
+
const asSet = (row: KeyRow | undefined): string[] => { expectType(row, 'set'); expectShape(row, Array.isArray(row?.v)); return row ? [...(row.v as string[])] : []; };
|
|
497
|
+
const asHash = (row: KeyRow | undefined): HashPairs => { expectType(row, 'hash'); expectShape(row, Array.isArray(row?.v)); return row ? (row.v as HashPairs).map(([f, v]) => [f, v] as [string, string]) : []; };
|
|
498
|
+
const asZSet = (row: KeyRow | undefined): ZSetPairs => {
|
|
499
|
+
expectType(row, 'zset');
|
|
500
|
+
expectShape(row, Array.isArray(row?.v));
|
|
501
|
+
// Tolerates a raw number too, so a connector-pulled or hand-written row still loads.
|
|
502
|
+
return row ? (row.v as StoredZSet).map(([m, sc]) => [m, typeof sc === 'number' ? sc : toFloat(sc)] as [string, number]) : [];
|
|
503
|
+
};
|
|
504
|
+
/** Serialize for the kernel. The inverse of `asZSet` — see `StoredZSet`. */
|
|
505
|
+
const storeZSet = (pairs: ZSetPairs): StoredZSet => pairs.map(([m, sc]) => [m, fmtScore(sc)] as [string, string]);
|
|
506
|
+
const asStream = (row: KeyRow | undefined): StreamEntries => { expectType(row, 'stream'); expectShape(row, Array.isArray(row?.v)); return row ? (row.v as StreamEntries).map(([id, f]) => [id, f.map(([x, y]) => [x, y] as [string, string])] as [string, HashPairs]) : []; };
|
|
507
|
+
|
|
508
|
+
// ── numeric parsing (Redis's exact acceptance rules) ──────────────────────────────────────────
|
|
509
|
+
function toInt(raw: string): number {
|
|
510
|
+
if (raw !== raw.trim() || !/^[+-]?\d+$/.test(raw)) throw new RedisCommandError(NOT_INT);
|
|
511
|
+
const n = Number(raw);
|
|
512
|
+
if (!Number.isSafeInteger(n)) throw new RedisCommandError(NOT_INT);
|
|
513
|
+
return n;
|
|
514
|
+
}
|
|
515
|
+
function toFloat(raw: string): number {
|
|
516
|
+
if (raw !== raw.trim() || raw === '') throw new RedisCommandError(NOT_FLOAT);
|
|
517
|
+
if (/^[+-]?inf(inity)?$/i.test(raw)) return raw.startsWith('-') ? Number.NEGATIVE_INFINITY : Number.POSITIVE_INFINITY;
|
|
518
|
+
const n = Number(raw);
|
|
519
|
+
if (!Number.isFinite(n)) throw new RedisCommandError(NOT_FLOAT);
|
|
520
|
+
return n;
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* Redis's float-increment guard. `t_string.c` and `t_hash.c` both do
|
|
524
|
+
* `if (isnan(value) || isinf(value)) addReplyError(c,"increment would produce NaN or Infinity")`.
|
|
525
|
+
*
|
|
526
|
+
* §9 round 2 found this missing on the string and hash paths (it had only been added to the sorted
|
|
527
|
+
* set): `INCRBYFLOAT f inf` answered 200 with "inf", `INCRBYFLOAT f -inf` then persisted the
|
|
528
|
+
* literal "NaN", and the very next `INCRBYFLOAT f 1` answered "not a valid float" — the twin had
|
|
529
|
+
* written a value it could no longer read. A fake success that poisons its own key.
|
|
530
|
+
*/
|
|
531
|
+
function guardFloatResult(next: number): number {
|
|
532
|
+
if (Number.isNaN(next) || !Number.isFinite(next)) throw new RedisCommandError('ERR increment would produce NaN or Infinity');
|
|
533
|
+
return next;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/** Redis renders a score as a bulk string; infinities spell out. */
|
|
537
|
+
function fmtScore(n: number): string {
|
|
538
|
+
if (n === Number.POSITIVE_INFINITY) return 'inf';
|
|
539
|
+
if (n === Number.NEGATIVE_INFINITY) return '-inf';
|
|
540
|
+
return String(n);
|
|
541
|
+
}
|
|
542
|
+
/** Redis sorts a zset by (score, then member lexicographically). */
|
|
543
|
+
function sortZSet(pairs: ZSetPairs): ZSetPairs {
|
|
544
|
+
return [...pairs].sort((a, b) => (a[1] - b[1]) || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
|
545
|
+
}
|
|
546
|
+
/** Redis's start/stop index normalisation (negatives count from the end, ends clamp). */
|
|
547
|
+
function normalizeRange(startRaw: number, stopRaw: number, length: number): [number, number] {
|
|
548
|
+
let start = startRaw < 0 ? length + startRaw : startRaw;
|
|
549
|
+
let stop = stopRaw < 0 ? length + stopRaw : stopRaw;
|
|
550
|
+
if (start < 0) start = 0;
|
|
551
|
+
if (stop >= length) stop = length - 1;
|
|
552
|
+
return [start, stop];
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** Redis glob-style key matching (`*`, `?`, `[abc]`, `[a-c]`, `[^a]`, `\` escape). */
|
|
556
|
+
export function globMatch(pattern: string, subject: string): boolean {
|
|
557
|
+
let re = '';
|
|
558
|
+
const esc = (c: string) => c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
559
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
560
|
+
const c = pattern[i]!;
|
|
561
|
+
if (c === '\\' && i + 1 < pattern.length) { re += esc(pattern[++i]!); continue; }
|
|
562
|
+
if (c === '*') { re += '[\\s\\S]*'; continue; }
|
|
563
|
+
if (c === '?') { re += '[\\s\\S]'; continue; }
|
|
564
|
+
if (c === '[') {
|
|
565
|
+
const close = pattern.indexOf(']', i + 1);
|
|
566
|
+
if (close < 0) { re += '\\['; continue; }
|
|
567
|
+
let cls = pattern.slice(i + 1, close);
|
|
568
|
+
const negate = cls.startsWith('^');
|
|
569
|
+
if (negate) cls = cls.slice(1);
|
|
570
|
+
re += `[${negate ? '^' : ''}${cls.replace(/\\/g, '\\\\')}]`;
|
|
571
|
+
i = close;
|
|
572
|
+
continue;
|
|
573
|
+
}
|
|
574
|
+
re += esc(c);
|
|
575
|
+
}
|
|
576
|
+
return new RegExp(`^${re}$`).test(subject);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// ── TTL option parsing, shared by SET and GETEX ────────────────────────────────────────────────
|
|
580
|
+
function ttlFromToken(token: string, value: string, nowMs: number, cmd: string): number {
|
|
581
|
+
const n = toInt(value);
|
|
582
|
+
if (token === 'ex') { if (n <= 0) throw new RedisCommandError(`ERR invalid expire time in '${cmd}' command`); return nowMs + n * 1000; }
|
|
583
|
+
if (token === 'px') { if (n <= 0) throw new RedisCommandError(`ERR invalid expire time in '${cmd}' command`); return nowMs + n; }
|
|
584
|
+
if (token === 'exat') return n * 1000;
|
|
585
|
+
return n; // pxat
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
589
|
+
// COMMAND DISPATCH — fully synchronous over the snapshot (see the file header for why).
|
|
590
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
591
|
+
|
|
592
|
+
function execOne(space: KeySpace, argv: string[]): RedisValue {
|
|
593
|
+
const shapeError = commandShapeError(argv);
|
|
594
|
+
if (shapeError !== null) throw new RedisCommandError(shapeError);
|
|
595
|
+
const name = argv[0]!.toUpperCase();
|
|
596
|
+
const a = argv.slice(1);
|
|
597
|
+
const nowMs = space.nowMs;
|
|
598
|
+
if (space.ctx.readOnly && isWriteCommand(name, a)) throw new ReadOnlyError();
|
|
599
|
+
|
|
600
|
+
switch (name) {
|
|
601
|
+
// ── connection / server ──────────────────────────────────────────────────────────────────
|
|
602
|
+
case 'PING': return a.length === 0 ? new RedisStatus('PONG') : a[0]!;
|
|
603
|
+
case 'ECHO': return a[0]!;
|
|
604
|
+
case 'DBSIZE': return visibleKeys(space).length;
|
|
605
|
+
case 'TYPE': { const r = space.get(a[0]!); return new RedisStatus(r ? r.kind : 'none'); }
|
|
606
|
+
case 'SELECT': {
|
|
607
|
+
const db = toInt(a[0]!);
|
|
608
|
+
// LIVE-PROBED: Upstash serves exactly one logical database and says so by number.
|
|
609
|
+
if (db !== 0) throw new RedisCommandError(`ERR Only 0th database is supported! Selected DB: ${db}`);
|
|
610
|
+
return OK;
|
|
611
|
+
}
|
|
612
|
+
case 'FLUSHDB': case 'FLUSHALL': {
|
|
613
|
+
for (const r of space.all()) space.remove(r.name, 'key.flush');
|
|
614
|
+
return OK;
|
|
615
|
+
}
|
|
616
|
+
case 'KEYS': return visibleKeys(space).filter((k) => globMatch(a[0]!, k)).sort();
|
|
617
|
+
case 'RANDOMKEY': {
|
|
618
|
+
// DETERMINISTIC by design: a twin that returned a genuinely random key could not be verified
|
|
619
|
+
// offline. Disclosed in the manifest (`upstash.keyspace.randomkey_deterministic`).
|
|
620
|
+
const keys = visibleKeys(space).sort();
|
|
621
|
+
return keys.length === 0 ? null : keys[0]!;
|
|
622
|
+
}
|
|
623
|
+
case 'SCAN': {
|
|
624
|
+
const cursor = toInt(a[0]!);
|
|
625
|
+
if (cursor < 0) throw new RedisCommandError('ERR invalid cursor');
|
|
626
|
+
let match: string | null = null;
|
|
627
|
+
let count = 10;
|
|
628
|
+
let typeFilter: string | null = null;
|
|
629
|
+
for (let i = 1; i < a.length; i++) {
|
|
630
|
+
const tok = a[i]!.toLowerCase();
|
|
631
|
+
if (tok === 'match') { if (i + 1 >= a.length) throw new RedisCommandError(SYNTAX); match = a[++i]!; continue; }
|
|
632
|
+
if (tok === 'count') { if (i + 1 >= a.length) throw new RedisCommandError(SYNTAX); count = toInt(a[++i]!); if (count < 1) throw new RedisCommandError(SYNTAX); continue; }
|
|
633
|
+
if (tok === 'type') { if (i + 1 >= a.length) throw new RedisCommandError(SYNTAX); typeFilter = a[++i]!.toLowerCase(); continue; }
|
|
634
|
+
throw new RedisCommandError(SYNTAX);
|
|
635
|
+
}
|
|
636
|
+
// THE CURSOR CONTRACT. Redis's only guarantee is: 0 starts, a returned 0 ends, and a key
|
|
637
|
+
// present for the WHOLE iteration is returned at least once (keys added or removed mid-scan
|
|
638
|
+
// may or may not appear). §9 round 1 refuted the first design, an INDEX into a sorted list:
|
|
639
|
+
// deleting a lower-sorted key shifts the list left under the cursor and a key that was
|
|
640
|
+
// present throughout is skipped entirely —
|
|
641
|
+
// MSET a1..a5 ; SCAN 0 COUNT 2 → ["2",[a1,a2]] ; DEL a1 ; SCAN 2 COUNT 2 → ["4",[a4,a5]]
|
|
642
|
+
// and a3 is never returned. That breaks the entire contract.
|
|
643
|
+
//
|
|
644
|
+
// So the cursor is now a POSITION IN A STABLE KEY-DERIVED ORDER, not a position in a list:
|
|
645
|
+
// each key gets a fixed 32-bit value from its own name, and the cursor is the next such value
|
|
646
|
+
// to resume from. A key's position therefore never moves when a DIFFERENT key is added or
|
|
647
|
+
// removed, which is precisely what makes the guarantee hold. This mirrors what real Redis
|
|
648
|
+
// does (its cursor is a reverse-binary index into hash buckets, and a key's bucket is derived
|
|
649
|
+
// from the key too) while staying reproducible, which the real one is not.
|
|
650
|
+
const all = visibleKeys(space)
|
|
651
|
+
.filter((k) => typeFilter === null || space.get(k)!.kind === typeFilter)
|
|
652
|
+
.map((k) => ({ k, c: scanCursorFor(k) }))
|
|
653
|
+
.sort((x, y) => (x.c - y.c) || (x.k < y.k ? -1 : x.k > y.k ? 1 : 0));
|
|
654
|
+
const from = all.filter((e) => e.c >= cursor);
|
|
655
|
+
let take = from.slice(0, Math.max(1, count));
|
|
656
|
+
// Never split a group of keys sharing a cursor value across pages: the resume point is a
|
|
657
|
+
// cursor VALUE, so a key left behind in a half-emitted group could never be reached again.
|
|
658
|
+
if (take.length > 0 && take.length < from.length) {
|
|
659
|
+
const lastC = take[take.length - 1]!.c;
|
|
660
|
+
take = from.filter((e) => e.c <= lastC);
|
|
661
|
+
}
|
|
662
|
+
const remaining = from.length - take.length;
|
|
663
|
+
const next = remaining === 0 ? 0 : take[take.length - 1]!.c + 1;
|
|
664
|
+
return [String(next), take.map((e) => e.k).filter((k) => match === null || globMatch(match, k))];
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// ── generic keyspace ─────────────────────────────────────────────────────────────────────
|
|
668
|
+
case 'EXISTS': case 'TOUCH': { let n = 0; for (const k of a) if (space.get(k)) n++; return n; }
|
|
669
|
+
case 'DEL': case 'UNLINK': { let n = 0; for (const k of a) if (space.get(k)) { space.remove(k, 'key.del'); n++; } return n; }
|
|
670
|
+
case 'EXPIRE': case 'PEXPIRE': case 'EXPIREAT': case 'PEXPIREAT': {
|
|
671
|
+
const row = space.get(a[0]!);
|
|
672
|
+
const n = toInt(a[1]!);
|
|
673
|
+
// Redis 7.0's options (redis.io/docs/latest/commands/expire/, "Options"): NX sets only a key with no expiry, XX
|
|
674
|
+
// only one with an expiry, GT only a later deadline, LT only an earlier one, a key with no expiry counting as
|
|
675
|
+
// one that never expires; NX with any other, and GT with LT, are refused
|
|
676
|
+
const opts = new Set(a.slice(2).map((o) => { const u = o.toUpperCase(); if (!['NX', 'XX', 'GT', 'LT'].includes(u)) throw new RedisCommandError(`ERR Unsupported option ${o}`); return u; }));
|
|
677
|
+
if (opts.has('NX') && (opts.has('XX') || opts.has('GT') || opts.has('LT'))) throw new RedisCommandError('ERR NX and XX, GT or LT options at the same time are not compatible');
|
|
678
|
+
if (opts.has('GT') && opts.has('LT')) throw new RedisCommandError('ERR GT and LT options at the same time are not compatible');
|
|
679
|
+
if (!row) return 0;
|
|
680
|
+
const at = name === 'EXPIRE' ? nowMs + n * 1000
|
|
681
|
+
: name === 'PEXPIRE' ? nowMs + n
|
|
682
|
+
: name === 'EXPIREAT' ? n * 1000
|
|
683
|
+
: n;
|
|
684
|
+
if (opts.has('NX') && row.pexpireAt !== null) return 0;
|
|
685
|
+
if (opts.has('XX') && row.pexpireAt === null) return 0;
|
|
686
|
+
if (opts.has('GT') && (row.pexpireAt === null || at <= row.pexpireAt)) return 0;
|
|
687
|
+
if (opts.has('LT') && row.pexpireAt !== null && at >= row.pexpireAt) return 0;
|
|
688
|
+
// A deadline already in the past DELETES the key, exactly as Redis does.
|
|
689
|
+
if (at <= nowMs) { space.remove(a[0]!, 'key.expire_now'); return 1; }
|
|
690
|
+
space.put({ ...row, pexpireAt: at }, 'key.expire');
|
|
691
|
+
return 1;
|
|
692
|
+
}
|
|
693
|
+
case 'TTL': case 'PTTL': {
|
|
694
|
+
const row = space.get(a[0]!);
|
|
695
|
+
if (!row) return -2;
|
|
696
|
+
if (row.pexpireAt === null) return -1;
|
|
697
|
+
const remainingMs = row.pexpireAt - nowMs;
|
|
698
|
+
// Redis computes `(ttl+500)/1000` in integer arithmetic — round HALF-UP, not ceil. With ceil,
|
|
699
|
+
// a key set `EX 10` and read 600ms later still reported 10 where the vendor says 9 (§9 round
|
|
700
|
+
// 1); every earlier verify happened to land on an exact second boundary, where they agree.
|
|
701
|
+
return name === 'TTL' ? Math.round(remainingMs / 1000) : remainingMs;
|
|
702
|
+
}
|
|
703
|
+
case 'PERSIST': {
|
|
704
|
+
const row = space.get(a[0]!);
|
|
705
|
+
if (!row || row.pexpireAt === null) return 0;
|
|
706
|
+
space.put({ ...row, pexpireAt: null }, 'key.persist');
|
|
707
|
+
return 1;
|
|
708
|
+
}
|
|
709
|
+
case 'RENAME': case 'RENAMENX': {
|
|
710
|
+
const row = space.get(a[0]!);
|
|
711
|
+
if (!row) throw new RedisCommandError(NO_SUCH_KEY);
|
|
712
|
+
if (a[0] === a[1]) return name === 'RENAME' ? OK : 0;
|
|
713
|
+
if (name === 'RENAMENX' && space.get(a[1]!)) return 0;
|
|
714
|
+
space.put({ ...row, name: a[1]! }, 'key.rename');
|
|
715
|
+
space.remove(a[0]!, 'key.rename_src');
|
|
716
|
+
return name === 'RENAME' ? OK : 1;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// ── strings ──────────────────────────────────────────────────────────────────────────────
|
|
720
|
+
case 'GET': { const v = asString(space.get(a[0]!)); return v === undefined ? null : v; }
|
|
721
|
+
case 'GETDEL': {
|
|
722
|
+
const row = space.get(a[0]!);
|
|
723
|
+
const v = asString(row);
|
|
724
|
+
if (row) space.remove(a[0]!, 'string.getdel');
|
|
725
|
+
return v === undefined ? null : v;
|
|
726
|
+
}
|
|
727
|
+
case 'GETSET': {
|
|
728
|
+
const old = asString(space.get(a[0]!));
|
|
729
|
+
space.put({ name: a[0]!, kind: 'string', v: a[1]!, pexpireAt: null }, 'string.getset');
|
|
730
|
+
return old === undefined ? null : old;
|
|
731
|
+
}
|
|
732
|
+
case 'GETEX': {
|
|
733
|
+
const row = space.get(a[0]!);
|
|
734
|
+
const v = asString(row);
|
|
735
|
+
if (!row) return null;
|
|
736
|
+
if (a.length === 1) return v ?? null;
|
|
737
|
+
const tok = a[1]!.toLowerCase();
|
|
738
|
+
if (tok === 'persist') { space.put({ ...row, pexpireAt: null }, 'string.getex'); return v ?? null; }
|
|
739
|
+
if (!['ex', 'px', 'exat', 'pxat'].includes(tok) || a.length < 3) throw new RedisCommandError(SYNTAX);
|
|
740
|
+
space.put({ ...row, pexpireAt: ttlFromToken(tok, a[2]!, nowMs, 'getex') }, 'string.getex');
|
|
741
|
+
return v ?? null;
|
|
742
|
+
}
|
|
743
|
+
case 'SET': {
|
|
744
|
+
const [key, value] = [a[0]!, a[1]!];
|
|
745
|
+
let nx = false;
|
|
746
|
+
let xx = false;
|
|
747
|
+
let get = false;
|
|
748
|
+
let keepTtl = false;
|
|
749
|
+
let pexpireAt: number | null = null;
|
|
750
|
+
for (let i = 2; i < a.length; i++) {
|
|
751
|
+
// Case-INSENSITIVE by necessity: the official SDK emits the TTL/flag tokens lowercase and
|
|
752
|
+
// pushes `keepTtl` in literal camelCase (`pkg/commands/set.ts`), which the real server
|
|
753
|
+
// accepts. A case-sensitive parser here would reject the vendor's own client.
|
|
754
|
+
const tok = a[i]!.toLowerCase();
|
|
755
|
+
if (tok === 'nx') { nx = true; continue; }
|
|
756
|
+
if (tok === 'xx') { xx = true; continue; }
|
|
757
|
+
if (tok === 'get') { get = true; continue; }
|
|
758
|
+
if (tok === 'keepttl') { keepTtl = true; continue; }
|
|
759
|
+
if (['ex', 'px', 'exat', 'pxat'].includes(tok)) {
|
|
760
|
+
if (i + 1 >= a.length) throw new RedisCommandError(SYNTAX);
|
|
761
|
+
pexpireAt = ttlFromToken(tok, a[++i]!, nowMs, 'set');
|
|
762
|
+
continue;
|
|
763
|
+
}
|
|
764
|
+
throw new RedisCommandError(SYNTAX);
|
|
765
|
+
}
|
|
766
|
+
if (nx && xx) throw new RedisCommandError(SYNTAX);
|
|
767
|
+
const existing = space.get(key);
|
|
768
|
+
if (get && existing && existing.kind !== 'string') throw new RedisCommandError(WRONGTYPE);
|
|
769
|
+
const old = existing?.kind === 'string' ? (existing.v as string) : undefined;
|
|
770
|
+
// THE `null` RETURN THAT MATTERS: a failed NX/XX answers nil, NOT "OK". dub branches on
|
|
771
|
+
// `res === null` in a dozen lock/dedupe paths (lib/upstash/redis-lock.ts, track-lead.ts,
|
|
772
|
+
// track-sale.ts, …), so getting this wrong turns every distributed lock into a no-op.
|
|
773
|
+
if (nx && existing) return get ? (old ?? null) : null;
|
|
774
|
+
if (xx && !existing) return get ? (old ?? null) : null;
|
|
775
|
+
space.put({ name: key, kind: 'string', v: value, pexpireAt: keepTtl ? (existing?.pexpireAt ?? null) : pexpireAt }, 'string.set');
|
|
776
|
+
return get ? (old ?? null) : OK;
|
|
777
|
+
}
|
|
778
|
+
case 'SETNX': {
|
|
779
|
+
if (space.get(a[0]!)) return 0;
|
|
780
|
+
space.put({ name: a[0]!, kind: 'string', v: a[1]!, pexpireAt: null }, 'string.setnx');
|
|
781
|
+
return 1;
|
|
782
|
+
}
|
|
783
|
+
case 'SETEX': case 'PSETEX': {
|
|
784
|
+
const n = toInt(a[1]!);
|
|
785
|
+
if (n <= 0) throw new RedisCommandError(`ERR invalid expire time in '${name.toLowerCase()}' command`);
|
|
786
|
+
space.put({ name: a[0]!, kind: 'string', v: a[2]!, pexpireAt: nowMs + (name === 'SETEX' ? n * 1000 : n) }, 'string.setex');
|
|
787
|
+
return OK;
|
|
788
|
+
}
|
|
789
|
+
case 'MGET': return a.map((k) => { const r = space.get(k); return r && r.kind === 'string' ? (r.v as string) : null; });
|
|
790
|
+
case 'MSET': case 'MSETNX': {
|
|
791
|
+
if (a.length % 2 !== 0) throw new RedisCommandError(wrongArity(name));
|
|
792
|
+
if (name === 'MSETNX') { for (let i = 0; i < a.length; i += 2) if (space.get(a[i]!)) return 0; }
|
|
793
|
+
for (let i = 0; i < a.length; i += 2) space.put({ name: a[i]!, kind: 'string', v: a[i + 1]!, pexpireAt: null }, 'string.mset');
|
|
794
|
+
return name === 'MSET' ? OK : 1;
|
|
795
|
+
}
|
|
796
|
+
case 'APPEND': {
|
|
797
|
+
const row = space.get(a[0]!);
|
|
798
|
+
const next = (asString(row) ?? '') + a[1]!;
|
|
799
|
+
space.put({ name: a[0]!, kind: 'string', v: next, pexpireAt: row?.pexpireAt ?? null }, 'string.append');
|
|
800
|
+
return next.length;
|
|
801
|
+
}
|
|
802
|
+
case 'STRLEN': return (asString(space.get(a[0]!)) ?? '').length;
|
|
803
|
+
case 'INCR': case 'DECR': case 'INCRBY': case 'DECRBY': {
|
|
804
|
+
const by = name === 'INCR' ? 1 : name === 'DECR' ? -1 : toInt(a[1]!) * (name === 'DECRBY' ? -1 : 1);
|
|
805
|
+
const row = space.get(a[0]!);
|
|
806
|
+
const current = asString(row);
|
|
807
|
+
const next = (current === undefined ? 0 : toInt(current)) + by;
|
|
808
|
+
if (!Number.isSafeInteger(next)) throw new RedisCommandError('ERR increment or decrement would overflow');
|
|
809
|
+
// INCR PRESERVES the key's TTL (real Redis semantics). This is exactly what makes
|
|
810
|
+
// @upstash/ratelimit's fixed-window script correct: the PEXPIRE is armed on the first
|
|
811
|
+
// increment and every later INCRBY must leave it alone, or the window would never close.
|
|
812
|
+
space.put({ name: a[0]!, kind: 'string', v: String(next), pexpireAt: row?.pexpireAt ?? null }, 'string.incr');
|
|
813
|
+
return next;
|
|
814
|
+
}
|
|
815
|
+
case 'INCRBYFLOAT': {
|
|
816
|
+
const row = space.get(a[0]!);
|
|
817
|
+
const current = asString(row);
|
|
818
|
+
const text = fmtScore(guardFloatResult((current === undefined ? 0 : toFloat(current)) + toFloat(a[1]!)));
|
|
819
|
+
space.put({ name: a[0]!, kind: 'string', v: text, pexpireAt: row?.pexpireAt ?? null }, 'string.incrbyfloat');
|
|
820
|
+
return text;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// ── hashes ───────────────────────────────────────────────────────────────────────────────
|
|
824
|
+
case 'HSET': case 'HMSET': {
|
|
825
|
+
if ((a.length - 1) % 2 !== 0) throw new RedisCommandError(wrongArity(name));
|
|
826
|
+
const row = space.get(a[0]!);
|
|
827
|
+
const pairs = asHash(row);
|
|
828
|
+
let added = 0;
|
|
829
|
+
for (let i = 1; i < a.length; i += 2) {
|
|
830
|
+
const idx = pairs.findIndex(([f]) => f === a[i]);
|
|
831
|
+
if (idx < 0) { pairs.push([a[i]!, a[i + 1]!]); added++; } else pairs[idx] = [a[i]!, a[i + 1]!];
|
|
832
|
+
}
|
|
833
|
+
space.put({ name: a[0]!, kind: 'hash', v: pairs, pexpireAt: row?.pexpireAt ?? null }, 'hash.set');
|
|
834
|
+
return name === 'HMSET' ? OK : added;
|
|
835
|
+
}
|
|
836
|
+
case 'HSETNX': {
|
|
837
|
+
const row = space.get(a[0]!);
|
|
838
|
+
const pairs = asHash(row);
|
|
839
|
+
if (pairs.some(([f]) => f === a[1])) return 0;
|
|
840
|
+
pairs.push([a[1]!, a[2]!]);
|
|
841
|
+
space.put({ name: a[0]!, kind: 'hash', v: pairs, pexpireAt: row?.pexpireAt ?? null }, 'hash.setnx');
|
|
842
|
+
return 1;
|
|
843
|
+
}
|
|
844
|
+
case 'HGET': { const p = asHash(space.get(a[0]!)).find(([f]) => f === a[1]); return p ? p[1] : null; }
|
|
845
|
+
case 'HMGET': { const pairs = asHash(space.get(a[0]!)); return a.slice(1).map((f) => pairs.find(([k]) => k === f)?.[1] ?? null); }
|
|
846
|
+
case 'HGETALL': return asHash(space.get(a[0]!)).flat();
|
|
847
|
+
case 'HKEYS': return asHash(space.get(a[0]!)).map(([f]) => f);
|
|
848
|
+
case 'HVALS': return asHash(space.get(a[0]!)).map(([, v]) => v);
|
|
849
|
+
case 'HLEN': return asHash(space.get(a[0]!)).length;
|
|
850
|
+
case 'HEXISTS': return asHash(space.get(a[0]!)).some(([f]) => f === a[1]) ? 1 : 0;
|
|
851
|
+
case 'HDEL': {
|
|
852
|
+
const row = space.get(a[0]!);
|
|
853
|
+
const pairs = asHash(row);
|
|
854
|
+
const doomed = a.slice(1);
|
|
855
|
+
const kept = pairs.filter(([f]) => !doomed.includes(f));
|
|
856
|
+
if (row) {
|
|
857
|
+
if (kept.length === 0) space.remove(a[0]!, 'hash.del_empty'); // Redis drops an emptied key
|
|
858
|
+
else space.put({ ...row, kind: 'hash', v: kept }, 'hash.del');
|
|
859
|
+
}
|
|
860
|
+
return pairs.length - kept.length;
|
|
861
|
+
}
|
|
862
|
+
case 'HINCRBY': case 'HINCRBYFLOAT': {
|
|
863
|
+
const isFloat = name === 'HINCRBYFLOAT';
|
|
864
|
+
const row = space.get(a[0]!);
|
|
865
|
+
const pairs = asHash(row);
|
|
866
|
+
const idx = pairs.findIndex(([f]) => f === a[1]);
|
|
867
|
+
const current = idx < 0 ? undefined : pairs[idx]![1];
|
|
868
|
+
const base = current === undefined ? 0 : (isFloat ? toFloat(current) : toInt(current));
|
|
869
|
+
const next = isFloat ? guardFloatResult(base + toFloat(a[2]!)) : base + toInt(a[2]!);
|
|
870
|
+
const text = isFloat ? fmtScore(next) : String(next);
|
|
871
|
+
if (idx < 0) pairs.push([a[1]!, text]); else pairs[idx] = [a[1]!, text];
|
|
872
|
+
space.put({ name: a[0]!, kind: 'hash', v: pairs, pexpireAt: row?.pexpireAt ?? null }, 'hash.incrby');
|
|
873
|
+
return isFloat ? text : next;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// ── sets ─────────────────────────────────────────────────────────────────────────────────
|
|
877
|
+
case 'SADD': {
|
|
878
|
+
const row = space.get(a[0]!);
|
|
879
|
+
const members = asSet(row);
|
|
880
|
+
let added = 0;
|
|
881
|
+
for (const m of a.slice(1)) if (!members.includes(m)) { members.push(m); added++; }
|
|
882
|
+
space.put({ name: a[0]!, kind: 'set', v: members, pexpireAt: row?.pexpireAt ?? null }, 'set.add');
|
|
883
|
+
return added;
|
|
884
|
+
}
|
|
885
|
+
case 'SREM': {
|
|
886
|
+
const row = space.get(a[0]!);
|
|
887
|
+
const members = asSet(row);
|
|
888
|
+
const doomed = a.slice(1);
|
|
889
|
+
const kept = members.filter((m) => !doomed.includes(m));
|
|
890
|
+
if (row) {
|
|
891
|
+
if (kept.length === 0) space.remove(a[0]!, 'set.rem_empty');
|
|
892
|
+
else space.put({ ...row, kind: 'set', v: kept }, 'set.rem');
|
|
893
|
+
}
|
|
894
|
+
return members.length - kept.length;
|
|
895
|
+
}
|
|
896
|
+
case 'SMEMBERS': return asSet(space.get(a[0]!));
|
|
897
|
+
case 'SCARD': return asSet(space.get(a[0]!)).length;
|
|
898
|
+
case 'SISMEMBER': return asSet(space.get(a[0]!)).includes(a[1]!) ? 1 : 0;
|
|
899
|
+
case 'SMISMEMBER': { const members = asSet(space.get(a[0]!)); return a.slice(1).map((m) => (members.includes(m) ? 1 : 0)); }
|
|
900
|
+
case 'SRANDMEMBER': case 'SPOP': {
|
|
901
|
+
const row = space.get(a[0]!);
|
|
902
|
+
const members = asSet(row);
|
|
903
|
+
const count = a.length >= 2 ? toInt(a[1]!) : null;
|
|
904
|
+
// SPOP's count must be positive; SRANDMEMBER's may be negative, and then "the command is allowed to return the
|
|
905
|
+
// same element multiple times", |count| of them (redis.io/docs/latest/commands/srandmember/)
|
|
906
|
+
if (name === 'SPOP' && count !== null && count < 0) throw new RedisCommandError('ERR value is out of range, must be positive');
|
|
907
|
+
if (name === 'SRANDMEMBER' && count !== null && count < 0) return members.length === 0 ? [] : Array.from({ length: -count }, (_, i) => members[i % members.length]!);
|
|
908
|
+
// DETERMINISTIC: takes from the FRONT of insertion order rather than at random, for the same
|
|
909
|
+
// reason RANDOMKEY does. Disclosed (`upstash.sets.spop_deterministic`).
|
|
910
|
+
const taken = members.slice(0, count === null ? 1 : Math.max(0, count));
|
|
911
|
+
if (name === 'SPOP' && taken.length > 0) {
|
|
912
|
+
const kept = members.slice(taken.length);
|
|
913
|
+
if (kept.length === 0) space.remove(a[0]!, 'set.pop_empty');
|
|
914
|
+
else space.put({ ...row!, kind: 'set', v: kept }, 'set.pop');
|
|
915
|
+
}
|
|
916
|
+
return count === null ? (taken[0] ?? null) : taken;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// ── lists ────────────────────────────────────────────────────────────────────────────────
|
|
920
|
+
case 'LPUSH': case 'RPUSH': {
|
|
921
|
+
const row = space.get(a[0]!);
|
|
922
|
+
const items = asList(row);
|
|
923
|
+
// LPUSH inserts each element at the head IN TURN, so `LPUSH k a b` leaves [b, a].
|
|
924
|
+
if (name === 'LPUSH') for (const v of a.slice(1)) items.unshift(v);
|
|
925
|
+
else items.push(...a.slice(1));
|
|
926
|
+
space.put({ name: a[0]!, kind: 'list', v: items, pexpireAt: row?.pexpireAt ?? null }, 'list.push');
|
|
927
|
+
return items.length;
|
|
928
|
+
}
|
|
929
|
+
case 'LPOP': case 'RPOP': {
|
|
930
|
+
const row = space.get(a[0]!);
|
|
931
|
+
const items = asList(row);
|
|
932
|
+
const count = a.length >= 2 ? toInt(a[1]!) : null;
|
|
933
|
+
if (count !== null && count < 0) throw new RedisCommandError('ERR value is out of range, must be positive');
|
|
934
|
+
if (items.length === 0) return null;
|
|
935
|
+
const n = count === null ? 1 : count;
|
|
936
|
+
const taken = name === 'LPOP' ? items.splice(0, n) : items.splice(Math.max(0, items.length - n)).reverse();
|
|
937
|
+
if (items.length === 0) space.remove(a[0]!, 'list.pop_empty');
|
|
938
|
+
else space.put({ ...row!, kind: 'list', v: items }, 'list.pop');
|
|
939
|
+
return count === null ? (taken[0] ?? null) : taken;
|
|
940
|
+
}
|
|
941
|
+
case 'LLEN': return asList(space.get(a[0]!)).length;
|
|
942
|
+
case 'LINDEX': { const items = asList(space.get(a[0]!)); const i = toInt(a[1]!); return items[i < 0 ? items.length + i : i] ?? null; }
|
|
943
|
+
case 'LSET': {
|
|
944
|
+
const row = space.get(a[0]!);
|
|
945
|
+
if (!row) throw new RedisCommandError(NO_SUCH_KEY);
|
|
946
|
+
const items = asList(row);
|
|
947
|
+
const raw = toInt(a[1]!);
|
|
948
|
+
const i = raw < 0 ? items.length + raw : raw;
|
|
949
|
+
if (i < 0 || i >= items.length) throw new RedisCommandError('ERR index out of range');
|
|
950
|
+
items[i] = a[2]!;
|
|
951
|
+
space.put({ ...row, kind: 'list', v: items }, 'list.set');
|
|
952
|
+
return OK;
|
|
953
|
+
}
|
|
954
|
+
case 'LRANGE': {
|
|
955
|
+
const items = asList(space.get(a[0]!));
|
|
956
|
+
const [start, stop] = normalizeRange(toInt(a[1]!), toInt(a[2]!), items.length);
|
|
957
|
+
return start > stop ? [] : items.slice(start, stop + 1);
|
|
958
|
+
}
|
|
959
|
+
case 'LTRIM': {
|
|
960
|
+
const row = space.get(a[0]!);
|
|
961
|
+
const items = asList(row);
|
|
962
|
+
if (!row) return OK;
|
|
963
|
+
const [start, stop] = normalizeRange(toInt(a[1]!), toInt(a[2]!), items.length);
|
|
964
|
+
const kept = start > stop ? [] : items.slice(start, stop + 1);
|
|
965
|
+
if (kept.length === 0) space.remove(a[0]!, 'list.trim_empty');
|
|
966
|
+
else space.put({ ...row, kind: 'list', v: kept }, 'list.trim');
|
|
967
|
+
return OK;
|
|
968
|
+
}
|
|
969
|
+
case 'LREM': {
|
|
970
|
+
const row = space.get(a[0]!);
|
|
971
|
+
const items = asList(row);
|
|
972
|
+
const count = toInt(a[1]!);
|
|
973
|
+
const target = a[2]!;
|
|
974
|
+
const limit = Math.abs(count);
|
|
975
|
+
const source = count < 0 ? [...items].reverse() : items;
|
|
976
|
+
const kept: string[] = [];
|
|
977
|
+
let removed = 0;
|
|
978
|
+
for (const item of source) {
|
|
979
|
+
if (item === target && (limit === 0 || removed < limit)) { removed++; continue; }
|
|
980
|
+
kept.push(item);
|
|
981
|
+
}
|
|
982
|
+
const result = count < 0 ? kept.reverse() : kept;
|
|
983
|
+
if (row) {
|
|
984
|
+
if (result.length === 0) space.remove(a[0]!, 'list.rem_empty');
|
|
985
|
+
else space.put({ ...row, kind: 'list', v: result }, 'list.rem');
|
|
986
|
+
}
|
|
987
|
+
return removed;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// ── sorted sets ──────────────────────────────────────────────────────────────────────────
|
|
991
|
+
case 'ZADD': return zadd(space, a);
|
|
992
|
+
case 'ZINCRBY': {
|
|
993
|
+
const row = space.get(a[0]!);
|
|
994
|
+
const pairs = asZSet(row);
|
|
995
|
+
const by = toFloat(a[1]!);
|
|
996
|
+
const idx = pairs.findIndex(([m]) => m === a[2]);
|
|
997
|
+
const next = (idx < 0 ? 0 : pairs[idx]![1]) + by;
|
|
998
|
+
if (Number.isNaN(next)) throw new RedisCommandError('ERR resulting score is not a number (NaN)');
|
|
999
|
+
if (idx < 0) pairs.push([a[2]!, next]); else pairs[idx] = [a[2]!, next];
|
|
1000
|
+
space.put({ name: a[0]!, kind: 'zset', v: storeZSet(sortZSet(pairs)), pexpireAt: row?.pexpireAt ?? null }, 'zset.incrby');
|
|
1001
|
+
return fmtScore(next);
|
|
1002
|
+
}
|
|
1003
|
+
case 'ZSCORE': { const p = asZSet(space.get(a[0]!)).find(([m]) => m === a[1]); return p ? fmtScore(p[1]) : null; }
|
|
1004
|
+
case 'ZCARD': return asZSet(space.get(a[0]!)).length;
|
|
1005
|
+
case 'ZCOUNT': {
|
|
1006
|
+
const lo = parseScoreBound(a[1]!);
|
|
1007
|
+
const hi = parseScoreBound(a[2]!);
|
|
1008
|
+
return asZSet(space.get(a[0]!)).filter(([, s]) => inScoreRange(s, lo, hi)).length;
|
|
1009
|
+
}
|
|
1010
|
+
case 'ZRANK': case 'ZREVRANK': {
|
|
1011
|
+
const sorted = sortZSet(asZSet(space.get(a[0]!)));
|
|
1012
|
+
const ordered = name === 'ZREVRANK' ? [...sorted].reverse() : sorted;
|
|
1013
|
+
const i = ordered.findIndex(([m]) => m === a[1]);
|
|
1014
|
+
// WITHSCORE (Redis 7.2): the rank and the member's score
|
|
1015
|
+
if (a.length === 3 && a[2]!.toUpperCase() !== 'WITHSCORE') throw new RedisCommandError(SYNTAX);
|
|
1016
|
+
if (a.length === 3) return i < 0 ? null : [i, fmtScore(ordered[i]![1])];
|
|
1017
|
+
return i < 0 ? null : i;
|
|
1018
|
+
}
|
|
1019
|
+
case 'ZREM': {
|
|
1020
|
+
const row = space.get(a[0]!);
|
|
1021
|
+
const pairs = asZSet(row);
|
|
1022
|
+
const doomed = a.slice(1);
|
|
1023
|
+
const kept = pairs.filter(([m]) => !doomed.includes(m));
|
|
1024
|
+
if (row) {
|
|
1025
|
+
if (kept.length === 0) space.remove(a[0]!, 'zset.rem_empty');
|
|
1026
|
+
else space.put({ ...row, kind: 'zset', v: storeZSet(kept) }, 'zset.rem');
|
|
1027
|
+
}
|
|
1028
|
+
return pairs.length - kept.length;
|
|
1029
|
+
}
|
|
1030
|
+
case 'ZREMRANGEBYSCORE': {
|
|
1031
|
+
const row = space.get(a[0]!);
|
|
1032
|
+
const pairs = asZSet(row);
|
|
1033
|
+
const lo = parseScoreBound(a[1]!);
|
|
1034
|
+
const hi = parseScoreBound(a[2]!);
|
|
1035
|
+
const kept = pairs.filter(([, s]) => !inScoreRange(s, lo, hi));
|
|
1036
|
+
if (row) {
|
|
1037
|
+
if (kept.length === 0) space.remove(a[0]!, 'zset.remrange_empty');
|
|
1038
|
+
else space.put({ ...row, kind: 'zset', v: storeZSet(kept) }, 'zset.remrange');
|
|
1039
|
+
}
|
|
1040
|
+
return pairs.length - kept.length;
|
|
1041
|
+
}
|
|
1042
|
+
case 'ZREMRANGEBYRANK': {
|
|
1043
|
+
const row = space.get(a[0]!);
|
|
1044
|
+
const sorted = sortZSet(asZSet(row));
|
|
1045
|
+
const [start, stop] = normalizeRange(toInt(a[1]!), toInt(a[2]!), sorted.length);
|
|
1046
|
+
const doomed = start > stop ? [] : sorted.slice(start, stop + 1).map(([m]) => m);
|
|
1047
|
+
const kept = sorted.filter(([m]) => !doomed.includes(m));
|
|
1048
|
+
if (row) {
|
|
1049
|
+
if (kept.length === 0) space.remove(a[0]!, 'zset.remrank_empty');
|
|
1050
|
+
else space.put({ ...row, kind: 'zset', v: storeZSet(kept) }, 'zset.remrank');
|
|
1051
|
+
}
|
|
1052
|
+
return doomed.length;
|
|
1053
|
+
}
|
|
1054
|
+
case 'ZRANGE': case 'ZREVRANGE': case 'ZRANGEBYSCORE': case 'ZREVRANGEBYSCORE': return zrange(space, name, a);
|
|
1055
|
+
|
|
1056
|
+
// ── streams (dub's lib/upstash/redis-streams client uses exactly these five) ──────────────
|
|
1057
|
+
case 'XADD': return xadd(space, a, nowMs);
|
|
1058
|
+
case 'XLEN': return asStream(space.get(a[0]!)).length;
|
|
1059
|
+
case 'XRANGE': case 'XREVRANGE': {
|
|
1060
|
+
const entries = asStream(space.get(a[0]!));
|
|
1061
|
+
const _rev = name === 'XREVRANGE';
|
|
1062
|
+
// XREVRANGE takes its bounds in REVERSE order (`XREVRANGE key + -`).
|
|
1063
|
+
const [startRaw, endRaw] = _rev ? [a[2]!, a[1]!] : [a[1]!, a[2]!];
|
|
1064
|
+
let count: number | null = null;
|
|
1065
|
+
if (a.length >= 5) {
|
|
1066
|
+
if (a[3]!.toLowerCase() !== 'count') throw new RedisCommandError(SYNTAX);
|
|
1067
|
+
count = toInt(a[4]!);
|
|
1068
|
+
} else if (a.length === 4) throw new RedisCommandError(SYNTAX);
|
|
1069
|
+
const lo = parseStreamBound(startRaw, 'min');
|
|
1070
|
+
const hi = parseStreamBound(endRaw, 'max');
|
|
1071
|
+
// a bound prefixed `(` is exclusive (XRANGE: "Exclusive ranges", redis.io/docs/latest/commands/xrange)
|
|
1072
|
+
const loOpen = startRaw.startsWith('('); const hiOpen = endRaw.startsWith('(');
|
|
1073
|
+
let selected = entries.filter(([id]) => {
|
|
1074
|
+
const fromLo = compareStreamIds(id, lo); const toHi = compareStreamIds(id, hi);
|
|
1075
|
+
return (loOpen ? fromLo > 0 : fromLo >= 0) && (hiOpen ? toHi < 0 : toHi <= 0);
|
|
1076
|
+
});
|
|
1077
|
+
if (_rev) selected = selected.reverse();
|
|
1078
|
+
if (count !== null) selected = selected.slice(0, Math.max(0, count));
|
|
1079
|
+
return selected.map(([id, fields]) => [id, fields.flat()] as RedisValue);
|
|
1080
|
+
}
|
|
1081
|
+
case 'XDEL': {
|
|
1082
|
+
const row = space.get(a[0]!);
|
|
1083
|
+
const entries = asStream(row);
|
|
1084
|
+
const doomed = a.slice(1);
|
|
1085
|
+
const kept = entries.filter(([id]) => !doomed.includes(id));
|
|
1086
|
+
if (row) space.put({ ...row, kind: 'stream', v: kept }, 'stream.del');
|
|
1087
|
+
return entries.length - kept.length;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
// ── scripting ────────────────────────────────────────────────────────────────────────────
|
|
1091
|
+
case 'SCRIPT': {
|
|
1092
|
+
const sub = a[0]!.toUpperCase();
|
|
1093
|
+
if (sub === 'LOAD') { if (a.length !== 2) throw new RedisCommandError(wrongArity('script')); return storeScript(space, a[1]!); }
|
|
1094
|
+
if (sub === 'EXISTS') return a.slice(1).map((sha) => (space.hasScript(sha.toLowerCase()) ? 1 : 0));
|
|
1095
|
+
if (sub === 'FLUSH') { space.flushScripts(); return OK; }
|
|
1096
|
+
throw new RedisCommandError(`ERR Unknown SCRIPT subcommand or wrong number of arguments for '${a[0]}'`);
|
|
1097
|
+
}
|
|
1098
|
+
case 'EVAL': case 'EVAL_RO': case 'EVALSHA': case 'EVALSHA_RO': {
|
|
1099
|
+
const bySha = name.startsWith('EVALSHA');
|
|
1100
|
+
let script: string;
|
|
1101
|
+
if (bySha) {
|
|
1102
|
+
const found = space.scriptFor(a[0]!.toLowerCase());
|
|
1103
|
+
// THE `NOSCRIPT` CONTRACT: @upstash/ratelimit ALWAYS tries EVALSHA first and only falls back
|
|
1104
|
+
// to EVAL when the error text contains "NOSCRIPT" (its `safeEval`, src/hash.ts). Answering
|
|
1105
|
+
// anything else here — above all a fake success — breaks every rate limiter pointed at this
|
|
1106
|
+
// twin, silently. This is the single most load-bearing error string in the pack.
|
|
1107
|
+
if (found === undefined) throw new RedisCommandError(NOSCRIPT);
|
|
1108
|
+
script = found;
|
|
1109
|
+
} else {
|
|
1110
|
+
script = a[0]!;
|
|
1111
|
+
// Caching is a WRITE, so a read-only twin must not attempt it — otherwise `EVAL_RO`, which
|
|
1112
|
+
// is a read by definition, would 405 on the cache rather than on anything it does (§9
|
|
1113
|
+
// round 1). The cost is that a read-only twin cannot serve a later EVALSHA for this
|
|
1114
|
+
// script, which is the correct read-only outcome rather than a silent write.
|
|
1115
|
+
if (!space.ctx.readOnly) storeScript(space, script);
|
|
1116
|
+
}
|
|
1117
|
+
const numKeys = toInt(a[1]!);
|
|
1118
|
+
if (numKeys < 0) throw new RedisCommandError("ERR Number of keys can't be negative");
|
|
1119
|
+
if (numKeys > a.length - 2) throw new RedisCommandError("ERR Number of keys can't be greater than number of args");
|
|
1120
|
+
return evalScript(space, script, a.slice(2, 2 + numKeys), a.slice(2 + numKeys), name.endsWith('_RO'));
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
default:
|
|
1124
|
+
// Unreachable: `commandShapeError` above already rejected anything not in SERVED_COMMANDS.
|
|
1125
|
+
throw new RedisCommandError(unavailableCommand(name));
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
/**
|
|
1130
|
+
* A key's fixed position in SCAN's iteration order: the low 31 bits of its own SHA-1.
|
|
1131
|
+
*
|
|
1132
|
+
* Deriving it from the KEY NAME is the whole point — the position is a property of the key, so
|
|
1133
|
+
* adding or deleting any OTHER key cannot move it, and a key present for the whole scan is
|
|
1134
|
+
* therefore returned exactly once. `+1` keeps every value strictly positive so that cursor 0
|
|
1135
|
+
* unambiguously means "start"/"done" and can never also mean "resume at the first key".
|
|
1136
|
+
*/
|
|
1137
|
+
function scanCursorFor(key: string): number {
|
|
1138
|
+
return (Number.parseInt(createHash('sha1').update(key).digest('hex').slice(0, 8), 16) >>> 1) + 1;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
/** Live key names, with the internal script-cache keys filtered out of the keyspace entirely. */
|
|
1142
|
+
/** Every live key name. No filtering: the script cache lives in a different subject type entirely,
|
|
1143
|
+
* so there is no internal name for a caller's key to collide with or be hidden by. */
|
|
1144
|
+
function visibleKeys(space: KeySpace): string[] {
|
|
1145
|
+
return space.all().map((r) => r.name);
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
type ScoreBound = { value: number; exclusive: boolean };
|
|
1149
|
+
function parseScoreBound(raw: string): ScoreBound {
|
|
1150
|
+
const exclusive = raw.startsWith('(');
|
|
1151
|
+
return { value: toFloat(exclusive ? raw.slice(1) : raw), exclusive };
|
|
1152
|
+
}
|
|
1153
|
+
function inScoreRange(score: number, lo: ScoreBound, hi: ScoreBound): boolean {
|
|
1154
|
+
return (lo.exclusive ? score > lo.value : score >= lo.value) && (hi.exclusive ? score < hi.value : score <= hi.value);
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
function zadd(space: KeySpace, a: string[]): RedisValue {
|
|
1158
|
+
let nx = false;
|
|
1159
|
+
let xx = false;
|
|
1160
|
+
let gt = false;
|
|
1161
|
+
let lt = false;
|
|
1162
|
+
let ch = false;
|
|
1163
|
+
let incr = false;
|
|
1164
|
+
let i = 1;
|
|
1165
|
+
for (; i < a.length; i++) {
|
|
1166
|
+
const tok = a[i]!.toLowerCase();
|
|
1167
|
+
if (tok === 'nx') { nx = true; continue; }
|
|
1168
|
+
if (tok === 'xx') { xx = true; continue; }
|
|
1169
|
+
if (tok === 'gt') { gt = true; continue; }
|
|
1170
|
+
if (tok === 'lt') { lt = true; continue; }
|
|
1171
|
+
if (tok === 'ch') { ch = true; continue; }
|
|
1172
|
+
if (tok === 'incr') { incr = true; continue; }
|
|
1173
|
+
break;
|
|
1174
|
+
}
|
|
1175
|
+
if (nx && xx) throw new RedisCommandError('ERR XX and NX options at the same time are not compatible');
|
|
1176
|
+
if ((nx && (gt || lt)) || (gt && lt)) throw new RedisCommandError('ERR GT, LT, and/or NX options at the same time are not compatible');
|
|
1177
|
+
const rest = a.slice(i);
|
|
1178
|
+
if (rest.length === 0 || rest.length % 2 !== 0) throw new RedisCommandError(SYNTAX);
|
|
1179
|
+
if (incr && rest.length !== 2) throw new RedisCommandError('ERR INCR option supports a single increment-element pair');
|
|
1180
|
+
|
|
1181
|
+
const row = space.get(a[0]!);
|
|
1182
|
+
const pairs = asZSet(row);
|
|
1183
|
+
let added = 0;
|
|
1184
|
+
let changed = 0;
|
|
1185
|
+
let incrResult: number | null = null;
|
|
1186
|
+
for (let j = 0; j < rest.length; j += 2) {
|
|
1187
|
+
const score = toFloat(rest[j]!);
|
|
1188
|
+
const member = rest[j + 1]!;
|
|
1189
|
+
const idx = pairs.findIndex(([m]) => m === member);
|
|
1190
|
+
if (idx < 0) {
|
|
1191
|
+
if (xx) { incrResult = null; continue; }
|
|
1192
|
+
pairs.push([member, score]);
|
|
1193
|
+
added++;
|
|
1194
|
+
changed++;
|
|
1195
|
+
incrResult = score;
|
|
1196
|
+
continue;
|
|
1197
|
+
}
|
|
1198
|
+
if (nx) { incrResult = null; continue; }
|
|
1199
|
+
const current = pairs[idx]![1];
|
|
1200
|
+
const next = incr ? current + score : score;
|
|
1201
|
+
// Redis refuses a score that computes to NaN (e.g. +inf added to -inf) by name.
|
|
1202
|
+
if (Number.isNaN(next)) throw new RedisCommandError('ERR resulting score is not a number (NaN)');
|
|
1203
|
+
// A GT/LT that REFUSES the move answers nil under INCR — real Redis marks ZADD_OUT_NOP and
|
|
1204
|
+
// replies null, which is how a caller distinguishes "applied" from "refused" (the same
|
|
1205
|
+
// distinction `SET … NX` relies on). Answering the current score would silently read as success.
|
|
1206
|
+
if ((gt && next <= current) || (lt && next >= current)) { incrResult = null; continue; }
|
|
1207
|
+
if (next !== current) changed++;
|
|
1208
|
+
pairs[idx] = [member, next];
|
|
1209
|
+
incrResult = next;
|
|
1210
|
+
}
|
|
1211
|
+
// THE HUSK GUARD (§9 round 1): `ZADD missing XX 1 m` adds nothing, and real Redis creates no key
|
|
1212
|
+
// at all. An unconditional write here materialised an EMPTY zset that EXISTS/TYPE/DBSIZE/KEYS all
|
|
1213
|
+
// reported and that made a later LPUSH on the same name raise WRONGTYPE. Only write when there is
|
|
1214
|
+
// something to write, or when the key already existed.
|
|
1215
|
+
if (pairs.length > 0 || row !== undefined) {
|
|
1216
|
+
space.put({ name: a[0]!, kind: 'zset', v: storeZSet(sortZSet(pairs)), pexpireAt: row?.pexpireAt ?? null }, 'zset.add');
|
|
1217
|
+
}
|
|
1218
|
+
if (incr) return incrResult === null ? null : fmtScore(incrResult);
|
|
1219
|
+
return ch ? changed : added;
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
function zrange(space: KeySpace, name: string, a: string[]): RedisValue {
|
|
1223
|
+
let byScore = name === 'ZRANGEBYSCORE' || name === 'ZREVRANGEBYSCORE';
|
|
1224
|
+
let _rev = name === 'ZREVRANGE' || name === 'ZREVRANGEBYSCORE';
|
|
1225
|
+
let withScores = false;
|
|
1226
|
+
let limit: { offset: number; count: number } | null = null;
|
|
1227
|
+
for (let i = 3; i < a.length; i++) {
|
|
1228
|
+
const tok = a[i]!.toLowerCase();
|
|
1229
|
+
if (tok === 'withscores') { withScores = true; continue; }
|
|
1230
|
+
if (tok === 'byscore') { byScore = true; continue; }
|
|
1231
|
+
if (tok === 'rev') { _rev = true; continue; }
|
|
1232
|
+
// Refused BY NAME rather than quietly treated as BYSCORE — a wrong ordering returned as a
|
|
1233
|
+
// success is exactly the fake success this pack forbids. Filed as a todo in the manifest.
|
|
1234
|
+
if (tok === 'bylex') throw new RedisCommandError('ERR twin: ZRANGE BYLEX is not modeled by this twin (upstash.sorted_sets.range_bylex is a filed todo)');
|
|
1235
|
+
if (tok === 'limit') {
|
|
1236
|
+
if (i + 2 >= a.length) throw new RedisCommandError(SYNTAX);
|
|
1237
|
+
limit = { offset: toInt(a[++i]!), count: toInt(a[++i]!) };
|
|
1238
|
+
continue;
|
|
1239
|
+
}
|
|
1240
|
+
throw new RedisCommandError(SYNTAX);
|
|
1241
|
+
}
|
|
1242
|
+
if (limit && !byScore) throw new RedisCommandError('ERR syntax error, LIMIT is only supported in combination with either BYSCORE or BYLEX');
|
|
1243
|
+
|
|
1244
|
+
const sorted = sortZSet(asZSet(space.get(a[0]!)));
|
|
1245
|
+
let selected: ZSetPairs;
|
|
1246
|
+
if (byScore) {
|
|
1247
|
+
// The REV byscore forms take (max, min); the forward forms take (min, max).
|
|
1248
|
+
const [loRaw, hiRaw] = _rev ? [a[2]!, a[1]!] : [a[1]!, a[2]!];
|
|
1249
|
+
const lo = parseScoreBound(loRaw);
|
|
1250
|
+
const hi = parseScoreBound(hiRaw);
|
|
1251
|
+
selected = sorted.filter(([, s]) => inScoreRange(s, lo, hi));
|
|
1252
|
+
if (_rev) selected = selected.reverse();
|
|
1253
|
+
if (limit) selected = limit.count < 0 ? selected.slice(limit.offset) : selected.slice(limit.offset, limit.offset + limit.count);
|
|
1254
|
+
} else {
|
|
1255
|
+
const ordered = _rev ? [...sorted].reverse() : sorted;
|
|
1256
|
+
const [start, stop] = normalizeRange(toInt(a[1]!), toInt(a[2]!), ordered.length);
|
|
1257
|
+
selected = start > stop ? [] : ordered.slice(start, stop + 1);
|
|
1258
|
+
}
|
|
1259
|
+
return withScores ? selected.flatMap(([m, s]) => [m, fmtScore(s)]) : selected.map(([m]) => m);
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
// ── streams ────────────────────────────────────────────────────────────────────────────────────
|
|
1263
|
+
function compareStreamIds(left: string, right: string): number {
|
|
1264
|
+
const [lms = 0, lseq = 0] = left.split('-').map(Number);
|
|
1265
|
+
const [rms = 0, rseq = 0] = right.split('-').map(Number);
|
|
1266
|
+
if (lms !== rms) return lms < rms ? -1 : 1;
|
|
1267
|
+
return lseq === rseq ? 0 : lseq < rseq ? -1 : 1;
|
|
1268
|
+
}
|
|
1269
|
+
function parseStreamBound(raw: string, side: 'min' | 'max'): string {
|
|
1270
|
+
if (raw === '-') return '0-0';
|
|
1271
|
+
if (raw === '+') return `${Number.MAX_SAFE_INTEGER}-${Number.MAX_SAFE_INTEGER}`;
|
|
1272
|
+
const bare = raw.startsWith('(') ? raw.slice(1) : raw;
|
|
1273
|
+
if (!/^\d+(-\d+)?$/.test(bare)) throw new RedisCommandError('ERR Invalid stream ID specified as stream command argument');
|
|
1274
|
+
return bare.includes('-') ? bare : `${bare}-${side === 'min' ? 0 : Number.MAX_SAFE_INTEGER}`;
|
|
1275
|
+
}
|
|
1276
|
+
function xadd(space: KeySpace, a: string[], nowMs: number): RedisValue {
|
|
1277
|
+
let i = 1;
|
|
1278
|
+
let noMkStream = false;
|
|
1279
|
+
for (; i < a.length; i++) {
|
|
1280
|
+
const tok = a[i]!.toLowerCase();
|
|
1281
|
+
if (tok === 'nomkstream') { noMkStream = true; continue; }
|
|
1282
|
+
// Accepting a trim option and silently NOT trimming would be a fake success, so it is refused.
|
|
1283
|
+
if (tok === 'maxlen' || tok === 'minid') throw new RedisCommandError(`ERR twin: XADD ${tok.toUpperCase()} trimming is not modeled by this twin (upstash.streams.trim is a filed todo)`);
|
|
1284
|
+
break;
|
|
1285
|
+
}
|
|
1286
|
+
const row = space.get(a[0]!);
|
|
1287
|
+
if (!row && noMkStream) return null;
|
|
1288
|
+
const entries = asStream(row);
|
|
1289
|
+
const idSpec = a[i]!;
|
|
1290
|
+
const fields = a.slice(i + 1);
|
|
1291
|
+
if (fields.length === 0 || fields.length % 2 !== 0) throw new RedisCommandError(wrongArity('xadd'));
|
|
1292
|
+
let id: string;
|
|
1293
|
+
if (idSpec === '*') {
|
|
1294
|
+
// The id's ms part is the REQUEST CLOCK (`occurredAt`), never `Date.now()`. Two entries added
|
|
1295
|
+
// in the same pinned millisecond get sequence 0, 1, 2 … exactly as Redis does — which is what
|
|
1296
|
+
// makes a stream verify reproducible.
|
|
1297
|
+
const [lastMs = 0, lastSeq = 0] = (row?.lastId ?? '0-0').split('-').map(Number);
|
|
1298
|
+
id = nowMs > lastMs ? `${nowMs}-0` : `${lastMs}-${lastSeq + 1}`;
|
|
1299
|
+
} else {
|
|
1300
|
+
id = idSpec.includes('-') ? idSpec : `${idSpec}-0`;
|
|
1301
|
+
if (!/^\d+-\d+$/.test(id)) throw new RedisCommandError('ERR Invalid stream ID specified as stream command argument');
|
|
1302
|
+
// Compare against the RETAINED last-id, not merely the last surviving entry: Redis keeps
|
|
1303
|
+
// `last_id` past an XDEL, so an id that was used and then deleted stays refused forever. §9
|
|
1304
|
+
// round 1 found the entries-only comparison re-accepting `5-5` after `XDEL st 5-5`.
|
|
1305
|
+
const top = row?.lastId ?? (entries.length > 0 ? entries[entries.length - 1]![0] : '0-0');
|
|
1306
|
+
if (compareStreamIds(id, top) <= 0) {
|
|
1307
|
+
throw new RedisCommandError('ERR The ID specified in XADD is equal or smaller than the target stream top item');
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
const pairs: HashPairs = [];
|
|
1311
|
+
for (let j = 0; j < fields.length; j += 2) pairs.push([fields[j]!, fields[j + 1]!]);
|
|
1312
|
+
entries.push([id, pairs]);
|
|
1313
|
+
space.put({ name: a[0]!, kind: 'stream', v: entries, pexpireAt: row?.pexpireAt ?? null, lastId: id }, 'stream.add');
|
|
1314
|
+
return id;
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
// ── the script cache ───────────────────────────────────────────────────────────────────────────
|
|
1318
|
+
//
|
|
1319
|
+
// Real Redis keeps the EVAL script cache in server memory (a restart or `SCRIPT FLUSH` empties it).
|
|
1320
|
+
// This twin persists it in the kernel under its OWN subject type ('script'), so EVALSHA keeps
|
|
1321
|
+
// working across a twin restart. Two things follow, and both are deliberate:
|
|
1322
|
+
// • A DISCLOSED divergence from the vendor — strictly more forgiving, never less — which is what
|
|
1323
|
+
// lets a persistent world's rate limiters survive a restart without a NOSCRIPT round trip.
|
|
1324
|
+
// • ZERO collision surface with the keyspace. An earlier draft stored scripts as keys under a
|
|
1325
|
+
// magic ` twin:script:<sha>` prefix and filtered that prefix out of KEYS/SCAN/DBSIZE; a caller
|
|
1326
|
+
// who wrote a key with that exact name (perfectly legal — Redis keys may begin with a space)
|
|
1327
|
+
// had it silently disappear from every listing and could then execute it via EVALSHA. Separate
|
|
1328
|
+
// subject types make that impossible instead of merely filtered.
|
|
1329
|
+
function storeScript(space: KeySpace, script: string): string {
|
|
1330
|
+
const sha = luaScriptSha1(script);
|
|
1331
|
+
if (!space.hasScript(sha)) space.putScript(sha, script);
|
|
1332
|
+
return sha;
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
// ── Lua ↔ Redis value conversion (real Redis's rules — see the Lua module header) ─────────────
|
|
1336
|
+
function redisToLua(v: RedisValue): LuaValue {
|
|
1337
|
+
if (v === null) return false; // nil bulk reply → Lua `false`
|
|
1338
|
+
if (typeof v === 'number' || typeof v === 'string') return v;
|
|
1339
|
+
if (v instanceof RedisStatus) {
|
|
1340
|
+
const t = new LuaTable();
|
|
1341
|
+
t.set('ok', v.value); // status reply → Lua table {ok=…}
|
|
1342
|
+
return t;
|
|
1343
|
+
}
|
|
1344
|
+
return LuaTable.fromArray(v.map(redisToLua));
|
|
1345
|
+
}
|
|
1346
|
+
function luaToRedis(v: LuaValue): RedisValue {
|
|
1347
|
+
if (v === null || v === false) return null;
|
|
1348
|
+
if (v === true) return 1; // Lua `true` → integer 1
|
|
1349
|
+
if (typeof v === 'number') return Math.trunc(v); // Lua number → integer, TRUNCATED toward zero
|
|
1350
|
+
if (typeof v === 'string') return v;
|
|
1351
|
+
if (v instanceof LuaTable) {
|
|
1352
|
+
const err = v.get('err');
|
|
1353
|
+
if (typeof err === 'string') throw new RedisCommandError(err);
|
|
1354
|
+
const ok = v.get('ok');
|
|
1355
|
+
if (typeof ok === 'string') return new RedisStatus(ok);
|
|
1356
|
+
const out: RedisValue[] = [];
|
|
1357
|
+
for (let i = 1; ; i++) {
|
|
1358
|
+
const item = v.get(i);
|
|
1359
|
+
if (item === null) break; // an array reply STOPS at the first nil
|
|
1360
|
+
out.push(luaToRedis(item));
|
|
1361
|
+
}
|
|
1362
|
+
return out;
|
|
1363
|
+
}
|
|
1364
|
+
throw new RedisCommandError('ERR twin: script returned a function value, which has no Redis representation');
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
/**
|
|
1368
|
+
* Run a Lua script over this twin's real command core, ATOMICALLY.
|
|
1369
|
+
*
|
|
1370
|
+
* `redis.call` re-enters `execOne` on the SAME `KeySpace`, so a script's writes are visible to its
|
|
1371
|
+
* own later reads — which every @upstash/ratelimit script depends on — and there is no second,
|
|
1372
|
+
* script-only implementation of `INCRBY` to drift from the real one. A script that raises part-way
|
|
1373
|
+
* is rolled back to the snapshot taken before it started, which is what "a script is one indivisible
|
|
1374
|
+
* unit" means.
|
|
1375
|
+
*/
|
|
1376
|
+
function evalScript(space: KeySpace, script: string, keys: string[], args: string[], readOnly: boolean): RedisValue {
|
|
1377
|
+
const before = space.snapshot();
|
|
1378
|
+
try {
|
|
1379
|
+
const result = runLua(script, {
|
|
1380
|
+
keys,
|
|
1381
|
+
argv: args,
|
|
1382
|
+
call: (callArgs, line) => {
|
|
1383
|
+
const cmd = String(callArgs[0] ?? '').toUpperCase();
|
|
1384
|
+
// Redis's refusal, as EVAL_RO's page prints it (redis.io/docs/latest/commands/eval_ro/): the script by its SHA1
|
|
1385
|
+
// and the line of the call
|
|
1386
|
+
if (readOnly && isWriteCommand(cmd, callArgs.slice(1))) throw new RedisCommandError(`ERR Error running script (call to ${luaScriptSha1(script)}): @user_script:${line}: @user_script: ${line}: Write commands are not allowed from read-only scripts.`);
|
|
1387
|
+
return redisToLua(execOne(space, callArgs));
|
|
1388
|
+
},
|
|
1389
|
+
});
|
|
1390
|
+
return luaToRedis(result);
|
|
1391
|
+
} catch (e) {
|
|
1392
|
+
space.restore(before); // atomicity: a failed script leaves NOTHING behind
|
|
1393
|
+
if (e instanceof RedisCommandError || e instanceof ReadOnlyError) throw e;
|
|
1394
|
+
if (e instanceof LuaError) throw new RedisCommandError(`ERR Error running script: ${e.message}`);
|
|
1395
|
+
throw e;
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
1400
|
+
// THE RUN
|
|
1401
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
1402
|
+
|
|
1403
|
+
export type RunItem = { result: RedisValue } | { error: string };
|
|
1404
|
+
|
|
1405
|
+
/**
|
|
1406
|
+
* Execute a whole request's worth of commands against one root, then flush.
|
|
1407
|
+
*
|
|
1408
|
+
* Each element comes back as `{result}` or `{error}` — the exact per-command envelope Upstash's
|
|
1409
|
+
* `/pipeline` and `/multi-exec` endpoints return and the SDK's `Pipeline.exec` destructures.
|
|
1410
|
+
*
|
|
1411
|
+
* RUNTIME ERRORS DO NOT ABORT — not in a pipeline and (LIVE-PROBED, contrary to the intuition that
|
|
1412
|
+
* a "transaction" rolls back) NOT in `/multi-exec` either: Upstash's own docs say "all commands
|
|
1413
|
+
* will be executed. Upstash Redis will not stop the processing of commands. This is to provide same
|
|
1414
|
+
* semantics with Redis when there are errors inside a transaction." A probe of the real service
|
|
1415
|
+
* confirmed a `SET` after a failing `INCR` in a `/multi-exec` batch is applied. Structural faults —
|
|
1416
|
+
* an unavailable command or a bad arity — are QUEUE-time and DO discard the whole batch, but the
|
|
1417
|
+
* caller (`upstash-twin.ts`) rejects those before this function is ever reached.
|
|
1418
|
+
*/
|
|
1419
|
+
export async function execRedisRun(commands: string[][], ctx: RedisContext): Promise<{ items: RunItem[]; wrote: boolean }> {
|
|
1420
|
+
const nowMs = Date.parse(ctx.occurredAt);
|
|
1421
|
+
if (!Number.isFinite(nowMs)) throw new RedisCommandError(`ERR twin: unparseable occurredAt '${ctx.occurredAt}'`);
|
|
1422
|
+
const space = new KeySpace(ctx, nowMs);
|
|
1423
|
+
const items: RunItem[] = [];
|
|
1424
|
+
for (const argv of commands) {
|
|
1425
|
+
try {
|
|
1426
|
+
items.push({ result: execOne(space, argv) });
|
|
1427
|
+
} catch (e) {
|
|
1428
|
+
if (e instanceof ReadOnlyError) throw e; // surfaces as HTTP 405 for the whole request
|
|
1429
|
+
if (e instanceof RedisCommandError) { items.push({ error: e.message }); continue; }
|
|
1430
|
+
throw e;
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
const wrote = space.dirty;
|
|
1434
|
+
await flushKeySpace(space);
|
|
1435
|
+
return { items, wrote };
|
|
1436
|
+
}
|
|
1437
|
+
|