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