@volter/world-core 2.0.0 → 2.0.1

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