@volter/twin-upstash 0.1.0 → 0.1.2

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