@volter/twin-upstash 0.1.0

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