@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,1286 @@
1
+ // upstash capability manifest — the EXPECTED REAL-PRODUCT SURFACE (the target), authored
2
+ // top-down from what the Upstash Redis REST API actually does, NOT from what this twin has built.
3
+ //
4
+ // GROUNDED (2026-08-19) three ways, all read-only — see spec-sources.json:
5
+ // (a) upstash.com/docs/redis/features/restapi + the Redis-compatibility and billing pages;
6
+ // (b) the ACTUALLY-INSTALLED `@upstash/redis@1.35.7` and `@upstash/ratelimit@2.0.8` package
7
+ // sources (their compiled command builders, HTTP client, and Lua scripts);
8
+ // (c) LIVE PROBES of a real ephemeral Upstash database — where every literal error string, HTTP
9
+ // status and base64 rule asserted below came from.
10
+ // The DENOMINATOR is enumerated from Redis's own command GROUPS as Upstash supports them (string,
11
+ // bitmap, hash, list, set, sorted-set, hyperloglog, geo, stream, JSON, pub/sub, scripting, generic,
12
+ // server, transactions, connection) crossed with the REST protocol's own surface — so whole command
13
+ // families this twin has not built (bitmaps, geo, HyperLogLog, RedisJSON, consumer groups, pub/sub)
14
+ // appear as `todo`s rather than being quietly left out of the count.
15
+ //
16
+ // `verify()` (required to count as done) is ground truth: every API verify drives the REAL
17
+ // `handleUpstashRedisTwinRequest` over a FRESH temp root with a PINNED clock, and asserts VALUES —
18
+ // never a bare status code. Nothing here is a mock.
19
+ //
20
+ // TIME IS INJECTED: TTL/window/stream-id time is measured against the request's own `occurredAt`
21
+ // (or `createUpstashRedisTwinServer({ now })`), which reproduces every TIME-DEPENDENT SEMANTIC —
22
+ // lazy expiry, TTL arithmetic, window rollover, monotonic stream ids — exactly and deterministically
23
+ // rather than racing a real clock. Every entry below is either done or todo.
24
+ //
25
+ // (Upstash Redis is an API-first vendor — docs/contributing/architecture.md C1b: an integrator CALLS this product from
26
+ // code and the Upstash console is a config/metrics dashboard, not where the work happens — so this
27
+ // pack ships NO mirror and there are no UI capabilities. See README ## Coverage § No UI mirror.)
28
+ import { mkdtempSync, rmSync } from 'node:fs';
29
+ import { tmpdir } from 'node:os';
30
+ import { join } from 'node:path';
31
+ import { checkCapabilities, verifyBoundary } from '@volter/world-tooling';
32
+ import { handleUpstashRedisTwinRequest } from "./upstash-twin.js";
33
+ import { checkUpstashRedisConformance } from "./upstash-conformance.js";
34
+ import { syncUpstashRedisFromReal, pullUpstashRedisKeys, pullUpstashRedisScan } from "./upstash-connector.js";
35
+ /** The pinned clock every verify runs against, so ids, TTLs and stream ids are deterministic. */
36
+ const AT = '2026-01-01T00:00:00.000Z';
37
+ const AT_MS = Date.parse(AT);
38
+ /** A later instant, for the verifies that need the clock to ADVANCE (expiry, window rollover). */
39
+ const laterAt = (ms) => new Date(AT_MS + ms).toISOString();
40
+ const AUTH = { authorization: 'Bearer twin-capability-token' };
41
+ async function withRoot(steps) {
42
+ const root = mkdtempSync(join(tmpdir(), 'upstash-cap-'));
43
+ const raw = (req) => handleUpstashRedisTwinRequest({
44
+ method: req.method ?? 'POST',
45
+ path: req.path ?? '/',
46
+ root,
47
+ occurredAt: req.at ?? AT,
48
+ headers: req.headers === undefined ? AUTH : req.headers,
49
+ ...(req.body !== undefined ? { body: req.body } : {}),
50
+ ...(req.readOnly !== undefined ? { readOnly: req.readOnly } : {}),
51
+ ...(req.token !== undefined ? { token: req.token } : {}),
52
+ });
53
+ const run = (async (argv, opts = {}) => {
54
+ const res = await raw({
55
+ body: JSON.stringify(argv.map((x) => (typeof x === 'number' ? x : String(x)))),
56
+ ...(opts.at !== undefined ? { at: opts.at } : {}),
57
+ ...(opts.headers !== undefined ? { headers: opts.headers } : {}),
58
+ });
59
+ const body = (res.body ?? {});
60
+ return { status: res.status, result: body.result, error: body.error, ...(res.headers ? { headers: res.headers } : {}) };
61
+ });
62
+ run.raw = raw;
63
+ run.root = root;
64
+ try {
65
+ return await verifyBoundary('upstash.withRoot', () => steps(run, root));
66
+ }
67
+ finally {
68
+ rmSync(root, { recursive: true, force: true });
69
+ }
70
+ }
71
+ /** The three real single-region @upstash/ratelimit scripts, read out of the installed package. */
72
+ const RATELIMIT_FIXED_WINDOW = `
73
+ local key = KEYS[1]
74
+ local dynamicLimitKey = KEYS[2] -- optional: key for dynamic limit in redis
75
+ local tokens = tonumber(ARGV[1]) -- default limit
76
+ local window = ARGV[2]
77
+ local incrementBy = ARGV[3] -- increment rate per request at a given value, default is 1
78
+
79
+ -- Check for dynamic limit
80
+ local effectiveLimit = tokens
81
+ if dynamicLimitKey ~= "" then
82
+ local dynamicLimit = redis.call("GET", dynamicLimitKey)
83
+ if dynamicLimit then
84
+ effectiveLimit = tonumber(dynamicLimit)
85
+ end
86
+ end
87
+
88
+ local r = redis.call("INCRBY", key, incrementBy)
89
+ if r == tonumber(incrementBy) then
90
+ -- The first time this key is set, the value will be equal to incrementBy.
91
+ -- So we only need the expire command once
92
+ redis.call("PEXPIRE", key, window)
93
+ end
94
+
95
+ return {r, effectiveLimit}
96
+ `;
97
+ const RATELIMIT_SLIDING_WINDOW = `
98
+ local currentKey = KEYS[1] -- identifier including prefixes
99
+ local previousKey = KEYS[2] -- key of the previous bucket
100
+ local dynamicLimitKey = KEYS[3] -- optional: key for dynamic limit in redis
101
+ local tokens = tonumber(ARGV[1]) -- default tokens per window
102
+ local now = ARGV[2] -- current timestamp in milliseconds
103
+ local window = ARGV[3] -- interval in milliseconds
104
+ local incrementBy = tonumber(ARGV[4]) -- increment rate per request at a given value, default is 1
105
+
106
+ -- Check for dynamic limit
107
+ local effectiveLimit = tokens
108
+ if dynamicLimitKey ~= "" then
109
+ local dynamicLimit = redis.call("GET", dynamicLimitKey)
110
+ if dynamicLimit then
111
+ effectiveLimit = tonumber(dynamicLimit)
112
+ end
113
+ end
114
+
115
+ local requestsInCurrentWindow = redis.call("GET", currentKey)
116
+ if requestsInCurrentWindow == false then
117
+ requestsInCurrentWindow = 0
118
+ end
119
+
120
+ local requestsInPreviousWindow = redis.call("GET", previousKey)
121
+ if requestsInPreviousWindow == false then
122
+ requestsInPreviousWindow = 0
123
+ end
124
+ local percentageInCurrent = ( now % window ) / window
125
+ -- weighted requests to consider from the previous window
126
+ requestsInPreviousWindow = math.floor(( 1 - percentageInCurrent ) * requestsInPreviousWindow)
127
+
128
+ -- Only check limit if not refunding (negative rate)
129
+ if incrementBy > 0 and requestsInPreviousWindow + requestsInCurrentWindow >= effectiveLimit then
130
+ return {-1, effectiveLimit}
131
+ end
132
+
133
+ local newValue = redis.call("INCRBY", currentKey, incrementBy)
134
+ if newValue == incrementBy then
135
+ -- The first time this key is set, the value will be equal to incrementBy.
136
+ -- So we only need the expire command once
137
+ redis.call("PEXPIRE", currentKey, window * 2 + 1000) -- Enough time to overlap with a new window + 1 second
138
+ end
139
+ return {effectiveLimit - ( newValue + requestsInPreviousWindow ), effectiveLimit}
140
+ `;
141
+ const RATELIMIT_TOKEN_BUCKET = `
142
+ local key = KEYS[1] -- identifier including prefixes
143
+ local dynamicLimitKey = KEYS[2] -- optional: key for dynamic limit in redis
144
+ local maxTokens = tonumber(ARGV[1]) -- default maximum number of tokens
145
+ local interval = tonumber(ARGV[2]) -- size of the window in milliseconds
146
+ local refillRate = tonumber(ARGV[3]) -- how many tokens are refilled after each interval
147
+ local now = tonumber(ARGV[4]) -- current timestamp in milliseconds
148
+ local incrementBy = tonumber(ARGV[5]) -- how many tokens to consume, default is 1
149
+
150
+ -- Check for dynamic limit
151
+ local effectiveLimit = maxTokens
152
+ if dynamicLimitKey ~= "" then
153
+ local dynamicLimit = redis.call("GET", dynamicLimitKey)
154
+ if dynamicLimit then
155
+ effectiveLimit = tonumber(dynamicLimit)
156
+ end
157
+ end
158
+
159
+ local bucket = redis.call("HMGET", key, "refilledAt", "tokens")
160
+
161
+ local refilledAt
162
+ local tokens
163
+
164
+ if bucket[1] == false then
165
+ refilledAt = now
166
+ tokens = effectiveLimit
167
+ else
168
+ refilledAt = tonumber(bucket[1])
169
+ tokens = tonumber(bucket[2])
170
+ end
171
+
172
+ if now >= refilledAt + interval then
173
+ local numRefills = math.floor((now - refilledAt) / interval)
174
+ tokens = math.min(effectiveLimit, tokens + numRefills * refillRate)
175
+
176
+ refilledAt = refilledAt + numRefills * interval
177
+ end
178
+
179
+ -- Only reject if tokens are 0 and we're consuming (not refunding)
180
+ if tokens == 0 and incrementBy > 0 then
181
+ return {-1, refilledAt + interval, effectiveLimit}
182
+ end
183
+
184
+ local remaining = tokens - incrementBy
185
+ local expireAt = math.ceil(((effectiveLimit - remaining) / refillRate)) * interval
186
+
187
+ redis.call("HSET", key, "refilledAt", refilledAt, "tokens", remaining)
188
+
189
+ if (expireAt > 0) then
190
+ redis.call("PEXPIRE", key, expireAt)
191
+ end
192
+ return {remaining, refilledAt + interval, effectiveLimit}
193
+ `;
194
+ /** dub's own compare-and-delete lock release (apps/web/lib/upstash/redis-lock.ts), verbatim. */
195
+ const DUB_RELEASE_LOCK = `
196
+ if redis.call("get", KEYS[1]) == ARGV[1] then
197
+ return redis.call("del", KEYS[1])
198
+ else
199
+ return 0
200
+ end`;
201
+ // ── a counting fake for the connector verifies (never a network client) ────────────────────────
202
+ function fakeRedisClient(data) {
203
+ const calls = [];
204
+ const client = {
205
+ type: async (k) => { calls.push(`type:${k}`); return data[k]?.type ?? 'none'; },
206
+ get: async (k) => { calls.push(`get:${k}`); return data[k]?.value ?? null; },
207
+ hgetall: async (k) => { calls.push(`hgetall:${k}`); return (data[k]?.value ?? null); },
208
+ smembers: async (k) => { calls.push(`smembers:${k}`); return (data[k]?.value ?? []); },
209
+ lrange: async (k) => { calls.push(`lrange:${k}`); return (data[k]?.value ?? []); },
210
+ zrange: async (k) => { calls.push(`zrange:${k}`); return (data[k]?.value ?? []); },
211
+ ttl: async (k) => { calls.push(`ttl:${k}`); return data[k]?.ttl ?? -1; },
212
+ scan: async (cursor) => { calls.push(`scan:${cursor}`); return ['0', Object.keys(data)]; },
213
+ };
214
+ return { client, calls };
215
+ }
216
+ const done = (id, area, title, dimension, tier, verify) => ({ id, area, title, dimension, tier, expected: 'done', verify });
217
+ const todo = (id, area, title, dimension, tier) => ({ id, area, title, dimension, tier, expected: 'todo' });
218
+ export const UPSTASH_CAPABILITIES = [
219
+ // ── REST PROTOCOL (the surface that makes this Upstash rather than Redis) ───────────────────
220
+ done('upstash.protocol.post_root_command', 'protocol', 'POST / with the whole command as a JSON array body -> {"result":…} (the documented "POST Command in Body" form)', 'api', 'core', () => withRoot(async (r) => {
221
+ const set = await r(['SET', 'p:a', 'one']);
222
+ const get = await r(['GET', 'p:a']);
223
+ return set.status === 200 && set.result === 'OK' && get.status === 200 && get.result === 'one';
224
+ })),
225
+ done('upstash.protocol.pipeline', 'protocol', 'POST /pipeline runs an array of command arrays IN ORDER and answers a positionally-aligned array of {result}/{error} objects (the endpoint the SDK\'s default auto-pipelining always uses)', 'api', 'core', () => withRoot(async (r) => {
226
+ const res = await r.raw({ path: '/pipeline', body: JSON.stringify([['SET', 'pl:k', 'v'], ['GET', 'pl:k'], ['INCR', 'pl:n']]) });
227
+ const items = res.body;
228
+ return res.status === 200 && Array.isArray(items) && items.length === 3
229
+ && items[0].result === 'OK' && items[1].result === 'v' && items[2].result === 1;
230
+ })),
231
+ done('upstash.protocol.pipeline_error_is_per_item', 'protocol', 'a RUNTIME failure inside a pipeline is reported per-item at HTTP 200 and does NOT abort the rest (LIVE-PROBED: the commands after it still apply)', 'api', 'core', () => withRoot(async (r) => {
232
+ await r(['SET', 'pe:s', 'abc']);
233
+ const res = await r.raw({ path: '/pipeline', body: JSON.stringify([['INCR', 'pe:s'], ['SET', 'pe:after', 'written']]) });
234
+ const items = res.body;
235
+ const after = await r(['GET', 'pe:after']);
236
+ return res.status === 200 && items[0].error === 'ERR value is not an integer or out of range'
237
+ && items[1].result === 'OK' && after.result === 'written';
238
+ })),
239
+ done('upstash.protocol.pipeline_rejects_malformed', 'protocol', 'an EMPTY pipeline -> 400 "ERR empty pipeline request"; a 1-D array -> 400 naming the index; an UNAVAILABLE command anywhere -> 400 with the whole batch discarded (nothing applied)', 'api', 'common', () => withRoot(async (r) => {
240
+ const empty = await r.raw({ path: '/pipeline', body: '[]' });
241
+ const flat = await r.raw({ path: '/pipeline', body: JSON.stringify(['SET', 'x', '1']) });
242
+ const bad = await r.raw({ path: '/pipeline', body: JSON.stringify([['SET', 'pm:ok', '1'], ['NOTACMD', 'x']]) });
243
+ const applied = await r(['GET', 'pm:ok']);
244
+ return empty.body.error === 'ERR empty pipeline request'
245
+ && empty.status === 400
246
+ && flat.body.error === 'ERR failed to parse pipeline command at index 0'
247
+ && bad.status === 400
248
+ && bad.body.error.startsWith("ERR Command is not available: 'NOTACMD'")
249
+ && applied.result === null; // the batch was discarded BEFORE execution
250
+ })),
251
+ done('upstash.protocol.multi_exec', 'protocol', 'POST /multi-exec runs the batch as a transaction and answers the same {result}/{error} array shape', 'api', 'core', () => withRoot(async (r) => {
252
+ const res = await r.raw({ path: '/multi-exec', body: JSON.stringify([['INCR', 'tx:n'], ['INCR', 'tx:n'], ['GET', 'tx:n']]) });
253
+ const items = res.body;
254
+ return res.status === 200 && items.length === 3 && items[0].result === 1 && items[1].result === 2 && items[2].result === '2';
255
+ })),
256
+ done('upstash.protocol.multi_exec_runtime_error_does_not_roll_back', 'protocol', 'a RUNTIME error inside /multi-exec is per-item at HTTP 200 and the later commands STILL APPLY — Upstash\'s documented "all commands will be executed … same semantics with Redis", NOT a rollback (LIVE-PROBED)', 'api', 'common', () => withRoot(async (r) => {
257
+ await r(['SET', 'tx:s', 'abc']);
258
+ const res = await r.raw({ path: '/multi-exec', body: JSON.stringify([['INCR', 'tx:s'], ['SET', 'tx:after', 'applied']]) });
259
+ const items = res.body;
260
+ const after = await r(['GET', 'tx:after']);
261
+ return res.status === 200 && items[0].error === 'ERR value is not an integer or out of range' && after.result === 'applied';
262
+ })),
263
+ done('upstash.protocol.multi_exec_execabort', 'protocol', 'a QUEUE-TIME fault discards the whole transaction pre-execution: a bad arity -> 400 EXECABORT naming the command, an unavailable command -> 400 "… at index N, discarding transaction", and nothing is applied', 'api', 'common', () => withRoot(async (r) => {
264
+ const arity = await r.raw({ path: '/multi-exec', body: JSON.stringify([['SET', 'ea:k', 'v'], ['GET']]) });
265
+ const unknown = await r.raw({ path: '/multi-exec', body: JSON.stringify([['SET', 'ea:k2', 'v'], ['NOTACMD']]) });
266
+ const k = await r(['GET', 'ea:k']);
267
+ const k2 = await r(['GET', 'ea:k2']);
268
+ return arity.status === 400
269
+ && arity.body.error === "EXECABORT Transaction discarded because of previous errors: ['GET': ERR wrong number of arguments for 'get' command]"
270
+ && unknown.status === 400
271
+ && unknown.body.error.endsWith('at index 1, discarding transaction')
272
+ && k.result === null && k2.result === null;
273
+ })),
274
+ done('upstash.protocol.path_style_get', 'protocol', 'GET /{COMMAND}/{arg}/… executes the command with percent-decoded args and a case-insensitive command name', 'api', 'core', () => withRoot(async (r) => {
275
+ const set = await r.raw({ method: 'GET', path: '/SET/ps:k/hello%20world' });
276
+ const get = await r.raw({ method: 'GET', path: '/get/ps:k' });
277
+ return set.status === 200 && set.body.result === 'OK'
278
+ && get.body.result === 'hello world';
279
+ })),
280
+ done('upstash.protocol.path_style_body_and_query', 'protocol', 'POST /set/{key} appends the RAW BODY as the command\'s last argument and turns query parameters into trailing option tokens (POST /set/k?EX=100 ⇒ SET k <body> EX 100)', 'api', 'common', () => withRoot(async (r) => {
281
+ const set = await r.raw({ method: 'POST', path: '/set/pb:k?EX=100', body: 'body-value' });
282
+ const get = await r(['GET', 'pb:k']);
283
+ const ttl = await r(['TTL', 'pb:k']);
284
+ return set.status === 200 && get.result === 'body-value' && ttl.result === 100;
285
+ })),
286
+ done('upstash.protocol.base64_encoding', 'protocol', 'Upstash-Encoding: base64 encodes every STRING in the result recursively at any array depth, leaving integers/null/empty arrays untouched (the SDK\'s DEFAULT, so a wrong answer here breaks every consumer)', 'api', 'core', () => withRoot(async (r) => {
287
+ const h = { ...AUTH, 'upstash-encoding': 'base64' };
288
+ await r(['SET', 'b64:s', 'hello']);
289
+ await r(['RPUSH', 'b64:l', 'two', 'three']);
290
+ await r(['SET', 'b64:n', '5']);
291
+ const s = await r(['GET', 'b64:s'], { headers: h });
292
+ const l = await r(['LRANGE', 'b64:l', '0', '-1'], { headers: h });
293
+ const n = await r(['INCR', 'b64:n'], { headers: h });
294
+ const nil = await r(['GET', 'b64:missing'], { headers: h });
295
+ return s.result === Buffer.from('hello').toString('base64')
296
+ && JSON.stringify(l.result) === JSON.stringify([Buffer.from('two').toString('base64'), Buffer.from('three').toString('base64')])
297
+ && n.result === 6 // an integer reply is NOT encoded
298
+ && nil.result === null;
299
+ })),
300
+ done('upstash.protocol.base64_ok_exemption', 'protocol', 'the base64 exemption is scoped to the simple-STATUS reply OK: SET answers a raw "OK", but a BULK STRING whose value is "OK" is encoded ("T0s="), and PING\'s status "PONG" IS encoded — the distinction the docs under-specify and the SDK\'s decoder silently mangles if a twin gets it wrong', 'api', 'common', () => withRoot(async (r) => {
301
+ const h = { ...AUTH, 'upstash-encoding': 'base64' };
302
+ const set = await r(['SET', 'okv', 'OK'], { headers: h });
303
+ const get = await r(['GET', 'okv'], { headers: h });
304
+ const ping = await r(['PING'], { headers: h });
305
+ return set.result === 'OK' && get.result === 'T0s=' && ping.result === Buffer.from('PONG').toString('base64');
306
+ })),
307
+ done('upstash.protocol.encoding_header_validated', 'protocol', 'an Upstash-Encoding value other than the exact lowercase "base64" -> 400 with the offending value quoted (LIVE-PROBED: the header is case-SENSITIVE, so "BASE64" is rejected)', 'api', 'niche', () => withRoot(async (r) => {
308
+ const bad = await r(['PING'], { headers: { ...AUTH, 'upstash-encoding': 'BASE64' } });
309
+ const hex = await r(['PING'], { headers: { ...AUTH, 'upstash-encoding': 'hex' } });
310
+ return bad.status === 400 && bad.error === 'ERR invalid Upstash-Encoding header: "BASE64"'
311
+ && hex.status === 400 && hex.error === 'ERR invalid Upstash-Encoding header: "hex"';
312
+ })),
313
+ done('upstash.protocol.command_error_is_http_400', 'protocol', 'a command-level failure answers HTTP 400 with {"error":…}, NOT 200 — which is what makes the SDK take its `!res.ok` branch and append its ", command was: […]" suffix to the thrown UpstashError', 'api', 'core', () => withRoot(async (r) => {
314
+ await r(['SET', 'e:s', 'abc']);
315
+ const bad = await r(['INCR', 'e:s']);
316
+ const ok = await r(['GET', 'e:s']);
317
+ return bad.status === 400 && bad.error === 'ERR value is not an integer or out of range' && bad.result === undefined
318
+ && ok.status === 200;
319
+ })),
320
+ done('upstash.protocol.sync_token_header', 'protocol', 'every 2xx carries an `upstash-sync-token` response header that ADVANCES on a write and is echoed back on a read; a garbage token degrades to "0" instead of erroring (the SDK sends a stale one by design — a documented off-by-one — so this must be permissive)', 'api', 'niche', () => withRoot(async (r) => {
321
+ const write = await r(['SET', 'st:k', 'v'], { headers: { ...AUTH, 'upstash-sync-token': '5' } });
322
+ const read = await r(['GET', 'st:k'], { headers: { ...AUTH, 'upstash-sync-token': '5' } });
323
+ const garbage = await r(['GET', 'st:k'], { headers: { ...AUTH, 'upstash-sync-token': 'zzz!!' } });
324
+ const absent = await r(['GET', 'st:k']);
325
+ return write.headers?.['upstash-sync-token'] === '6'
326
+ && read.headers?.['upstash-sync-token'] === '5'
327
+ && garbage.status === 200 && garbage.headers?.['upstash-sync-token'] === '0'
328
+ && absent.headers?.['upstash-sync-token'] === '0';
329
+ })),
330
+ done('upstash.protocol.method_and_cors', 'protocol', 'OPTIONS is a CORS preflight answering 200 with an empty body and the echoed origin (NOT the 405 the docs\' method list implies), while a genuinely disallowed method (DELETE) answers 405 with no body at all', 'api', 'niche', () => withRoot(async (r) => {
331
+ const preflight = await r.raw({ method: 'OPTIONS', path: '/', headers: { origin: 'https://app.example.test', 'access-control-request-headers': 'authorization' } });
332
+ const disallowed = await r.raw({ method: 'DELETE', path: '/get/k' });
333
+ return preflight.status === 200 && preflight.body === null
334
+ && preflight.headers?.['access-control-allow-origin'] === 'https://app.example.test'
335
+ && disallowed.status === 405 && disallowed.body === null;
336
+ })),
337
+ done('upstash.protocol.malformed_body_errors', 'protocol', 'the body-parse errors, verbatim: no body -> "EOF", a non-array -> "expected JSON array" (both WITHOUT an ERR prefix), [] -> "ERR empty command", a null arg -> "ERR null args are not supported", an object arg -> the vendor\'s json.Delim message', 'api', 'niche', () => withRoot(async (r) => {
338
+ const eof = await r.raw({ body: '' });
339
+ const notArray = await r.raw({ body: '{"not":"array"}' });
340
+ const empty = await r.raw({ body: '[]' });
341
+ const nullArg = await r.raw({ body: JSON.stringify(['set', 'k', null]) });
342
+ const objArg = await r.raw({ body: JSON.stringify(['set', 'k', { a: 1 }]) });
343
+ const err = (x) => x.body.error;
344
+ return err(eof) === 'EOF' && err(notArray) === 'expected JSON array' && err(empty) === 'ERR empty command'
345
+ && err(nullArg) === 'ERR null args are not supported'
346
+ && err(objArg) === 'ERR unsupported arg type: "{": json.Delim'
347
+ && [eof, notArray, empty, nullArg, objArg].every((x) => x.status === 400);
348
+ })),
349
+ done('upstash.protocol.max_request_size', 'protocol', 'a body above the documented 10 MB limit -> HTTP 413 with the vendor\'s max-request-size message (a status the REST docs do not even list — LIVE-PROBED)', 'api', 'niche', () => withRoot(async (r) => {
350
+ const huge = await r.raw({ body: `["SET","big","${'x'.repeat(10_485_800)}"]` });
351
+ return huge.status === 413 && huge.body.error.startsWith('ERR max request size exceeded. Limit: 10485760 bytes');
352
+ })),
353
+ done('upstash.protocol.json_arg_coercion', 'protocol', 'JSON numbers and booleans are accepted as command arguments and stringified (["setex","k",60,"v"] works) — the SDK emits TTL values as JSON numbers, so refusing them would break `set(k,v,{ex:60})`', 'api', 'common', () => withRoot(async (r) => {
354
+ const res = await r.raw({ body: JSON.stringify(['setex', 'ja:k', 60, 'v']) });
355
+ const ttl = await r(['TTL', 'ja:k']);
356
+ const bool = await r.raw({ body: JSON.stringify(['set', 'ja:b', true]) });
357
+ const got = await r(['GET', 'ja:b']);
358
+ return res.status === 200 && ttl.result === 60 && bool.status === 200 && got.result === 'true';
359
+ })),
360
+ // ── AUTH ────────────────────────────────────────────────────────────────────────────────────
361
+ done('upstash.auth.missing_token_401', 'auth', 'a missing/blank credential -> HTTP 401 with the vendor\'s LITERAL body: "WRONGPASS invalid or missing auth token. See https://docs.upstash.com/redis/troubleshooting/http_unauthorized for details." — and NO upstash-sync-token header', 'api', 'core', () => withRoot(async (r) => {
362
+ const none = await r.raw({ body: JSON.stringify(['PING']), headers: {} });
363
+ const blank = await r.raw({ body: JSON.stringify(['PING']), headers: { authorization: 'Bearer ' } });
364
+ const expected = 'WRONGPASS invalid or missing auth token. See https://docs.upstash.com/redis/troubleshooting/http_unauthorized for details.';
365
+ return none.status === 401 && none.body.error === expected
366
+ && blank.status === 401 && blank.body.error === expected
367
+ && none.headers?.['upstash-sync-token'] === undefined;
368
+ })),
369
+ done('upstash.auth.bearer_and_bare_header', 'auth', 'both `Authorization: Bearer <token>` (documented) and a BARE `Authorization: <token>` (undocumented but LIVE-PROBED as accepted) are honoured', 'api', 'core', () => withRoot(async (r) => {
370
+ const bearer = await r.raw({ body: JSON.stringify(['SET', 'a:k', 'v']), headers: { authorization: 'Bearer some-token' } });
371
+ const bare = await r.raw({ body: JSON.stringify(['GET', 'a:k']), headers: { authorization: 'some-token' } });
372
+ return bearer.status === 200 && bare.status === 200 && bare.body.result === 'v';
373
+ })),
374
+ done('upstash.auth.query_token_wins', 'auth', 'the documented `?_token=` query parameter authenticates, is NOT passed through as a command argument, and OVERRIDES a bogus Authorization header (LIVE-PROBED precedence)', 'api', 'common', () => withRoot(async (r) => {
375
+ const set = await r.raw({ method: 'GET', path: '/set/qt:k/qv?_token=any-token', headers: {} });
376
+ const overridden = await r.raw({ method: 'GET', path: '/get/qt:k?_token=any-token', headers: { authorization: 'Bearer bogus' } });
377
+ return set.status === 200 && overridden.status === 200 && overridden.body.result === 'qv';
378
+ })),
379
+ done('upstash.auth.configured_token_enforced', 'auth', 'when the twin is started with an explicit token, a DIFFERENT credential is refused with the same 401 while the right one succeeds — so a world can model a real credential rather than accepting anything', 'api', 'common', () => withRoot(async (r) => {
380
+ const wrong = await r.raw({ body: JSON.stringify(['PING']), headers: { authorization: 'Bearer nope' }, token: 'the-real-token' });
381
+ const right = await r.raw({ body: JSON.stringify(['PING']), headers: { authorization: 'Bearer the-real-token' }, token: 'the-real-token' });
382
+ return wrong.status === 401 && right.status === 200 && right.body.result === 'PONG';
383
+ })),
384
+ // ── STRINGS ─────────────────────────────────────────────────────────────────────────────────
385
+ done('upstash.strings.set_get', 'strings', 'SET/GET round-trip the exact bytes; GET of a missing key -> nil', 'api', 'core', () => withRoot(async (r) => {
386
+ await r(['SET', 's:k', '{"json":"payload"}']);
387
+ const got = await r(['GET', 's:k']);
388
+ const missing = await r(['GET', 's:none']);
389
+ return got.result === '{"json":"payload"}' && missing.result === null && missing.status === 200;
390
+ })),
391
+ done('upstash.strings.set_expiry_options', 'strings', 'SET … EX/PX/EXAT/PXAT set a TTL and KEEPTTL preserves one across an overwrite (a plain SET CLEARS it); option tokens are parsed case-insensitively, which the SDK requires since it emits `keepTtl` in camelCase', 'api', 'core', () => withRoot(async (r) => {
392
+ await r(['SET', 's:ex', 'v', 'EX', '100']);
393
+ const ex = await r(['TTL', 's:ex']);
394
+ await r(['SET', 's:px', 'v', 'px', '5000']);
395
+ const px = await r(['PTTL', 's:px']);
396
+ await r(['SET', 's:ex', 'v2', 'keepTtl']);
397
+ const kept = await r(['TTL', 's:ex']);
398
+ await r(['SET', 's:ex', 'v3']);
399
+ const cleared = await r(['TTL', 's:ex']);
400
+ return ex.result === 100 && px.result === 5000 && kept.result === 100 && cleared.result === -1;
401
+ })),
402
+ done('upstash.strings.set_nx_returns_nil', 'strings', 'SET … NX on an EXISTING key answers nil (NOT "OK") and leaves the value alone; XX on a MISSING key does the same — the branch every distributed lock and dedupe guard in the real consumer depends on', 'api', 'core', () => withRoot(async (r) => {
403
+ const first = await r(['SET', 's:lock', 'a', 'NX', 'EX', '60']);
404
+ const second = await r(['SET', 's:lock', 'b', 'NX', 'EX', '60']);
405
+ const value = await r(['GET', 's:lock']);
406
+ const xxMissing = await r(['SET', 's:absent', 'z', 'XX']);
407
+ return first.result === 'OK' && second.result === null && value.result === 'a' && xxMissing.result === null;
408
+ })),
409
+ done('upstash.strings.set_get_option', 'strings', 'SET … GET answers the PREVIOUS value (nil when there was none) while still applying the write, and raises WRONGTYPE when the old key held a non-string', 'api', 'niche', () => withRoot(async (r) => {
410
+ const firstWrite = await r(['SET', 's:g', 'one', 'GET']);
411
+ const secondWrite = await r(['SET', 's:g', 'two', 'GET']);
412
+ const now = await r(['GET', 's:g']);
413
+ await r(['RPUSH', 's:gl', 'x']);
414
+ const wrong = await r(['SET', 's:gl', 'y', 'GET']);
415
+ return firstWrite.result === null && secondWrite.result === 'one' && now.result === 'two'
416
+ && wrong.status === 400 && wrong.error === 'WRONGTYPE Operation against a key holding the wrong kind of value';
417
+ })),
418
+ done('upstash.strings.setex_setnx_getset_getdel', 'strings', 'SETEX/PSETEX set value+TTL atomically, SETNX answers 0/1, GETSET returns the old value, and GETDEL consumes the key in one round trip', 'api', 'common', () => withRoot(async (r) => {
419
+ await r(['SETEX', 's:e', '30', 'v']);
420
+ const ttl = await r(['TTL', 's:e']);
421
+ const nx1 = await r(['SETNX', 's:n', 'first']);
422
+ const nx2 = await r(['SETNX', 's:n', 'second']);
423
+ const gs = await r(['GETSET', 's:n', 'third']);
424
+ const gd = await r(['GETDEL', 's:n']);
425
+ const after = await r(['GET', 's:n']);
426
+ return ttl.result === 30 && nx1.result === 1 && nx2.result === 0 && gs.result === 'first' && gd.result === 'third' && after.result === null;
427
+ })),
428
+ done('upstash.strings.mget_mset', 'strings', 'MGET answers a positionally-aligned array with nil in the holes (never a compacted list), and MSET writes every pair', 'api', 'core', () => withRoot(async (r) => {
429
+ await r(['MSET', 'm:a', '1', 'm:c', '3']);
430
+ const got = await r(['MGET', 'm:a', 'm:b', 'm:c']);
431
+ return JSON.stringify(got.result) === JSON.stringify(['1', null, '3']);
432
+ })),
433
+ done('upstash.strings.append_strlen_getex', 'strings', 'APPEND concatenates and returns the new length, STRLEN measures it, and GETEX reads the value while (re)setting or clearing its TTL', 'api', 'niche', () => withRoot(async (r) => {
434
+ const a1 = await r(['APPEND', 's:ap', 'foo']);
435
+ const a2 = await r(['APPEND', 's:ap', 'bar']);
436
+ const len = await r(['STRLEN', 's:ap']);
437
+ const gx = await r(['GETEX', 's:ap', 'EX', '50']);
438
+ const ttl = await r(['TTL', 's:ap']);
439
+ const persisted = await r(['GETEX', 's:ap', 'PERSIST']);
440
+ const noTtl = await r(['TTL', 's:ap']);
441
+ return a1.result === 3 && a2.result === 6 && len.result === 6 && gx.result === 'foobar' && ttl.result === 50 && persisted.result === 'foobar' && noTtl.result === -1;
442
+ })),
443
+ // ── COUNTERS ────────────────────────────────────────────────────────────────────────────────
444
+ done('upstash.counters.incr_decr', 'counters', 'INCR/DECR/INCRBY/DECRBY treat a missing key as 0 and answer the NEW integer value', 'api', 'core', () => withRoot(async (r) => {
445
+ const i1 = await r(['INCR', 'c:n']);
446
+ const i2 = await r(['INCRBY', 'c:n', '9']);
447
+ const d1 = await r(['DECR', 'c:n']);
448
+ const d2 = await r(['DECRBY', 'c:n', '4']);
449
+ const stored = await r(['GET', 'c:n']);
450
+ return i1.result === 1 && i2.result === 10 && d1.result === 9 && d2.result === 5 && stored.result === '5';
451
+ })),
452
+ done('upstash.counters.non_integer_error', 'counters', 'INCR against a non-numeric string -> 400 "ERR value is not an integer or out of range" (the live server\'s wording, which differs from the docs\' own stale "not an int" example), and the value is UNCHANGED', 'api', 'core', () => withRoot(async (r) => {
453
+ await r(['SET', 'c:s', 'abc']);
454
+ const bad = await r(['INCR', 'c:s']);
455
+ const unchanged = await r(['GET', 'c:s']);
456
+ return bad.status === 400 && bad.error === 'ERR value is not an integer or out of range' && unchanged.result === 'abc';
457
+ })),
458
+ done('upstash.counters.incr_preserves_ttl', 'counters', 'INCR does NOT clear an existing TTL — the exact semantic @upstash/ratelimit\'s fixed-window script relies on (it arms PEXPIRE once on the first increment and every later one must leave the deadline alone, or the window would never close)', 'api', 'core', () => withRoot(async (r) => {
459
+ await r(['SET', 'c:t', '1', 'EX', '60']);
460
+ await r(['INCR', 'c:t']);
461
+ await r(['INCRBY', 'c:t', '5']);
462
+ const ttl = await r(['TTL', 'c:t']);
463
+ const value = await r(['GET', 'c:t']);
464
+ return ttl.result === 60 && value.result === '7';
465
+ })),
466
+ done('upstash.counters.incrbyfloat', 'counters', 'INCRBYFLOAT and HINCRBYFLOAT answer a bulk STRING, reject a non-float, and REFUSE a result that would be NaN or Infinity with Redis\'s own "ERR increment would produce NaN or Infinity" — the guard §9 round 2 found missing on these two paths, where the twin was answering 200 and then persisting the literal "NaN" into a key its own next INCRBYFLOAT could no longer read', 'api', 'common', () => withRoot(async (r) => {
467
+ const f = await r(['INCRBYFLOAT', 'c:f', '1.5']);
468
+ const f2 = await r(['INCRBYFLOAT', 'c:f', '2.25']);
469
+ const bad = await r(['INCRBYFLOAT', 'c:f', 'nope']);
470
+ // The NaN/Infinity guard, on BOTH float-increment paths, and on both routes to it.
471
+ const toInf = await r(['INCRBYFLOAT', 'c:f', 'inf']);
472
+ const overflow = await r(['INCRBYFLOAT', 'c:big', '1e308']);
473
+ const overflow2 = await r(['INCRBYFLOAT', 'c:big', '1e308']);
474
+ const hashInf = await r(['HINCRBYFLOAT', 'c:h', 'fld', 'inf']);
475
+ // …and the key is UNCHANGED by every refusal, not left holding a poisoned value.
476
+ const intact = await r(['GET', 'c:f']);
477
+ const hashAbsent = await r(['EXISTS', 'c:h']);
478
+ const NAN_ERR = 'ERR increment would produce NaN or Infinity';
479
+ return f.result === '1.5' && f2.result === '3.75'
480
+ && bad.status === 400 && bad.error === 'ERR value is not a valid float'
481
+ && toInf.status === 400 && toInf.error === NAN_ERR
482
+ && overflow.result === '1e+308' && overflow2.status === 400 && overflow2.error === NAN_ERR
483
+ && hashInf.status === 400 && hashInf.error === NAN_ERR
484
+ && intact.result === '3.75' && hashAbsent.result === 0;
485
+ })),
486
+ // ── KEYSPACE / TTL ──────────────────────────────────────────────────────────────────────────
487
+ done('upstash.keyspace.del_exists', 'keyspace', 'DEL removes N keys and answers how many EXISTED; EXISTS counts them (both variadic)', 'api', 'core', () => withRoot(async (r) => {
488
+ await r(['MSET', 'k:a', '1', 'k:b', '2']);
489
+ const exists = await r(['EXISTS', 'k:a', 'k:b', 'k:missing']);
490
+ const del = await r(['DEL', 'k:a', 'k:missing']);
491
+ const after = await r(['EXISTS', 'k:a']);
492
+ return exists.result === 2 && del.result === 1 && after.result === 0;
493
+ })),
494
+ done('upstash.keyspace.expire_ttl_persist', 'keyspace', 'EXPIRE/PEXPIRE/EXPIREAT/PEXPIREAT set a deadline (0 on a missing key), TTL/PTTL report it (-1 no TTL, -2 no key), PERSIST clears it', 'api', 'core', () => withRoot(async (r) => {
495
+ await r(['SET', 'k:t', 'v']);
496
+ const noTtl = await r(['TTL', 'k:t']);
497
+ const set = await r(['EXPIRE', 'k:t', '120']);
498
+ const ttl = await r(['TTL', 'k:t']);
499
+ const missing = await r(['EXPIRE', 'k:none', '10']);
500
+ const gone = await r(['TTL', 'k:none']);
501
+ const persist = await r(['PERSIST', 'k:t']);
502
+ const cleared = await r(['TTL', 'k:t']);
503
+ return noTtl.result === -1 && set.result === 1 && ttl.result === 120 && missing.result === 0 && gone.result === -2 && persist.result === 1 && cleared.result === -1;
504
+ })),
505
+ done('upstash.keyspace.lazy_expiry', 'keyspace', 'once the request clock passes a key\'s deadline the key stops existing for EVERY read path at once — GET, EXISTS, TTL, TYPE, KEYS and DBSIZE all agree — and a past deadline passed to EXPIRE deletes the key immediately', 'api', 'core', () => withRoot(async (r) => {
506
+ await r(['SET', 'x:soon', 'v', 'EX', '10']);
507
+ await r(['SET', 'x:stay', 'v']);
508
+ const before = await r(['GET', 'x:soon']);
509
+ const at = laterAt(11_000);
510
+ const [get, exists, ttl, type, keys, dbsize] = await Promise.all([
511
+ r(['GET', 'x:soon'], { at }), r(['EXISTS', 'x:soon'], { at }), r(['TTL', 'x:soon'], { at }),
512
+ r(['TYPE', 'x:soon'], { at }), r(['KEYS', 'x:*'], { at }), r(['DBSIZE'], { at }),
513
+ ]);
514
+ const past = await r(['EXPIRE', 'x:stay', '-1']);
515
+ const stayGone = await r(['EXISTS', 'x:stay']);
516
+ return before.result === 'v' && get.result === null && exists.result === 0 && ttl.result === -2
517
+ && type.result === 'none' && JSON.stringify(keys.result) === JSON.stringify(['x:stay']) && dbsize.result === 1
518
+ && past.result === 1 && stayGone.result === 0;
519
+ })),
520
+ done('upstash.keyspace.past_deadline_within_one_request', 'keyspace', 'a deadline set INTO THE PAST takes effect immediately — even for the rest of the SAME pipeline. §9 round 2 found expiry being applied only when the request snapshot was built, so SET k v PXAT 1 stayed readable for every later command in that batch and TTL answered a huge negative number real Redis can never return; expiry is now re-checked on every read', 'api', 'common', () => withRoot(async (r) => {
521
+ // ONE batch: the write and every read that follows it are in the same request.
522
+ const res = await r.raw({ path: '/pipeline', body: JSON.stringify([
523
+ ['SET', 'past', 'v', 'PXAT', '1'],
524
+ ['GET', 'past'], ['EXISTS', 'past'], ['TTL', 'past'], ['TYPE', 'past'], ['DBSIZE'],
525
+ ['SET', 'live', 'v'], ['GETEX', 'live', 'EXAT', '1'], ['GET', 'live'], ['EXISTS', 'live'],
526
+ ]) });
527
+ const items = res.body.map((i) => i.result);
528
+ // GET nil, EXISTS 0, TTL -2 (never a large negative), TYPE none, DBSIZE 0 — all within the batch.
529
+ return res.status === 200
530
+ && items[1] === null && items[2] === 0 && items[3] === -2 && items[4] === 'none' && items[5] === 0
531
+ && items[8] === null && items[9] === 0;
532
+ })),
533
+ done('upstash.keyspace.type_and_wrongtype', 'keyspace', 'TYPE names each of the six value types (and "none" for a missing key), and a command aimed at the wrong type raises the vendor\'s literal WRONGTYPE error', 'api', 'core', () => withRoot(async (r) => {
534
+ await r(['SET', 't:s', 'v']);
535
+ await r(['RPUSH', 't:l', 'a']);
536
+ await r(['SADD', 't:se', 'a']);
537
+ await r(['HSET', 't:h', 'f', 'v']);
538
+ await r(['ZADD', 't:z', '1', 'a']);
539
+ await r(['XADD', 't:x', '*', 'f', 'v']);
540
+ const types = await Promise.all(['t:s', 't:l', 't:se', 't:h', 't:z', 't:x', 't:none'].map((k) => r(['TYPE', k])));
541
+ const wrong = await r(['GET', 't:l']);
542
+ const wrong2 = await r(['SADD', 't:s', 'x']);
543
+ return JSON.stringify(types.map((t) => t.result)) === JSON.stringify(['string', 'list', 'set', 'hash', 'zset', 'stream', 'none'])
544
+ && wrong.status === 400 && wrong.error === 'WRONGTYPE Operation against a key holding the wrong kind of value'
545
+ && wrong2.status === 400 && wrong2.error === 'WRONGTYPE Operation against a key holding the wrong kind of value';
546
+ })),
547
+ done('upstash.keyspace.rename', 'keyspace', 'RENAME moves the value AND its TTL and answers OK; renaming a missing key -> 400 "ERR no such key"; RENAMENX refuses to clobber an existing destination', 'api', 'common', () => withRoot(async (r) => {
548
+ await r(['SET', 'r:old', 'v', 'EX', '90']);
549
+ const ok = await r(['RENAME', 'r:old', 'r:new']);
550
+ const value = await r(['GET', 'r:new']);
551
+ const ttl = await r(['TTL', 'r:new']);
552
+ const oldGone = await r(['EXISTS', 'r:old']);
553
+ const missing = await r(['RENAME', 'r:nope', 'r:x']);
554
+ await r(['SET', 'r:taken', 'other']);
555
+ const nx = await r(['RENAMENX', 'r:new', 'r:taken']);
556
+ return ok.result === 'OK' && value.result === 'v' && ttl.result === 90 && oldGone.result === 0
557
+ && missing.status === 400 && missing.error === 'ERR no such key' && nx.result === 0;
558
+ })),
559
+ done('upstash.keyspace.keys_and_dbsize', 'keyspace', 'KEYS applies Redis GLOB matching (*, ?, [abc]) and DBSIZE counts live keys — and neither ever leaks the twin\'s internal EVAL script-cache entries into the keyspace', 'api', 'common', () => withRoot(async (r) => {
560
+ await r(['MSET', 'user:1', 'a', 'user:2', 'b', 'other:1', 'c']);
561
+ await r(['EVAL', 'return 1', '0']); // populates the internal script cache
562
+ const star = await r(['KEYS', 'user:*']);
563
+ const single = await r(['KEYS', 'user:?']);
564
+ const cls = await r(['KEYS', '[ou]ther:1']);
565
+ const size = await r(['DBSIZE']);
566
+ const all = await r(['KEYS', '*']);
567
+ return JSON.stringify(star.result) === JSON.stringify(['user:1', 'user:2'])
568
+ && JSON.stringify(single.result) === JSON.stringify(['user:1', 'user:2'])
569
+ && JSON.stringify(cls.result) === JSON.stringify(['other:1'])
570
+ && size.result === 3 && all.result.length === 3;
571
+ })),
572
+ done('upstash.keyspace.scan', 'keyspace', 'SCAN answers the two-element [cursor, keys] tuple the SDK destructures, honours MATCH/COUNT/TYPE, and terminates by returning cursor "0" after covering every key exactly once', 'api', 'common', () => withRoot(async (r) => {
573
+ await r(['MSET', 'sc:1', 'a', 'sc:2', 'b', 'sc:3', 'c', 'sc:4', 'd']);
574
+ await r(['RPUSH', 'sc:list', 'x']);
575
+ const seen = [];
576
+ let cursor = '0';
577
+ let guard = 0;
578
+ do {
579
+ const page = await r(['SCAN', cursor, 'MATCH', 'sc:*', 'COUNT', '2']);
580
+ const [next, keys] = page.result;
581
+ seen.push(...keys);
582
+ cursor = next;
583
+ } while (cursor !== '0' && ++guard < 20);
584
+ const typed = await r(['SCAN', '0', 'MATCH', 'sc:*', 'COUNT', '100', 'TYPE', 'list']);
585
+ return seen.sort().join(',') === 'sc:1,sc:2,sc:3,sc:4,sc:list'
586
+ && JSON.stringify(typed.result[1]) === JSON.stringify(['sc:list']);
587
+ })),
588
+ done('upstash.keyspace.scan_survives_mutation', 'keyspace', 'DIRTY-STATE: SCAN honours Redis\'s ONE cursor guarantee — a key present for the WHOLE iteration is returned at least once even as other keys are DELETED between pages. The §9 round-1 refutation killed the first design (an index into a sorted list, which shifts left under the cursor when a lower-sorted key goes, silently skipping a survivor); the cursor is now a position in a stable KEY-DERIVED order, so no key\'s position moves when a different key does', 'api', 'core', () => withRoot(async (r) => {
589
+ const names = Array.from({ length: 12 }, (_, i) => `walk:${i}`);
590
+ await r(['MSET', ...names.flatMap((n) => [n, '1'])]);
591
+ const seen = new Set();
592
+ let cursor = '0';
593
+ let deleted = 0;
594
+ let guard = 0;
595
+ do {
596
+ const page = await r(['SCAN', cursor, 'COUNT', '3']);
597
+ const [next, keys] = page.result;
598
+ keys.forEach((k) => seen.add(k));
599
+ // Mutate BETWEEN pages — the whole point. A fresh-keyspace scan cannot see this class.
600
+ if (deleted < 4) {
601
+ await r(['DEL', names[deleted]]);
602
+ deleted++;
603
+ }
604
+ cursor = next;
605
+ } while (cursor !== '0' && ++guard < 60);
606
+ // Every key that was present for the entire scan must have been returned.
607
+ const survivors = names.slice(4);
608
+ return cursor === '0' && survivors.every((n) => seen.has(n));
609
+ })),
610
+ done('upstash.keyspace.flushdb', 'keyspace', 'FLUSHDB empties the whole keyspace and answers OK; DBSIZE then reads 0', 'api', 'niche', () => withRoot(async (r) => {
611
+ await r(['MSET', 'f:a', '1', 'f:b', '2']);
612
+ const flush = await r(['FLUSHDB']);
613
+ const size = await r(['DBSIZE']);
614
+ const gone = await r(['GET', 'f:a']);
615
+ return flush.result === 'OK' && size.result === 0 && gone.result === null;
616
+ })),
617
+ done('upstash.keyspace.dirty_state_delete_recreate', 'keyspace', 'DIRTY-STATE: a key deleted and RECREATED carries none of its former life — no stale TTL, no stale type, no resurrected members — and an emptied collection (last member removed) genuinely stops existing rather than lingering as an empty husk', 'api', 'core', () => withRoot(async (r) => {
618
+ // Build up real state first: this verify is worthless from a fresh key.
619
+ await r(['SET', 'd:k', 'first', 'EX', '60']);
620
+ await r(['DEL', 'd:k']);
621
+ await r(['RPUSH', 'd:k', 'now-a-list']); // recreated as a DIFFERENT type
622
+ const type = await r(['TYPE', 'd:k']);
623
+ const ttl = await r(['TTL', 'd:k']); // must be -1, not the old 60
624
+ // An emptied collection must vanish, not linger — otherwise a later SET would WRONGTYPE.
625
+ await r(['SADD', 'd:s', 'only']);
626
+ await r(['SREM', 'd:s', 'only']);
627
+ const emptied = await r(['EXISTS', 'd:s']);
628
+ const reuse = await r(['SET', 'd:s', 'plain-string-now']);
629
+ await r(['HSET', 'd:h', 'f', 'v']);
630
+ await r(['HDEL', 'd:h', 'f']);
631
+ const hashGone = await r(['EXISTS', 'd:h']);
632
+ return type.result === 'list' && ttl.result === -1 && emptied.result === 0 && reuse.result === 'OK' && hashGone.result === 0;
633
+ })),
634
+ done('upstash.keyspace.no_internal_namespace_collision', 'keyspace', 'a caller\'s key can never collide with the twin\'s own internals: the EVAL script cache lives in a SEPARATE kernel subject type, so a key named like a cache entry stays a normal, listable key and is NOT executable as a script (§9 self-audit found the earlier magic-prefix design hid such a key from KEYS/SCAN/DBSIZE and let EVALSHA run it)', 'api', 'core', () => withRoot(async (r) => {
635
+ const sneaky = ' twin:script:deadbeef'; // a legal Redis key — a leading space is fine
636
+ await r(['SET', sneaky, 'return 999']);
637
+ const keys = await r(['KEYS', '*']);
638
+ const size = await r(['DBSIZE']);
639
+ const readable = await r(['GET', sneaky]);
640
+ const asScript = await r(['EVALSHA', 'deadbeef', '0']);
641
+ // …and the REAL cache still works, in its own namespace, without becoming a key.
642
+ const load = await r(['SCRIPT', 'LOAD', 'return 7']);
643
+ const ran = await r(['EVALSHA', load.result, '0']);
644
+ const sizeAfter = await r(['DBSIZE']);
645
+ return JSON.stringify(keys.result) === JSON.stringify([sneaky]) && size.result === 1
646
+ && readable.result === 'return 999'
647
+ && asScript.status === 400 && asScript.error === 'NOSCRIPT No matching script. Please use EVAL.'
648
+ && ran.result === 7 && sizeAfter.result === 1;
649
+ })),
650
+ done('upstash.keyspace.reply_and_state_never_disagree', 'keyspace', 'DIRTY-STATE: writing a key back to a value it ALREADY HELD sticks — the reply and the stored state can never disagree. The kernel dedupes actions by (content + occurredAt millisecond), so a pinned-clock sequence that returns a key to an earlier value used to be silently dropped as a replay: the command answered the new value while the projection kept the old one. Every write now carries a per-key ordinal, so no write can be mistaken for a replay', 'api', 'core', () => withRoot(async (r) => {
651
+ // (a) the shape that found it: a ZADD INCR walk that lands back on a score it held before.
652
+ await r(['ZADD', 'w', '10', 'm']);
653
+ const steps = [
654
+ [['ZADD', 'w', 'INCR', '5', 'm'], '15'],
655
+ [['ZADD', 'w', 'XX', 'INCR', '5', 'm'], '20'],
656
+ [['ZADD', 'w', 'GT', 'INCR', '5', 'm'], '25'],
657
+ [['ZADD', 'w', 'LT', 'INCR', '-5', 'm'], '20'], // back to a score already seen at this instant
658
+ ];
659
+ for (const [argv, expected] of steps) {
660
+ const reply = await r(argv);
661
+ const stored = await r(['ZSCORE', 'w', 'm']);
662
+ // THE assertion: what the command SAID and what the twin KEPT must be the same thing.
663
+ if (reply.result !== expected || stored.result !== expected)
664
+ return false;
665
+ }
666
+ // (b) the plain-string shape of the same hazard, and (c) delete-then-recreate-identical.
667
+ await r(['SET', 'k', 'a']);
668
+ await r(['SET', 'k', 'b']);
669
+ await r(['SET', 'k', 'a']);
670
+ const back = await r(['GET', 'k']);
671
+ await r(['DEL', 'k']);
672
+ await r(['SET', 'k', 'a']);
673
+ const recreated = await r(['GET', 'k']);
674
+ return back.result === 'a' && recreated.result === 'a';
675
+ })),
676
+ done('upstash.keyspace.persistence_across_restart', 'keyspace', 'state is the KERNEL ACTION LOG, not process memory: a value written through one handler invocation is readable by a completely independent one pointed at the same root — which is what makes this a twin rather than a mock', 'api', 'core', () => withRoot(async (r, root) => {
677
+ await r(['SET', 'persist:k', 'survives']);
678
+ await r(['HSET', 'persist:h', 'f', 'hv']);
679
+ // A brand-new call with no shared in-process state whatsoever — only the root on disk.
680
+ const fresh = await handleUpstashRedisTwinRequest({ method: 'POST', path: '/', headers: AUTH, root, occurredAt: AT, body: JSON.stringify(['GET', 'persist:k']) });
681
+ const freshHash = await handleUpstashRedisTwinRequest({ method: 'POST', path: '/', headers: AUTH, root, occurredAt: AT, body: JSON.stringify(['HGET', 'persist:h', 'f']) });
682
+ return fresh.body.result === 'survives' && freshHash.body.result === 'hv';
683
+ })),
684
+ // ── HASHES ──────────────────────────────────────────────────────────────────────────────────
685
+ done('upstash.hashes.set_get', 'hashes', 'HSET answers how many fields were NEW (0 on an overwrite), HGET reads one field, and a missing field/key answers nil', 'api', 'core', () => withRoot(async (r) => {
686
+ const first = await r(['HSET', 'h:k', 'a', '1', 'b', '2']);
687
+ const overwrite = await r(['HSET', 'h:k', 'a', '9']);
688
+ const got = await r(['HGET', 'h:k', 'a']);
689
+ const missingField = await r(['HGET', 'h:k', 'zz']);
690
+ const missingKey = await r(['HGET', 'h:none', 'a']);
691
+ return first.result === 2 && overwrite.result === 0 && got.result === '9' && missingField.result === null && missingKey.result === null;
692
+ })),
693
+ done('upstash.hashes.getall_mget_keys_vals', 'hashes', 'HGETALL answers the FLAT [field, value, …] array the SDK folds into an object, HMGET answers positional nils for absent fields, and HKEYS/HVALS/HLEN agree with it', 'api', 'core', () => withRoot(async (r) => {
694
+ await r(['HSET', 'h:m', 'x', '1', 'y', '2']);
695
+ const all = await r(['HGETALL', 'h:m']);
696
+ const mget = await r(['HMGET', 'h:m', 'x', 'nope', 'y']);
697
+ const keys = await r(['HKEYS', 'h:m']);
698
+ const vals = await r(['HVALS', 'h:m']);
699
+ const len = await r(['HLEN', 'h:m']);
700
+ return JSON.stringify(all.result) === JSON.stringify(['x', '1', 'y', '2'])
701
+ && JSON.stringify(mget.result) === JSON.stringify(['1', null, '2'])
702
+ && JSON.stringify(keys.result) === JSON.stringify(['x', 'y'])
703
+ && JSON.stringify(vals.result) === JSON.stringify(['1', '2']) && len.result === 2;
704
+ })),
705
+ done('upstash.hashes.del_exists_incrby', 'hashes', 'HDEL answers how many fields it removed and DROPS the key once the last one goes, HEXISTS reports membership, and HINCRBY/HINCRBYFLOAT do arithmetic on a field (rejecting a non-numeric one)', 'api', 'common', () => withRoot(async (r) => {
706
+ await r(['HSET', 'h:d', 'a', '1', 'b', 'notanum']);
707
+ const exists = await r(['HEXISTS', 'h:d', 'a']);
708
+ const inc = await r(['HINCRBY', 'h:d', 'a', '4']);
709
+ const incFloat = await r(['HINCRBYFLOAT', 'h:d', 'c', '2.5']);
710
+ const bad = await r(['HINCRBY', 'h:d', 'b', '1']);
711
+ const del = await r(['HDEL', 'h:d', 'a', 'b', 'c', 'missing']);
712
+ const keyGone = await r(['EXISTS', 'h:d']);
713
+ return exists.result === 1 && inc.result === 5 && incFloat.result === '2.5'
714
+ && bad.status === 400 && bad.error === 'ERR value is not an integer or out of range'
715
+ && del.result === 3 && keyGone.result === 0;
716
+ })),
717
+ // ── SETS ────────────────────────────────────────────────────────────────────────────────────
718
+ done('upstash.sets.add_members', 'sets', 'SADD answers how many members were NEW (deduping silently), SMEMBERS lists them and SCARD counts them', 'api', 'core', () => withRoot(async (r) => {
719
+ const first = await r(['SADD', 'se:k', 'a', 'b']);
720
+ const dup = await r(['SADD', 'se:k', 'b', 'c']);
721
+ const members = await r(['SMEMBERS', 'se:k']);
722
+ const card = await r(['SCARD', 'se:k']);
723
+ return first.result === 2 && dup.result === 1 && members.result.sort().join(',') === 'a,b,c' && card.result === 3;
724
+ })),
725
+ done('upstash.sets.membership', 'sets', 'SISMEMBER answers 0/1 and SMISMEMBER answers a positionally-aligned 0/1 array — the batch form a real consumer uses to check many ids in one round trip', 'api', 'core', () => withRoot(async (r) => {
726
+ await r(['SADD', 'se:m', 'x', 'y']);
727
+ const yes = await r(['SISMEMBER', 'se:m', 'x']);
728
+ const no = await r(['SISMEMBER', 'se:m', 'zz']);
729
+ const batch = await r(['SMISMEMBER', 'se:m', 'x', 'zz', 'y']);
730
+ return yes.result === 1 && no.result === 0 && JSON.stringify(batch.result) === JSON.stringify([1, 0, 1]);
731
+ })),
732
+ done('upstash.sets.remove', 'sets', 'SREM answers how many members it actually removed and the key disappears once the set is empty', 'api', 'common', () => withRoot(async (r) => {
733
+ await r(['SADD', 'se:r', 'a', 'b']);
734
+ const some = await r(['SREM', 'se:r', 'a', 'never-there']);
735
+ const rest = await r(['SREM', 'se:r', 'b']);
736
+ const gone = await r(['EXISTS', 'se:r']);
737
+ return some.result === 1 && rest.result === 1 && gone.result === 0;
738
+ })),
739
+ // ── SORTED SETS ─────────────────────────────────────────────────────────────────────────────
740
+ done('upstash.sorted_sets.add_and_range', 'sorted_sets', 'ZADD answers how many members were NEW, ZRANGE walks in (score, member) order, WITHSCORES interleaves scores as bulk strings, and REV reverses', 'api', 'core', () => withRoot(async (r) => {
741
+ const add = await r(['ZADD', 'z:k', '2', 'b', '1', 'a', '3', 'c']);
742
+ const range = await r(['ZRANGE', 'z:k', '0', '-1']);
743
+ const scores = await r(['ZRANGE', 'z:k', '0', '-1', 'WITHSCORES']);
744
+ const _rev = await r(['ZREVRANGE', 'z:k', '0', '-1']);
745
+ const slice = await r(['ZRANGE', 'z:k', '1', '1']);
746
+ return add.result === 3
747
+ && JSON.stringify(range.result) === JSON.stringify(['a', 'b', 'c'])
748
+ && JSON.stringify(scores.result) === JSON.stringify(['a', '1', 'b', '2', 'c', '3'])
749
+ && JSON.stringify(_rev.result) === JSON.stringify(['c', 'b', 'a'])
750
+ && JSON.stringify(slice.result) === JSON.stringify(['b']);
751
+ })),
752
+ done('upstash.sorted_sets.add_flags', 'sorted_sets', 'ZADD NX/XX/GT/LT/CH behave as Redis specifies (NX never updates, XX never inserts, GT/LT only move a score in one direction, CH counts CHANGED rather than added), and NX+XX together is refused', 'api', 'common', () => withRoot(async (r) => {
753
+ await r(['ZADD', 'z:f', '5', 'm']);
754
+ await r(['ZADD', 'z:f', 'NX', '1', 'm']);
755
+ const afterNx = await r(['ZSCORE', 'z:f', 'm']);
756
+ const xxMissing = await r(['ZADD', 'z:f', 'XX', '1', 'absent']);
757
+ await r(['ZADD', 'z:f', 'GT', '3', 'm']); // 3 < 5 → no move
758
+ const afterGt = await r(['ZSCORE', 'z:f', 'm']);
759
+ await r(['ZADD', 'z:f', 'GT', '9', 'm']); // 9 > 5 → moves
760
+ const afterGt2 = await r(['ZSCORE', 'z:f', 'm']);
761
+ const ch = await r(['ZADD', 'z:f', 'CH', '9', 'm', '1', 'fresh']);
762
+ const conflict = await r(['ZADD', 'z:f', 'NX', 'XX', '1', 'q']);
763
+ return afterNx.result === '5' && xxMissing.result === 0 && afterGt.result === '5' && afterGt2.result === '9'
764
+ && ch.result === 1 && conflict.status === 400 && conflict.error === 'ERR XX and NX options at the same time are not compatible';
765
+ })),
766
+ done('upstash.sorted_sets.score_ops', 'sorted_sets', 'ZSCORE answers a bulk string (nil when absent), ZINCRBY moves a score and RE-SORTS the set, ZCARD counts, and ZRANK/ZREVRANK report position', 'api', 'core', () => withRoot(async (r) => {
767
+ await r(['ZADD', 'z:s', '1', 'a', '2', 'b']);
768
+ const score = await r(['ZSCORE', 'z:s', 'b']);
769
+ const absent = await r(['ZSCORE', 'z:s', 'zz']);
770
+ const inc = await r(['ZINCRBY', 'z:s', '5', 'a']);
771
+ const order = await r(['ZRANGE', 'z:s', '0', '-1']);
772
+ const rank = await r(['ZRANK', 'z:s', 'a']);
773
+ const revRank = await r(['ZREVRANK', 'z:s', 'a']);
774
+ const card = await r(['ZCARD', 'z:s']);
775
+ return score.result === '2' && absent.result === null && inc.result === '6'
776
+ && JSON.stringify(order.result) === JSON.stringify(['b', 'a']) && rank.result === 1 && revRank.result === 0 && card.result === 2;
777
+ })),
778
+ done('upstash.sorted_sets.range_by_score', 'sorted_sets', 'ZRANGEBYSCORE honours inclusive, EXCLUSIVE "(" and infinite bounds plus LIMIT offset/count; ZCOUNT agrees with it; ZREMRANGEBYSCORE removes exactly that window', 'api', 'common', () => withRoot(async (r) => {
779
+ await r(['ZADD', 'z:r', '1', 'a', '2', 'b', '3', 'c', '4', 'd']);
780
+ const inclusive = await r(['ZRANGEBYSCORE', 'z:r', '2', '3']);
781
+ const exclusive = await r(['ZRANGEBYSCORE', 'z:r', '(2', '(4']);
782
+ const infinite = await r(['ZRANGEBYSCORE', 'z:r', '-inf', '+inf']);
783
+ const limited = await r(['ZRANGEBYSCORE', 'z:r', '-inf', '+inf', 'LIMIT', '1', '2']);
784
+ const count = await r(['ZCOUNT', 'z:r', '2', '3']);
785
+ const removed = await r(['ZREMRANGEBYSCORE', 'z:r', '1', '2']);
786
+ const left = await r(['ZRANGE', 'z:r', '0', '-1']);
787
+ return JSON.stringify(inclusive.result) === JSON.stringify(['b', 'c'])
788
+ && JSON.stringify(exclusive.result) === JSON.stringify(['c'])
789
+ && infinite.result.length === 4
790
+ && JSON.stringify(limited.result) === JSON.stringify(['b', 'c'])
791
+ && count.result === 2 && removed.result === 2 && JSON.stringify(left.result) === JSON.stringify(['c', 'd']);
792
+ })),
793
+ done('upstash.sorted_sets.infinite_scores_persist', 'sorted_sets', 'DIRTY-STATE: a ±inf score SURVIVES the kernel round-trip and still sorts and ranges correctly on a LATER request — the §9 round-1 refutation, where an in-memory +inf was JSON-serialised to null and came back as the literal bulk string "null", so ZRANGEBYSCORE key 0 100 returned an infinite-scored member', 'api', 'common', () => withRoot(async (r) => {
794
+ await r(['ZADD', 'zi', '+inf', 'top', '-inf', 'bottom', '1.5', 'mid']);
795
+ // EVERY assertion below is a SEPARATE request, so each one reads through the persisted form.
796
+ const top = await r(['ZSCORE', 'zi', 'top']);
797
+ const bottom = await r(['ZSCORE', 'zi', 'bottom']);
798
+ const mid = await r(['ZSCORE', 'zi', 'mid']);
799
+ const window = await r(['ZRANGEBYSCORE', 'zi', '0', '100']); // must NOT contain the infinities
800
+ const everything = await r(['ZRANGEBYSCORE', 'zi', '-inf', '+inf']);
801
+ const ordered = await r(['ZRANGE', 'zi', '0', '-1', 'WITHSCORES']);
802
+ // …and inf + -inf is Redis's NaN refusal, not a NaN score written to disk. (The SAME guard
803
+ // on the string/hash float paths is proven separately by upstash.counters.incrbyfloat —
804
+ // §9 round 2 found it present here but missing there.)
805
+ const nan = await r(['ZADD', 'zi', 'INCR', '-inf', 'top']);
806
+ return top.result === 'inf' && bottom.result === '-inf' && mid.result === '1.5'
807
+ && JSON.stringify(window.result) === JSON.stringify(['mid'])
808
+ && JSON.stringify(everything.result) === JSON.stringify(['bottom', 'mid', 'top'])
809
+ && JSON.stringify(ordered.result) === JSON.stringify(['bottom', '-inf', 'mid', '1.5', 'top', 'inf'])
810
+ && nan.status === 400 && nan.error === 'ERR resulting score is not a number (NaN)';
811
+ })),
812
+ done('upstash.sorted_sets.xx_creates_no_key', 'sorted_sets', 'ZADD … XX against a MISSING key adds nothing AND creates no key — no empty husk for EXISTS/TYPE/DBSIZE/KEYS to report and no phantom type to make a later LPUSH on that name raise WRONGTYPE (the §9 round-1 refutation of "XX never inserts", which held at the member level but not the key level)', 'api', 'common', () => withRoot(async (r) => {
813
+ const added = await r(['ZADD', 'ghost', 'XX', '1', 'm']);
814
+ const exists = await r(['EXISTS', 'ghost']);
815
+ const size = await r(['DBSIZE']);
816
+ const type = await r(['TYPE', 'ghost']);
817
+ // The name must still be FREE — a husk would make this WRONGTYPE.
818
+ const reuse = await r(['LPUSH', 'ghost', 'x']);
819
+ // …while NX on a missing key still creates, which is what NX means.
820
+ const nx = await r(['ZADD', 'fresh', 'NX', '1', 'm']);
821
+ return added.result === 0 && exists.result === 0 && size.result === 0 && type.result === 'none'
822
+ && reuse.result === 1 && nx.result === 1;
823
+ })),
824
+ done('upstash.sorted_sets.gt_lt_incr_returns_nil', 'sorted_sets', 'ZADD … GT|LT with INCR answers NIL when it REFUSES the move (Redis\'s ZADD_OUT_NOP), not the unchanged score — the distinction a "only move this watermark forward" caller branches on, exactly as it branches on SET NX', 'api', 'niche', () => withRoot(async (r) => {
825
+ await r(['ZADD', 'w', '10', 'm']);
826
+ const refusedGt = await r(['ZADD', 'w', 'GT', 'INCR', '-5', 'm']);
827
+ const refusedLt = await r(['ZADD', 'w', 'LT', 'INCR', '5', 'm']);
828
+ const allowed = await r(['ZADD', 'w', 'GT', 'INCR', '5', 'm']);
829
+ const refusedNx = await r(['ZADD', 'w', 'NX', 'INCR', '5', 'm']);
830
+ const finalScore = await r(['ZSCORE', 'w', 'm']);
831
+ return refusedGt.result === null && refusedLt.result === null && allowed.result === '15'
832
+ && refusedNx.result === null && finalScore.result === '15';
833
+ })),
834
+ done('upstash.sorted_sets.remove', 'sorted_sets', 'ZREM answers how many members it removed and ZREMRANGEBYRANK removes by position; the key vanishes once empty', 'api', 'niche', () => withRoot(async (r) => {
835
+ await r(['ZADD', 'z:d', '1', 'a', '2', 'b', '3', 'c']);
836
+ const rem = await r(['ZREM', 'z:d', 'a', 'not-there']);
837
+ const byRank = await r(['ZREMRANGEBYRANK', 'z:d', '0', '0']);
838
+ const left = await r(['ZRANGE', 'z:d', '0', '-1']);
839
+ const removeRest = await r(['ZREM', 'z:d', 'c']);
840
+ const gone = await r(['EXISTS', 'z:d']);
841
+ return rem.result === 1 && byRank.result === 1 && JSON.stringify(left.result) === JSON.stringify(['c']) && removeRest.result === 1 && gone.result === 0;
842
+ })),
843
+ done('upstash.sorted_sets.range_bylex_refused', 'sorted_sets', 'ZRANGE … BYLEX is REFUSED BY NAME rather than silently answered as if it were BYSCORE — an unmodeled option must fail loudly, never return a plausible-looking wrong ordering', 'api', 'niche', () => withRoot(async (r) => {
844
+ await r(['ZADD', 'z:lex', '0', 'a', '0', 'b']);
845
+ const res = await r(['ZRANGE', 'z:lex', '[a', '[b', 'BYLEX']);
846
+ return res.status === 400 && res.error.includes('BYLEX is not modeled by this twin');
847
+ })),
848
+ // ── LISTS ───────────────────────────────────────────────────────────────────────────────────
849
+ done('upstash.lists.push_and_range', 'lists', 'LPUSH inserts each element at the head IN TURN (so `LPUSH k a b` leaves [b, a]) while RPUSH appends; both answer the new length, and LRANGE handles negative indices', 'api', 'core', () => withRoot(async (r) => {
850
+ const lp = await r(['LPUSH', 'l:k', 'a', 'b']);
851
+ const rp = await r(['RPUSH', 'l:k', 'c']);
852
+ const all = await r(['LRANGE', 'l:k', '0', '-1']);
853
+ const tail = await r(['LRANGE', 'l:k', '-2', '-1']);
854
+ const len = await r(['LLEN', 'l:k']);
855
+ return lp.result === 2 && rp.result === 3
856
+ && JSON.stringify(all.result) === JSON.stringify(['b', 'a', 'c'])
857
+ && JSON.stringify(tail.result) === JSON.stringify(['a', 'c']) && len.result === 3;
858
+ })),
859
+ done('upstash.lists.pop', 'lists', 'LPOP/RPOP answer a single element (nil on an empty list) and, with a COUNT, an ARRAY — the batched form a real consumer drains a work queue with; the key disappears once drained', 'api', 'core', () => withRoot(async (r) => {
860
+ await r(['RPUSH', 'l:p', 'a', 'b', 'c', 'd']);
861
+ const one = await r(['LPOP', 'l:p']);
862
+ const batch = await r(['LPOP', 'l:p', '2']);
863
+ const last = await r(['RPOP', 'l:p']);
864
+ const empty = await r(['LPOP', 'l:p']);
865
+ const gone = await r(['EXISTS', 'l:p']);
866
+ return one.result === 'a' && JSON.stringify(batch.result) === JSON.stringify(['b', 'c'])
867
+ && last.result === 'd' && empty.result === null && gone.result === 0;
868
+ })),
869
+ done('upstash.lists.index_set_trim_rem', 'lists', 'LINDEX reads by position (negatives from the end), LSET overwrites one (400 "ERR index out of range" past the end, "ERR no such key" on a missing list), LTRIM keeps a window, LREM removes N occurrences honouring the sign of count', 'api', 'common', () => withRoot(async (r) => {
870
+ await r(['RPUSH', 'l:i', 'a', 'b', 'a', 'c', 'a']);
871
+ const first = await r(['LINDEX', 'l:i', '0']);
872
+ const fromEnd = await r(['LINDEX', 'l:i', '-1']);
873
+ const remTwo = await r(['LREM', 'l:i', '2', 'a']);
874
+ const afterRem = await r(['LRANGE', 'l:i', '0', '-1']);
875
+ const set = await r(['LSET', 'l:i', '0', 'B']);
876
+ const oob = await r(['LSET', 'l:i', '99', 'x']);
877
+ const missing = await r(['LSET', 'l:none', '0', 'x']);
878
+ await r(['LTRIM', 'l:i', '0', '0']);
879
+ const trimmed = await r(['LRANGE', 'l:i', '0', '-1']);
880
+ return first.result === 'a' && fromEnd.result === 'a' && remTwo.result === 2
881
+ && JSON.stringify(afterRem.result) === JSON.stringify(['b', 'c', 'a'])
882
+ && set.result === 'OK' && oob.status === 400 && oob.error === 'ERR index out of range'
883
+ && missing.status === 400 && missing.error === 'ERR no such key'
884
+ && JSON.stringify(trimmed.result) === JSON.stringify(['B']);
885
+ })),
886
+ // ── STREAMS ─────────────────────────────────────────────────────────────────────────────────
887
+ done('upstash.streams.add_and_read', 'streams', 'XADD with `*` mints a MONOTONIC <ms>-<seq> id off the injected clock (sequence incrementing within one millisecond), XLEN counts, and XRANGE answers [id, [field, value, …]] pairs with -/+ bounds and COUNT', 'api', 'common', () => withRoot(async (r) => {
888
+ const a = await r(['XADD', 'x:s', '*', 'f', '1']);
889
+ const b = await r(['XADD', 'x:s', '*', 'f', '2']);
890
+ const c = await r(['XADD', 'x:s', '*', 'f', '3'], { at: laterAt(1000) });
891
+ const len = await r(['XLEN', 'x:s']);
892
+ const all = await r(['XRANGE', 'x:s', '-', '+']);
893
+ const limited = await r(['XRANGE', 'x:s', '-', '+', 'COUNT', '1']);
894
+ const rows = all.result;
895
+ return a.result === `${AT_MS}-0` && b.result === `${AT_MS}-1` && c.result === `${AT_MS + 1000}-0`
896
+ && len.result === 3 && rows.length === 3
897
+ && JSON.stringify(rows[0]) === JSON.stringify([`${AT_MS}-0`, ['f', '1']])
898
+ && limited.result.length === 1;
899
+ })),
900
+ done('upstash.streams.explicit_id_and_reverse', 'streams', 'an EXPLICIT XADD id is accepted only if it is greater than the stream top (else the vendor\'s "equal or smaller" error), XREVRANGE takes its bounds reversed (`+ -`), and XDEL removes by id', 'api', 'niche', () => withRoot(async (r) => {
901
+ await r(['XADD', 'x:e', '100-1', 'a', '1']);
902
+ const ok = await r(['XADD', 'x:e', '100-2', 'a', '2']);
903
+ const tooSmall = await r(['XADD', 'x:e', '100-1', 'a', '3']);
904
+ const _rev = await r(['XREVRANGE', 'x:e', '+', '-']);
905
+ const del = await r(['XDEL', 'x:e', '100-1', 'never-existed-0']);
906
+ const left = await r(['XLEN', 'x:e']);
907
+ return ok.result === '100-2'
908
+ && tooSmall.status === 400 && tooSmall.error === 'ERR The ID specified in XADD is equal or smaller than the target stream top item'
909
+ && JSON.stringify(_rev.result[0][0]) === JSON.stringify('100-2')
910
+ && del.result === 1 && left.result === 1;
911
+ })),
912
+ done('upstash.streams.id_monotonic_across_delete', 'streams', 'DIRTY-STATE: a stream id that was used and then XDEL\'d stays REFUSED forever — Redis retains last_id past a delete, so ids never go backwards (§9 round 1 found the entries-only comparison re-accepting 5-5 after XDEL st 5-5, which would let two different entries share an id over the stream\'s lifetime)', 'api', 'niche', () => withRoot(async (r) => {
913
+ await r(['XADD', 'sid', '5-5', 'f', 'v1']);
914
+ await r(['XDEL', 'sid', '5-5']);
915
+ const empty = await r(['XLEN', 'sid']);
916
+ const reused = await r(['XADD', 'sid', '5-5', 'f', 'v2']);
917
+ const smaller = await r(['XADD', 'sid', '5-4', 'f', 'v3']);
918
+ const greater = await r(['XADD', 'sid', '5-6', 'f', 'v4']);
919
+ return empty.result === 0
920
+ && reused.status === 400 && reused.error === 'ERR The ID specified in XADD is equal or smaller than the target stream top item'
921
+ && smaller.status === 400 && greater.result === '5-6';
922
+ })),
923
+ done('upstash.streams.trim_refused', 'streams', 'XADD … MAXLEN/MINID is REFUSED BY NAME rather than accepted-and-ignored — accepting a trim option and not trimming would be a silent fake success that lets a caller\'s stream grow without bound', 'api', 'niche', () => withRoot(async (r) => {
924
+ const res = await r(['XADD', 'x:t', 'MAXLEN', '10', '*', 'f', 'v']);
925
+ return res.status === 400 && res.error.includes('MAXLEN trimming is not modeled by this twin');
926
+ })),
927
+ // ── SCRIPTING (the heart of the pack) ───────────────────────────────────────────────────────
928
+ done('upstash.scripting.eval', 'scripting', 'EVAL parses and EXECUTES caller-supplied Lua against the real command core: KEYS/ARGV are bound, `redis.call` re-enters the twin, and the returned Lua value is converted by Redis\'s own rules (number truncated to an integer, table to an array reply)', 'api', 'core', () => withRoot(async (r) => {
929
+ const echo = await r(['EVAL', 'return {KEYS[1], ARGV[1], 7.9}', '1', 'thekey', 'thearg']);
930
+ const write = await r(['EVAL', 'redis.call("SET", KEYS[1], ARGV[1]) return redis.call("GET", KEYS[1])', '1', 'ev:k', 'written-by-lua']);
931
+ const visible = await r(['GET', 'ev:k']);
932
+ return JSON.stringify(echo.result) === JSON.stringify(['thekey', 'thearg', 7])
933
+ && write.result === 'written-by-lua' && visible.result === 'written-by-lua';
934
+ })),
935
+ done('upstash.scripting.evalsha_noscript_contract', 'scripting', 'EVALSHA of an uncached sha -> the LITERAL "NOSCRIPT No matching script. Please use EVAL." — the exact string @upstash/ratelimit\'s safeEval greps for to decide to retry with EVAL; after an EVAL or SCRIPT LOAD the same sha resolves', 'api', 'core', () => withRoot(async (r) => {
936
+ const miss = await r(['EVALSHA', 'a'.repeat(40), '0']);
937
+ const load = await r(['SCRIPT', 'LOAD', 'return 42']);
938
+ const sha = load.result;
939
+ const hit = await r(['EVALSHA', sha, '0']);
940
+ const evalThenSha = await r(['EVAL', 'return 99', '0']);
941
+ const exists = await r(['SCRIPT', 'EXISTS', sha, 'b'.repeat(40)]);
942
+ return miss.status === 400 && miss.error === 'NOSCRIPT No matching script. Please use EVAL.'
943
+ && /^[0-9a-f]{40}$/.test(sha) && hit.result === 42 && evalThenSha.result === 99
944
+ && JSON.stringify(exists.result) === JSON.stringify([1, 0]);
945
+ })),
946
+ done('upstash.scripting.ratelimit_fixed_window', 'scripting', 'the REAL @upstash/ratelimit fixed-window script runs verbatim: the counter increments, PEXPIRE is armed EXACTLY ONCE on the first call (later increments must not reset the window), and the {count, limit} table comes back as an array reply', 'api', 'core', () => withRoot(async (r) => {
947
+ const call = (at) => r(['EVAL', RATELIMIT_FIXED_WINDOW, '2', 'rl:fw:u1', '', '3', '60000', '1'], at ? { at } : {});
948
+ const one = await call();
949
+ const two = await call();
950
+ const ttlAfterTwo = await r(['PTTL', 'rl:fw:u1']);
951
+ // The window must NOT be re-armed by the second increment: at +30s the deadline is 30s away.
952
+ const later = await r(['PTTL', 'rl:fw:u1'], { at: laterAt(30_000) });
953
+ const three = await call();
954
+ const four = await call();
955
+ return JSON.stringify(one.result) === JSON.stringify([1, 3])
956
+ && JSON.stringify(two.result) === JSON.stringify([2, 3])
957
+ && JSON.stringify(three.result) === JSON.stringify([3, 3])
958
+ && JSON.stringify(four.result) === JSON.stringify([4, 3]) // the SDK compares 4 > 3 and denies
959
+ && ttlAfterTwo.result === 60_000 && later.result === 30_000;
960
+ })),
961
+ done('upstash.scripting.ratelimit_sliding_window', 'scripting', 'the REAL @upstash/ratelimit sliding-window script runs verbatim and genuinely ENFORCES: it returns the remaining allowance while under the limit and the sentinel -1 once the weighted previous+current count reaches it — exercising Lua float math, `%`, math.floor and the nil→false conversion of a missing GET', 'api', 'core', () => withRoot(async (r) => {
962
+ const call = () => r(['EVAL', RATELIMIT_SLIDING_WINDOW, '3', 'rl:sw:cur', 'rl:sw:prev', '', '2', '30000', '60000', '1']);
963
+ const one = await call();
964
+ const two = await call();
965
+ const three = await call();
966
+ // A different identifier is untouched — the script keys per identifier and so must the twin.
967
+ const other = await r(['EVAL', RATELIMIT_SLIDING_WINDOW, '3', 'rl:sw:other', 'rl:sw:otherprev', '', '2', '30000', '60000', '1']);
968
+ return JSON.stringify(one.result) === JSON.stringify([1, 2])
969
+ && JSON.stringify(two.result) === JSON.stringify([0, 2])
970
+ && JSON.stringify(three.result) === JSON.stringify([-1, 2])
971
+ && JSON.stringify(other.result) === JSON.stringify([1, 2]);
972
+ })),
973
+ done('upstash.scripting.ratelimit_token_bucket', 'scripting', 'the REAL @upstash/ratelimit token-bucket script runs verbatim: it drains the bucket to the -1 sentinel and REFILLS as its `now` argument advances — exercising HMGET\'s per-field false conversion, HSET from Lua, math.min/math.ceil and multi-value table returns', 'api', 'common', () => withRoot(async (r) => {
974
+ const call = (now) => r(['EVAL', RATELIMIT_TOKEN_BUCKET, '2', 'rl:tb:u1', '', '2', '10000', '1', now, '1']);
975
+ const one = await call('0');
976
+ const two = await call('0');
977
+ const denied = await call('0');
978
+ const refilled = await call('20000');
979
+ const bucket = await r(['HGETALL', 'rl:tb:u1']);
980
+ return JSON.stringify(one.result) === JSON.stringify([1, 10000, 2])
981
+ && JSON.stringify(two.result) === JSON.stringify([0, 10000, 2])
982
+ && JSON.stringify(denied.result) === JSON.stringify([-1, 10000, 2])
983
+ && JSON.stringify(refilled.result) === JSON.stringify([1, 30000, 2])
984
+ && bucket.result.includes('refilledAt');
985
+ })),
986
+ done('upstash.scripting.compare_and_delete_lock', 'scripting', 'the real consumer\'s own compare-and-delete lock-release script (dub, lib/upstash/redis-lock.ts) runs verbatim: it deletes ONLY when the token matches and answers 0 otherwise — the Lua `==` between a GET result and an ARGV string', 'api', 'core', () => withRoot(async (r) => {
987
+ await r(['SET', 'lk:a', 'my-token']);
988
+ const wrong = await r(['EVAL', DUB_RELEASE_LOCK, '1', 'lk:a', 'other-token']);
989
+ const stillThere = await r(['GET', 'lk:a']);
990
+ const right = await r(['EVAL', DUB_RELEASE_LOCK, '1', 'lk:a', 'my-token']);
991
+ const gone = await r(['GET', 'lk:a']);
992
+ return wrong.result === 0 && stillThere.result === 'my-token' && right.result === 1 && gone.result === null;
993
+ })),
994
+ done('upstash.scripting.atomicity', 'scripting', 'a script is ONE indivisible unit: one that raises part-way leaves NOTHING behind, even for the writes it had already made — the property that makes a Lua rate limiter safe under concurrency', 'api', 'core', () => withRoot(async (r) => {
995
+ const failed = await r(['EVAL', 'redis.call("SET", KEYS[1], "half-written") redis.call("INCR", KEYS[1]) return 1', '1', 'atomic:k']);
996
+ const nothing = await r(['GET', 'atomic:k']);
997
+ const succeeded = await r(['EVAL', 'redis.call("SET", KEYS[1], "committed") return redis.call("GET", KEYS[1])', '1', 'atomic:k']);
998
+ const committed = await r(['GET', 'atomic:k']);
999
+ return failed.status === 400 && failed.error === 'ERR value is not an integer or out of range'
1000
+ && nothing.result === null && succeeded.result === 'committed' && committed.result === 'committed';
1001
+ })),
1002
+ done('upstash.scripting.lua_semantics', 'scripting', 'the interpreter follows LUA, not JavaScript: `==` does NOT coerce across types ("3" ~= 3), only nil/false are falsy (0 and "" are TRUE), a nil bulk reply becomes `false`, `%` takes the sign of the divisor, and `#t` is the array length', 'api', 'common', () => withRoot(async (r) => {
1003
+ const strictEq = await r(['EVAL', 'if "3" == 3 then return "coerced" else return "strict" end', '0']);
1004
+ const zeroTruthy = await r(['EVAL', 'if 0 then return "truthy" else return "falsy" end', '0']);
1005
+ const nilIsFalse = await r(['EVAL', 'local v = redis.call("GET", KEYS[1]) if v == false then return "false" else return "other" end', '1', 'nope']);
1006
+ const modulo = await r(['EVAL', 'return tostring(-1 % 3)', '0']);
1007
+ const length = await r(['EVAL', 'return #{10,20,30}', '0']);
1008
+ return strictEq.result === 'strict' && zeroTruthy.result === 'truthy' && nilIsFalse.result === 'false'
1009
+ && modulo.result === '2' && length.result === 3;
1010
+ })),
1011
+ done('upstash.scripting.unsupported_fails_loudly', 'scripting', 'a script using something outside the documented Lua SUBSET (cjson, goto, metatables, Lua string PATTERNS) is REFUSED by name at 400 — never quietly mis-evaluated and never a fake success', 'api', 'core', () => withRoot(async (r) => {
1012
+ const cjson = await r(['EVAL', 'return cjson.encode({1,2})', '0']);
1013
+ const goTo = await r(['EVAL', 'local x = 1 goto done', '0']);
1014
+ const meta = await r(['EVAL', 'return setmetatable({}, {})', '0']);
1015
+ const pattern = await r(['EVAL', 'return string.gsub("abc", "%a", "z")', '0']);
1016
+ const all = [cjson, goTo, meta, pattern];
1017
+ return all.every((x) => x.status === 400 && typeof x.error === 'string')
1018
+ && cjson.error.includes("unsupported Lua library 'cjson'")
1019
+ && goTo.error.includes("unsupported Lua construct 'goto'")
1020
+ && meta.error.includes("unsupported Lua library 'setmetatable'")
1021
+ && pattern.error.includes('Lua PATTERN');
1022
+ })),
1023
+ done('upstash.scripting.eval_ro_rejects_writes', 'scripting', 'EVAL_RO/EVALSHA_RO refuse a write command from inside the script, naming it — while the same script under plain EVAL succeeds', 'api', 'niche', () => withRoot(async (r) => {
1024
+ const script = 'redis.call("SET", KEYS[1], "x") return 1';
1025
+ const ro = await r(['EVAL_RO', script, '1', 'ro:k']);
1026
+ const notWritten = await r(['GET', 'ro:k']);
1027
+ const rw = await r(['EVAL', script, '1', 'ro:k']);
1028
+ return ro.status === 400 && ro.error.includes('Write commands are not allowed from read-only scripts.') && notWritten.result === null && rw.result === 1;
1029
+ })),
1030
+ done('upstash.scripting.script_flush', 'scripting', 'SCRIPT FLUSH empties the cache so a previously-working EVALSHA goes back to answering NOSCRIPT', 'api', 'niche', () => withRoot(async (r) => {
1031
+ const load = await r(['SCRIPT', 'LOAD', 'return 5']);
1032
+ const sha = load.result;
1033
+ const before = await r(['EVALSHA', sha, '0']);
1034
+ await r(['SCRIPT', 'FLUSH']);
1035
+ const after = await r(['EVALSHA', sha, '0']);
1036
+ return before.result === 5 && after.status === 400 && after.error === 'NOSCRIPT No matching script. Please use EVAL.';
1037
+ })),
1038
+ // ── ERRORS ──────────────────────────────────────────────────────────────────────────────────
1039
+ done('upstash.errors.unavailable_command', 'errors', 'an unknown command answers UPSTASH\'s message, not stock Redis\'s: "ERR Command is not available: \'FOO\'. See https://upstash.com/docs/redis/overall/rediscompatibility for details", with the name UPPER-CASED whichever form it arrived in', 'api', 'core', () => withRoot(async (r) => {
1040
+ const body = await r(['notacommand', 'x']);
1041
+ const path = await r.raw({ method: 'GET', path: '/notacommand/x' });
1042
+ const expected = "ERR Command is not available: 'NOTACOMMAND'. See https://upstash.com/docs/redis/overall/rediscompatibility for details";
1043
+ return body.status === 400 && body.error === expected
1044
+ && path.status === 400 && path.body.error === expected;
1045
+ })),
1046
+ done('upstash.errors.rest_restricted_commands', 'errors', 'connection/transaction/pub-sub CONTROL commands that make no sense over stateless HTTP (AUTH, MULTI, WATCH, SUBSCRIBE, CLIENT) answer the vendor\'s "is not allowed in REST or it has a special context path" refusal, with the command name upper-cased in double quotes', 'api', 'common', () => withRoot(async (r) => {
1047
+ const results = await Promise.all([['AUTH', 'x'], ['MULTI'], ['WATCH', 'k'], ['SUBSCRIBE', 'c'], ['CLIENT', 'LIST']].map((c) => r(c)));
1048
+ return results.every((x, i) => x.status === 400 && x.error === `ERR Command "${['AUTH', 'MULTI', 'WATCH', 'SUBSCRIBE', 'CLIENT'][i]}" is not allowed in REST or it has a special context path`);
1049
+ })),
1050
+ done('upstash.errors.select_only_db_zero', 'errors', 'SELECT 0 succeeds but any other database answers "ERR Only 0th database is supported! Selected DB: N" — Upstash serves exactly one logical database and says so by number', 'api', 'niche', () => withRoot(async (r) => {
1051
+ const zero = await r(['SELECT', '0']);
1052
+ const one = await r(['SELECT', '1']);
1053
+ return zero.result === 'OK' && one.status === 400 && one.error === 'ERR Only 0th database is supported! Selected DB: 1';
1054
+ })),
1055
+ done('upstash.errors.arity_and_syntax', 'errors', 'a wrong argument count answers "ERR wrong number of arguments for \'<cmd>\' command" and an unrecognised option token answers "ERR syntax error"', 'api', 'core', () => withRoot(async (r) => {
1056
+ const tooFew = await r(['GET']);
1057
+ const tooMany = await r(['GET', 'a', 'b']);
1058
+ const syntax = await r(['SET', 'k', 'v', 'BOGUS']);
1059
+ return tooFew.status === 400 && tooFew.error === "ERR wrong number of arguments for 'get' command"
1060
+ && tooMany.status === 400 && tooMany.error === "ERR wrong number of arguments for 'get' command"
1061
+ && syntax.status === 400 && syntax.error === 'ERR syntax error';
1062
+ })),
1063
+ // ── SAFETY ──────────────────────────────────────────────────────────────────────────────────
1064
+ done('upstash.safety.read_only_mode', 'safety', 'a twin started read-only serves every READ but refuses every WRITE with 405 — including a write attempted from inside an EVAL script, which must not be a back door around the flag', 'api', 'core', () => withRoot(async (r) => {
1065
+ await r(['SET', 'ro:seed', 'value']);
1066
+ const read = await r.raw({ body: JSON.stringify(['GET', 'ro:seed']), readOnly: true });
1067
+ const write = await r.raw({ body: JSON.stringify(['SET', 'ro:seed', 'changed']), readOnly: true });
1068
+ const viaScript = await r.raw({ body: JSON.stringify(['EVAL', 'return redis.call("SET", KEYS[1], "sneaky")', '1', 'ro:seed']), readOnly: true });
1069
+ const unchanged = await r(['GET', 'ro:seed']);
1070
+ return read.status === 200 && read.body.result === 'value'
1071
+ && write.status === 405 && viaScript.status === 405 && unchanged.result === 'value';
1072
+ })),
1073
+ done('upstash.safety.read_only_serves_every_read', 'safety', 'a read-only twin serves EVERY read, including the ones that look like writes: EVAL_RO, EVALSHA_RO and SCRIPT EXISTS all answer 200 — while SCRIPT LOAD/FLUSH, plain EVAL and a write attempted from inside EVAL_RO are all still refused (§9 round 1 found the first three answering 405, because script CACHING is a write and SCRIPT was classified write-wholesale)', 'api', 'core', () => withRoot(async (r) => {
1074
+ await r(['SET', 'ro:k', 'v']);
1075
+ const load = await r(['SCRIPT', 'LOAD', 'return redis.call("GET", KEYS[1])']);
1076
+ const sha = load.result;
1077
+ const ro = (body) => r.raw({ body: JSON.stringify(body), readOnly: true });
1078
+ const [evalRo, shaRo, exists] = await Promise.all([
1079
+ ro(['EVAL_RO', 'return redis.call("GET", KEYS[1])', '1', 'ro:k']),
1080
+ ro(['EVALSHA_RO', sha, '1', 'ro:k']),
1081
+ ro(['SCRIPT', 'EXISTS', sha]),
1082
+ ]);
1083
+ const reads = [evalRo, shaRo, exists].every((x) => x.status === 200);
1084
+ const readValues = evalRo.body.result === 'v' && shaRo.body.result === 'v';
1085
+ // …and every WRITE disguise is still refused.
1086
+ const writes = await Promise.all([
1087
+ ro(['SET', 'ro:k', 'changed']),
1088
+ ro(['EVAL', 'return 1', '0']),
1089
+ ro(['SCRIPT', 'LOAD', 'return 2']),
1090
+ ro(['SCRIPT', 'FLUSH']),
1091
+ ro(['EVAL_RO', 'return redis.call("SET", KEYS[1], "sneaky")', '1', 'ro:k']),
1092
+ ]);
1093
+ const unchanged = await r(['GET', 'ro:k']);
1094
+ return reads && readValues && writes.every((x) => x.status === 405 || x.status === 400) && unchanged.result === 'v';
1095
+ })),
1096
+ // ── CONNECTOR ───────────────────────────────────────────────────────────────────────────────
1097
+ done('upstash.connector.pull', 'connector', 'the connector pulls NAMED keys of every type from an INJECTED client and folds them into the twin, where they read back exactly as locally-written ones do (a real SDK client is structurally assignable; the pack imports no SDK and holds no token)', 'connector', 'core', () => withRoot(async (r, root) => {
1098
+ const { client, calls } = fakeRedisClient({
1099
+ 'pulled:str': { type: 'string', value: 'from-vendor', ttl: 120 },
1100
+ 'pulled:hash': { type: 'hash', value: { a: '1', b: '2' } },
1101
+ 'pulled:set': { type: 'set', value: ['x', 'y'] },
1102
+ });
1103
+ const result = await syncUpstashRedisFromReal(client, {
1104
+ root, occurredAt: AT,
1105
+ keys: [{ key: 'pulled:str' }, { key: 'pulled:hash' }, { key: 'pulled:set' }, { key: 'pulled:missing' }],
1106
+ });
1107
+ const str = await r(['GET', 'pulled:str']);
1108
+ const ttl = await r(['TTL', 'pulled:str']);
1109
+ const hash = await r(['HGETALL', 'pulled:hash']);
1110
+ const set = await r(['SISMEMBER', 'pulled:set', 'x']);
1111
+ const missing = await r(['EXISTS', 'pulled:missing']);
1112
+ return result.observed === 3 && result.deltasAppended > 0
1113
+ && str.result === 'from-vendor' && ttl.result === 120
1114
+ && JSON.stringify(hash.result) === JSON.stringify(['a', '1', 'b', '2'])
1115
+ && set.result === 1 && missing.result === 0 // a key the vendor lacks is SKIPPED, never invented
1116
+ && calls.includes('type:pulled:str');
1117
+ })),
1118
+ done('upstash.connector.refuses_unencodable_type', 'connector', 'a pulled key whose type the connector cannot faithfully encode (a STREAM) is REFUSED BY NAME, naming the filed todo — never coerced into a JSON string and stored under the real type, which §9 round 2 found producing a key that looked real and then threw a raw TypeError out of the request handler on every read', 'connector', 'common', () => {
1119
+ const { client } = fakeRedisClient({ 'st:1': { type: 'stream', value: [['1-0', { f: 'v' }]] } });
1120
+ return verifyBoundary('upstash.connector.refuses_unencodable_type', async () => {
1121
+ let refused = '';
1122
+ try {
1123
+ await pullUpstashRedisKeys(client, [{ key: 'st:1' }]);
1124
+ }
1125
+ catch (e) {
1126
+ refused = e.message;
1127
+ }
1128
+ return refused.includes("cannot pull key of type 'stream'") && refused.includes('upstash.connector.pull_streams');
1129
+ });
1130
+ }),
1131
+ done('upstash.connector.pull_idempotent', 'connector', 'a re-pull of IDENTICAL vendor state appends ZERO new deltas (shadow-diff dedup), so a scheduled sync does not grow the log without bound — while a CHANGED value does append', 'connector', 'core', () => withRoot(async (r, root) => {
1132
+ const first = fakeRedisClient({ 'idem:k': { type: 'string', value: 'v1' } });
1133
+ const a = await syncUpstashRedisFromReal(first.client, { root, occurredAt: AT, keys: [{ key: 'idem:k' }] });
1134
+ const b = await syncUpstashRedisFromReal(first.client, { root, occurredAt: AT, keys: [{ key: 'idem:k' }] });
1135
+ const changed = fakeRedisClient({ 'idem:k': { type: 'string', value: 'v2' } });
1136
+ const c = await syncUpstashRedisFromReal(changed.client, { root, occurredAt: AT, keys: [{ key: 'idem:k' }] });
1137
+ const now = await r(['GET', 'idem:k']);
1138
+ return a.deltasAppended > 0 && b.deltasAppended === 0 && c.deltasAppended > 0 && now.result === 'v2';
1139
+ })),
1140
+ done('upstash.connector.scan_is_bounded', 'connector', 'the SCAN-based discovery path is HARD-BOUNDED by an explicit limit rather than looping until the vendor says stop — an unbounded walk of a production keyspace is exactly the burst the rate budget exists to prevent', 'connector', 'common', () => {
1141
+ const { client, calls } = fakeRedisClient(Object.fromEntries(Array.from({ length: 40 }, (_, i) => [`walk:${i}`, { type: 'string', value: String(i) }])));
1142
+ return verifyBoundary('upstash.connector.scan_is_bounded', async () => {
1143
+ const found = await pullUpstashRedisScan(client, { limit: 5, pageSize: 50 });
1144
+ return found.length === 5 && calls.some((c) => c.startsWith('scan:'));
1145
+ });
1146
+ }),
1147
+ done('upstash.connector.rate_budget_fail_closed', 'connector', 'the client-side rate budget REFUSES a call past the ceiling BEFORE it reaches the vendor: after N calls the injected fake recorded exactly N, and the next attempt throws with the count UNCHANGED (the count is the proof — "it threw" is not)', 'connector', 'core', async () => {
1148
+ const { mkdtempSync: mk, rmSync: rm } = await import('node:fs');
1149
+ const ledgerRoot = mk(join(tmpdir(), 'upstash-budget-'));
1150
+ try {
1151
+ return await verifyBoundary('upstash.connector.rate_budget_fail_closed', async () => {
1152
+ const { guardUpstashRedisClient, UPSTASH_BUDGET_CEILING, UPSTASH_CALL_WEIGHTS } = await import("./upstash-budget.js");
1153
+ let vendorCalls = 0;
1154
+ const raw = { get: async () => { vendorCalls++; return 'v'; } };
1155
+ // A frozen injected clock, so the rolling window cannot slide underneath the assertion, and
1156
+ // an isolated ledger path, so this never spends against the operator's real ~/.volter file.
1157
+ const guarded = guardUpstashRedisClient(raw, { budgetOptions: { root: ledgerRoot, now: () => 1_800_000_000_000 } });
1158
+ const allowed = Math.floor(UPSTASH_BUDGET_CEILING / UPSTASH_CALL_WEIGHTS.other);
1159
+ for (let i = 0; i < allowed; i++)
1160
+ await guarded.get('k');
1161
+ if (vendorCalls !== allowed)
1162
+ return false;
1163
+ let refused = false;
1164
+ try {
1165
+ await guarded.get('k');
1166
+ }
1167
+ catch {
1168
+ refused = true;
1169
+ }
1170
+ // THE assertion: the count did not move, so nothing reached the vendor.
1171
+ return refused && vendorCalls === allowed;
1172
+ });
1173
+ }
1174
+ finally {
1175
+ rm(ledgerRoot, { recursive: true, force: true });
1176
+ }
1177
+ }),
1178
+ // ── CONFORMANCE ─────────────────────────────────────────────────────────────────────────────
1179
+ done('upstash.conformance.endpoint_probe', 'conformance', 'the conformance check ISSUES REAL REQUESTS against every endpoint its inventory claims (not a constant-vs-constant snapshot comparison) and requires each to answer as declared — so a dead handler fails it', 'api', 'common', async () => {
1180
+ const report = await checkUpstashRedisConformance();
1181
+ return report.ok && report.violations.length === 0 && report.endpointsChecked >= 6 && report.resourceTypesChecked === 2;
1182
+ }),
1183
+ // ══ TODOS — real vendor surface this twin has NOT reached ═══════════════════════════════════
1184
+ // Enumerated top-down from Redis's own command groups as Upstash supports them, so a whole
1185
+ // missing family shows in the denominator rather than being quietly left out.
1186
+ // RedisJSON — a first-class Upstash feature with its own command family.
1187
+ todo('upstash.json.set_get', 'json', 'JSON.SET / JSON.GET — store and read a JSON document at a path (RedisJSON, supported by Upstash)', 'api', 'common'),
1188
+ todo('upstash.json.mutate', 'json', 'JSON.NUMINCRBY / JSON.STRAPPEND / JSON.DEL / JSON.TOGGLE — in-place mutation at a JSONPath', 'api', 'common'),
1189
+ todo('upstash.json.arrays', 'json', 'JSON.ARRAPPEND / JSON.ARRINSERT / JSON.ARRPOP / JSON.ARRTRIM / JSON.ARRLEN — array operations inside a document', 'api', 'niche'),
1190
+ todo('upstash.json.type_and_objkeys', 'json', 'JSON.TYPE / JSON.OBJKEYS / JSON.OBJLEN / JSON.RESP — document introspection', 'api', 'niche'),
1191
+ // Bitmaps.
1192
+ todo('upstash.bitmaps.setbit_getbit', 'bitmaps', 'SETBIT / GETBIT — single-bit addressing inside a string value', 'api', 'common'),
1193
+ todo('upstash.bitmaps.bitcount_bitpos', 'bitmaps', 'BITCOUNT / BITPOS with BYTE|BIT ranges — population counts over a bitmap', 'api', 'common'),
1194
+ todo('upstash.bitmaps.bitop_bitfield', 'bitmaps', 'BITOP (AND/OR/XOR/NOT) and BITFIELD\'s typed sub-command language', 'api', 'niche'),
1195
+ // HyperLogLog.
1196
+ todo('upstash.hyperloglog.pfadd_pfcount', 'hyperloglog', 'PFADD / PFCOUNT — probabilistic cardinality estimation with the real HLL error bound', 'api', 'common'),
1197
+ todo('upstash.hyperloglog.pfmerge', 'hyperloglog', 'PFMERGE — union of HyperLogLog registers into a destination key', 'api', 'niche'),
1198
+ // Geo.
1199
+ todo('upstash.geo.add_pos_dist', 'geo', 'GEOADD / GEOPOS / GEODIST / GEOHASH — geospatial members over a sorted set', 'api', 'niche'),
1200
+ todo('upstash.geo.search', 'geo', 'GEOSEARCH / GEOSEARCHSTORE (and the deprecated GEORADIUS family) — radius and box queries', 'api', 'niche'),
1201
+ // Pub/Sub + the two SSE endpoints, which are a genuinely different transport.
1202
+ todo('upstash.pubsub.publish', 'pubsub', 'PUBLISH — deliver a message to a channel and answer the subscriber count (the twin currently has no channel registry)', 'api', 'common'),
1203
+ todo('upstash.pubsub.subscribe_sse', 'pubsub', 'POST /subscribe/{channel} with Accept: text/event-stream — the documented SSE subscription endpoint (`data: message,chat,hello` frames)', 'api', 'common'),
1204
+ todo('upstash.pubsub.monitor_sse', 'pubsub', 'POST /monitor with Accept: text/event-stream — the documented command-firehose SSE endpoint', 'api', 'niche'),
1205
+ todo('upstash.pubsub.pattern_channels', 'pubsub', 'PSUBSCRIBE pattern channels and the PUBSUB CHANNELS/NUMSUB/NUMPAT introspection sub-commands', 'api', 'niche'),
1206
+ // Streams beyond the five commands the consumer uses.
1207
+ todo('upstash.streams.consumer_groups', 'streams', 'XGROUP CREATE/DESTROY/SETID, XREADGROUP, XACK, XPENDING — the consumer-group half of streams', 'api', 'common'),
1208
+ todo('upstash.streams.claim', 'streams', 'XCLAIM / XAUTOCLAIM — reassigning a pending entry from a dead consumer', 'api', 'niche'),
1209
+ todo('upstash.streams.xread', 'streams', 'XREAD (non-blocking) across multiple streams with per-stream last-id cursors', 'api', 'common'),
1210
+ todo('upstash.streams.trim', 'streams', 'XTRIM, and XADD\'s MAXLEN/MINID trimming with ~ and LIMIT (currently REFUSED by name rather than silently ignored — see upstash.streams.trim_refused)', 'api', 'common'),
1211
+ todo('upstash.streams.xinfo', 'streams', 'XINFO STREAM/GROUPS/CONSUMERS — stream introspection', 'api', 'niche'),
1212
+ // Search (RediSearch) — a documented Upstash family this twin does not model at all.
1213
+ todo('upstash.search.index_and_query', 'search', 'FT.CREATE / FT.SEARCH / FT.AGGREGATE / FT.INFO / FT.DROPINDEX / FT.ALTER / FT.EXPLAIN — the RediSearch index and query family Upstash documents', 'api', 'common'),
1214
+ // Set algebra.
1215
+ todo('upstash.sets.algebra', 'sets', 'SINTER / SUNION / SDIFF and their *STORE variants — set algebra across keys', 'api', 'common'),
1216
+ todo('upstash.sets.move_and_random', 'sets', 'SMOVE, and SPOP/SRANDMEMBER with genuine randomness (this twin takes deterministically from the front — see upstash.sets.spop_deterministic)', 'api', 'niche'),
1217
+ todo('upstash.sorted_sets.blocking_pop', 'sorted_sets', 'BZPOPMIN / BZPOPMAX / BZMPOP — the blocking sorted-set pops', 'api', 'niche'),
1218
+ todo('upstash.hashes.field_ops', 'hashes', 'HSTRLEN — the per-field length accessor', 'api', 'niche'),
1219
+ todo('upstash.keyspace.expiretime', 'keyspace', 'EXPIRETIME / PEXPIRETIME — reading a key\'s ABSOLUTE expiry deadline rather than its remaining TTL', 'api', 'niche'),
1220
+ todo('upstash.keyspace.conditional_delete', 'keyspace', 'DELEX — conditional delete (delete only when the key still holds an expected state)', 'api', 'niche'),
1221
+ todo('upstash.server.replication_wait', 'server', 'WAIT / WAITAOF — blocking until N replicas (or the AOF) have acknowledged a write', 'api', 'niche'),
1222
+ todo('upstash.server.digest', 'server', 'DIGEST — the dataset digest Upstash exposes for comparing databases', 'api', 'niche'),
1223
+ todo('upstash.auth.acl', 'auth', 'the ACL family (ACL WHOAMI / LIST / GETUSER / SETUSER …) Upstash documents under Server', 'api', 'niche'),
1224
+ todo('upstash.scripting.script_kill', 'scripting', 'SCRIPT KILL — terminating a long-running script (documented Upstash surface; this twin runs scripts to completion under a step ceiling instead)', 'api', 'niche'),
1225
+ todo('upstash.sets.sscan', 'sets', 'SSCAN — cursor iteration over a large set', 'api', 'niche'),
1226
+ todo('upstash.sets.spop_deterministic', 'sets', 'SPOP/SRANDMEMBER currently return the FRONT of insertion order rather than a random member, so a verify can assert them; real random selection is the gap', 'api', 'niche'),
1227
+ // Sorted-set surface beyond what is built.
1228
+ todo('upstash.sorted_sets.range_bylex', 'sorted_sets', 'ZRANGEBYLEX / ZREVRANGEBYLEX / ZREMRANGEBYLEX and ZRANGE … BYLEX — lexicographic ranges over equal-score members (currently REFUSED by name)', 'api', 'common'),
1229
+ todo('upstash.sorted_sets.store_and_algebra', 'sorted_sets', 'ZUNIONSTORE / ZINTERSTORE / ZDIFFSTORE / ZRANGESTORE with WEIGHTS and AGGREGATE', 'api', 'common'),
1230
+ todo('upstash.sorted_sets.pop_and_random', 'sorted_sets', 'ZPOPMIN / ZPOPMAX / ZMPOP / ZRANDMEMBER — pop-by-rank and random sampling', 'api', 'niche'),
1231
+ todo('upstash.sorted_sets.mscore_and_scan', 'sorted_sets', 'ZMSCORE (batch score lookup) and ZSCAN (cursor iteration)', 'api', 'niche'),
1232
+ // List surface beyond what is built.
1233
+ todo('upstash.lists.move', 'lists', 'LMOVE / RPOPLPUSH / LMPOP — moving elements between lists atomically', 'api', 'common'),
1234
+ todo('upstash.lists.insert_and_pos', 'lists', 'LINSERT BEFORE|AFTER and LPOS with RANK/COUNT/MAXLEN', 'api', 'niche'),
1235
+ todo('upstash.lists.blocking', 'lists', 'BLPOP / BRPOP / BLMOVE — blocking pops (the REST docs call these unsupported, but a live probe found BLPOP answering on redis 8.2, so the vendor\'s true position needs re-grounding before modeling)', 'api', 'niche'),
1236
+ // Hash surface beyond what is built.
1237
+ todo('upstash.hashes.hscan', 'hashes', 'HSCAN — cursor iteration over a large hash, with MATCH/COUNT/NOVALUES', 'api', 'common'),
1238
+ todo('upstash.hashes.field_ttl', 'hashes', 'HEXPIRE / HPEXPIRE / HTTL / HPERSIST — per-FIELD expiry (Redis 7.4), distinct from the key-level TTL this twin models', 'api', 'niche'),
1239
+ todo('upstash.hashes.randfield', 'hashes', 'HRANDFIELD with COUNT and WITHVALUES', 'api', 'niche'),
1240
+ // String surface beyond what is built.
1241
+ todo('upstash.strings.ranges', 'strings', 'GETRANGE / SETRANGE / SUBSTR — substring read and in-place overwrite at an offset', 'api', 'niche'),
1242
+ todo('upstash.counters.int64_range', 'counters', 'full 64-bit integer range: Redis counters are int64, but this twin\'s arithmetic is JavaScript doubles, so it refuses beyond 2^53-1 — with the vendor\'s "value is not an integer or out of range" for an out-of-range ARGUMENT, and its "increment or decrement would overflow" when an in-range increment would cross the ceiling (§9 rounds 1 and 2). Loud rather than lossy — a silently wrong big integer would be far worse — but the ceiling is the twin\'s, not Upstash\'s', 'api', 'niche'),
1243
+ todo('upstash.strings.lcs', 'strings', 'LCS with LEN/IDX/MINMATCHLEN — longest common subsequence between two string keys', 'api', 'niche'),
1244
+ // Generic keyspace surface beyond what is built.
1245
+ todo('upstash.keyspace.expire_conditions', 'keyspace', 'EXPIRE/PEXPIRE/EXPIREAT with the NX|XX|GT|LT condition flags (currently REFUSED by name rather than silently ignored)', 'api', 'common'),
1246
+ todo('upstash.keyspace.copy', 'keyspace', 'COPY source destination [REPLACE] — duplicating a key of any type', 'api', 'niche'),
1247
+ todo('upstash.keyspace.dump_restore', 'keyspace', 'DUMP / RESTORE — the serialized-value format, which is a real binary encoding this twin does not produce', 'api', 'niche'),
1248
+ todo('upstash.keyspace.object_introspection', 'keyspace', 'OBJECT ENCODING / REFCOUNT / IDLETIME / FREQ — internal representation introspection', 'api', 'niche'),
1249
+ todo('upstash.keyspace.scan_cursor_shape', 'keyspace', 'the cursor\'s exact SHAPE: Redis returns a reverse-binary index into its hash table (a large opaque number that reflects real rehashing). This twin returns a stable key-derived position, which DOES honour the present-throughout guarantee (proven by upstash.keyspace.scan_survives_mutation) and is additionally reproducible — but the numbers themselves are not the vendor\'s, so a caller that inspected or persisted a cursor across implementations would see different values', 'api', 'niche'),
1250
+ todo('upstash.keyspace.randomkey_deterministic', 'keyspace', 'RANDOMKEY currently answers the FIRST key in sorted order so a verify can assert it; genuine random selection is the gap', 'api', 'niche'),
1251
+ // Server / connection surface.
1252
+ todo('upstash.server.info', 'server', 'INFO — the whole report as one \\r\\n-separated bulk string, including Upstash-specific fields (upstash_version, total_keys, max_ops_per_sec)', 'api', 'common'),
1253
+ todo('upstash.server.config_and_command', 'server', 'CONFIG GET/SET and COMMAND / COMMAND DOCS / COMMAND COUNT introspection', 'api', 'niche'),
1254
+ todo('upstash.server.time_and_lastsave', 'server', 'TIME / LASTSAVE / BGSAVE — server-clock and persistence introspection commands', 'api', 'niche'),
1255
+ todo('upstash.server.dbsize_per_type_stats', 'server', 'MEMORY USAGE and the per-type keyspace statistics INFO reports', 'api', 'niche'),
1256
+ // Scripting surface beyond what is built.
1257
+ todo('upstash.scripting.functions', 'scripting', 'FUNCTION LOAD/DUMP/FLUSH/LIST and FCALL — Redis 7 Functions, a separate mechanism from EVAL', 'api', 'niche'),
1258
+ todo('upstash.scripting.lua_libraries', 'scripting', 'the cjson / cmsgpack / bit / struct Lua libraries real Redis exposes to scripts (currently REFUSED by name — see upstash.scripting.unsupported_fails_loudly)', 'api', 'common'),
1259
+ todo('upstash.scripting.lua_patterns', 'scripting', 'Lua PATTERN matching in string.find/gsub/match/gmatch — a distinct language from JS regex, currently refused rather than mistranslated', 'api', 'niche'),
1260
+ todo('upstash.scripting.metatables_and_coroutines', 'scripting', 'setmetatable/getmetatable, pcall/xpcall and coroutines inside a script', 'api', 'niche'),
1261
+ todo('upstash.scripting.multiregion_ratelimit_scripts', 'scripting', '@upstash/ratelimit\'s MULTI-REGION scripts (hash-based fixed/sliding window with per-request-id fields and generic-for loops) — the single-region three are proven; the multi-region pair is not yet exercised', 'api', 'niche'),
1262
+ // Protocol surface beyond what is built.
1263
+ todo('upstash.protocol.resp2_format', 'protocol', 'Upstash-Response-Format: resp2 — raw RESP2 bytes as application/octet-stream instead of JSON (and its documented rejection when combined with /multi-exec)', 'api', 'niche'),
1264
+ todo('upstash.protocol.read_your_writes_semantics', 'protocol', 'REAL read-your-writes: the sync token as a replication watermark a read region actually waits on. This twin echoes/advances a counter permissively because it has no replicas', 'api', 'common'),
1265
+ todo('upstash.protocol.telemetry_headers', 'protocol', 'recording the Upstash-Telemetry-Sdk/-Platform/-Runtime headers so a world can assert which client version spoke to it (currently accepted and ignored, as the real server does)', 'api', 'niche'),
1266
+ todo('upstash.protocol.auto_pipeline_exclusions', 'protocol', 'asserting the SDK\'s EXCLUDE_COMMANDS set arrives at POST / rather than /pipeline — both endpoints work, but the routing split itself is not pinned by a verify', 'api', 'niche'),
1267
+ // Quotas and auth surface.
1268
+ todo('upstash.quotas.daily_request_limit', 'quotas', 'ERR max daily request limit exceeded — the documented daily-quota refusal (Upstash publishes the error string but NO figure, and the grounding pass could not trigger it, so its HTTP status is unverified)', 'api', 'niche'),
1269
+ todo('upstash.quotas.monthly_request_limit', 'quotas', 'ERR max requests limit exceeded — the monthly-quota refusal (500K commands/month on the free tier; status unverified for the same reason)', 'api', 'niche'),
1270
+ todo('upstash.quotas.record_and_key_size', 'quotas', 'ERR max key size exceeded / ERR max single record size exceeded — the per-key and per-record size ceilings (100 MB records on Free/PAYG)', 'api', 'niche'),
1271
+ todo('upstash.quotas.concurrent_connections', 'quotas', 'ERR max concurrent connections exceeded — the per-plan connection ceiling', 'api', 'niche'),
1272
+ todo('upstash.auth.read_only_token', 'auth', 'Upstash\'s READ-ONLY token: reads allowed, writes and SCAN/KEYS refused. The rejection status and error string could not be observed (the grounding database exposed only one token), so modeling them would be a guess', 'api', 'common'),
1273
+ // Connector surface.
1274
+ todo('upstash.connector.push', 'connector', 'push locally-written keys back to a real Upstash database — deliberately not built: a blind write-back to a production cache is destructive, and the safe design (an explicit key allowlist plus a dry-run diff) has not been settled', 'connector', 'common'),
1275
+ todo('upstash.connector.pull_by_scan', 'connector', 'a full connector sync driven by SCAN discovery rather than caller-named keys (pullUpstashRedisScan finds the handles today, but wiring it into syncUpstashRedisFromReal would make an unbounded production walk one call away)', 'connector', 'common'),
1276
+ todo('upstash.connector.pull_streams', 'connector', 'pulling STREAM keys — the connector maps string/hash/set/list/zset today and skips streams, since XRANGE over a large stream is unbounded', 'connector', 'niche'),
1277
+ todo('upstash.connector.fixtures', 'connector', 'seed a small library of realistic keyspace fixtures (a session cache, a rate-limit window, a leaderboard) for eval worlds', 'connector', 'niche'),
1278
+ ];
1279
+ export const UPSTASH_AREAS = [
1280
+ 'protocol', 'auth', 'strings', 'counters', 'keyspace', 'hashes', 'sets', 'sorted_sets', 'lists',
1281
+ 'streams', 'scripting', 'json', 'search', 'bitmaps', 'hyperloglog', 'geo', 'pubsub', 'server', 'quotas',
1282
+ 'errors', 'safety', 'connector', 'conformance',
1283
+ ];
1284
+ export async function upstashCapabilities() {
1285
+ return checkCapabilities('upstash', UPSTASH_CAPABILITIES);
1286
+ }