@volter/world-core 3.0.38 → 3.0.40

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 (179) hide show
  1. package/app-route.cjs +26 -25
  2. package/app-route.d.cts +1 -0
  3. package/dist/app-route.cjs +26 -25
  4. package/dist/app-route.d.cts +1 -0
  5. package/dist/generated/pack-facts.json +409 -352
  6. package/dist/inject.cjs +474 -95
  7. package/dist/network-policy.cjs +110 -13
  8. package/dist/network-policy.d.cts +8 -3
  9. package/dist/src/actions.js +2 -1
  10. package/dist/src/anthropic-wire.d.ts +2 -0
  11. package/dist/src/anthropic-wire.js +5 -0
  12. package/dist/src/attestation.d.ts +6 -0
  13. package/dist/src/attestation.js +27 -0
  14. package/dist/src/bytes.d.ts +3 -0
  15. package/dist/src/bytes.js +17 -0
  16. package/dist/src/changeset.js +2 -1
  17. package/dist/src/clickhouse/sql.js +73 -14
  18. package/dist/src/derived-core.d.ts +210 -32
  19. package/dist/src/derived-core.js +510 -244
  20. package/dist/src/derived-real.d.ts +1 -1
  21. package/dist/src/derived-real.js +704 -93
  22. package/dist/src/derived.d.ts +18 -2
  23. package/dist/src/derived.js +21 -2
  24. package/dist/src/events.d.ts +28 -45
  25. package/dist/src/events.js +40 -58
  26. package/dist/src/exact-json.d.ts +10 -0
  27. package/dist/src/exact-json.js +98 -0
  28. package/dist/src/executor.d.ts +17 -0
  29. package/dist/src/executor.js +91 -30
  30. package/dist/src/file-response.js +1 -1
  31. package/dist/src/form.d.ts +8 -0
  32. package/dist/src/form.js +76 -0
  33. package/dist/src/git/format.d.ts +16 -0
  34. package/dist/src/git/format.js +110 -0
  35. package/dist/src/git/index.d.ts +1 -0
  36. package/dist/src/git/index.js +1 -0
  37. package/dist/src/git/lfs.d.ts +1 -1
  38. package/dist/src/git/lfs.js +4 -3
  39. package/dist/src/graphql-wire.d.ts +6 -0
  40. package/dist/src/graphql-wire.js +6 -3
  41. package/dist/src/grpc-wire.js +2 -1
  42. package/dist/src/head.d.ts +1 -0
  43. package/dist/src/head.js +15 -4
  44. package/dist/src/held-reads.d.ts +6 -0
  45. package/dist/src/held-reads.js +60 -0
  46. package/dist/src/history.js +5 -4
  47. package/dist/src/host-port.d.ts +5 -0
  48. package/dist/src/host-port.js +14 -0
  49. package/dist/src/index.d.ts +11 -3
  50. package/dist/src/index.js +9 -4
  51. package/dist/src/log.js +12 -7
  52. package/dist/src/machines.d.ts +81 -5
  53. package/dist/src/machines.js +125 -9
  54. package/dist/src/managed-database.d.ts +15 -11
  55. package/dist/src/managed-database.js +38 -37
  56. package/dist/src/multipart.d.ts +21 -3
  57. package/dist/src/multipart.js +112 -38
  58. package/dist/src/observe.d.ts +3 -0
  59. package/dist/src/observe.js +6 -2
  60. package/dist/src/openai-wire.d.ts +16 -0
  61. package/dist/src/openai-wire.js +43 -12
  62. package/dist/src/pack-fetch.d.ts +4 -6
  63. package/dist/src/pack-fetch.js +189 -57
  64. package/dist/src/packRegistry.d.ts +7 -1
  65. package/dist/src/packRegistry.js +20 -3
  66. package/dist/src/private-transport.d.ts +1 -0
  67. package/dist/src/private-transport.js +11 -0
  68. package/dist/src/protobuf.js +10 -4
  69. package/dist/src/rateBudget.d.ts +8 -1
  70. package/dist/src/rateBudget.js +1 -0
  71. package/dist/src/redis/engine.d.ts +14 -5
  72. package/dist/src/redis/engine.js +271 -72
  73. package/dist/src/redis/frames.d.ts +17 -0
  74. package/dist/src/redis/frames.js +123 -0
  75. package/dist/src/redis/index.d.ts +6 -0
  76. package/dist/src/redis/index.js +6 -0
  77. package/dist/src/redis/json.d.ts +2 -0
  78. package/dist/src/redis/json.js +14 -0
  79. package/dist/src/redis/lua-libs.d.ts +4 -0
  80. package/dist/src/redis/lua-libs.js +173 -0
  81. package/dist/src/redis/lua.d.ts +1 -0
  82. package/dist/src/redis/lua.js +36 -0
  83. package/dist/src/redis/member-storage.d.ts +5 -0
  84. package/dist/src/redis/member-storage.js +75 -0
  85. package/dist/src/redis/resp2.d.ts +4 -0
  86. package/dist/src/redis/resp2.js +29 -0
  87. package/dist/src/redis/stream.d.ts +14 -0
  88. package/dist/src/redis/stream.js +335 -0
  89. package/dist/src/remote-execute.d.ts +2 -2
  90. package/dist/src/request-body.d.ts +11 -0
  91. package/dist/src/request-body.js +11 -0
  92. package/dist/src/runtime.d.ts +6 -5
  93. package/dist/src/runtime.js +5 -5
  94. package/dist/src/s3/wire.d.ts +4 -4
  95. package/dist/src/s3/wire.js +5 -3
  96. package/dist/src/scenario.d.ts +22 -0
  97. package/dist/src/scenario.js +35 -31
  98. package/dist/src/serve-http.d.ts +2 -1
  99. package/dist/src/serve-http.js +10 -1
  100. package/dist/src/serve.js +7 -4
  101. package/dist/src/signing.d.ts +23 -5
  102. package/dist/src/signing.js +74 -11
  103. package/dist/src/sigv4.d.ts +3 -1
  104. package/dist/src/sigv4.js +4 -2
  105. package/dist/src/smtp.js +2 -1
  106. package/dist/src/sockets.js +8 -3
  107. package/dist/src/state-system.d.ts +3 -0
  108. package/dist/src/storage.js +5 -2
  109. package/dist/src/twin-fetch.d.ts +3 -1
  110. package/dist/src/twin-fetch.js +3 -2
  111. package/dist/src/vendor-call.d.ts +10 -3
  112. package/dist/src/vendor-call.js +58 -14
  113. package/dist/test-fixtures/attestation.SOURCE.md +1 -0
  114. package/dist/test-fixtures/attestation.json +50 -0
  115. package/generated/pack-facts.json +409 -352
  116. package/inject.cjs +474 -95
  117. package/network-policy.cjs +110 -13
  118. package/network-policy.d.cts +8 -3
  119. package/package.json +11 -4
  120. package/src/actions.ts +2 -1
  121. package/src/anthropic-wire.ts +6 -0
  122. package/src/attestation.ts +23 -0
  123. package/src/bytes.ts +21 -0
  124. package/src/changeset.ts +2 -1
  125. package/src/clickhouse/sql.ts +50 -16
  126. package/src/derived-core.ts +573 -249
  127. package/src/derived-real.ts +552 -42
  128. package/src/derived.ts +23 -4
  129. package/src/events.ts +54 -85
  130. package/src/exact-json.ts +71 -0
  131. package/src/executor.ts +86 -27
  132. package/src/file-response.ts +1 -1
  133. package/src/form.ts +66 -0
  134. package/src/git/format.ts +89 -0
  135. package/src/git/index.ts +1 -0
  136. package/src/git/lfs.ts +5 -4
  137. package/src/graphql-wire.ts +8 -3
  138. package/src/grpc-wire.ts +2 -1
  139. package/src/head.ts +15 -5
  140. package/src/held-reads.ts +54 -0
  141. package/src/history.ts +5 -4
  142. package/src/host-port.ts +9 -0
  143. package/src/index.ts +10 -4
  144. package/src/log.ts +11 -7
  145. package/src/machines.ts +135 -13
  146. package/src/managed-database.ts +48 -40
  147. package/src/multipart.ts +110 -35
  148. package/src/observe.ts +6 -3
  149. package/src/openai-wire.ts +45 -14
  150. package/src/pack-fetch.ts +173 -50
  151. package/src/packRegistry.ts +27 -4
  152. package/src/private-transport.ts +8 -0
  153. package/src/protobuf.ts +8 -4
  154. package/src/rateBudget.ts +6 -1
  155. package/src/redis/engine.ts +168 -63
  156. package/src/redis/frames.ts +53 -0
  157. package/src/redis/index.ts +6 -0
  158. package/src/redis/json.ts +13 -0
  159. package/src/redis/lua-libs.ts +62 -0
  160. package/src/redis/lua.ts +20 -0
  161. package/src/redis/member-storage.ts +58 -0
  162. package/src/redis/resp2.ts +25 -0
  163. package/src/redis/stream.ts +102 -0
  164. package/src/remote-execute.ts +2 -2
  165. package/src/request-body.ts +12 -0
  166. package/src/runtime.ts +6 -5
  167. package/src/s3/wire.ts +8 -5
  168. package/src/scenario.ts +34 -13
  169. package/src/serve-http.ts +11 -2
  170. package/src/serve.ts +4 -3
  171. package/src/signing.ts +71 -17
  172. package/src/sigv4.ts +5 -3
  173. package/src/smtp.ts +3 -2
  174. package/src/sockets.ts +8 -3
  175. package/src/state-system.ts +3 -0
  176. package/src/storage.ts +5 -2
  177. package/src/twin-fetch.ts +4 -3
  178. package/src/vendor-call.ts +59 -12
  179. package/test-fixtures/attestation.json +50 -0
@@ -66,7 +66,12 @@ export class RedisStatus {
66
66
  export const OK = new RedisStatus('OK');
67
67
 
68
68
  /** The JSON projection of a RESP reply — what Upstash puts in `{"result": …}`. */
69
- export type RedisValue = null | number | string | RedisStatus | RedisValue[];
69
+ // Redis integers are signed 64-bit on RESP; JSON clients read them as JSON numbers.
70
+ export class RedisInteger {
71
+ constructor(readonly integer: string) {}
72
+ }
73
+ const integerReply = (n: bigint): number | RedisInteger => n >= -9007199254740991n && n <= 9007199254740991n ? Number(n) : new RedisInteger(String(n));
74
+ export type RedisValue = null | number | string | RedisStatus | RedisInteger | RedisValue[];
70
75
 
71
76
  /** A command-level failure. `message` is the vendor's literal error string. Maps to HTTP 400. */
72
77
  export class RedisCommandError extends Error {
@@ -111,8 +116,8 @@ export type KeyRow = {
111
116
  kind: KeyType;
112
117
  /** string → string; list/set → string[]; hash → [f,v][]; zset → [member,score][]; stream → [id,[f,v][]][] */
113
118
  v: string | string[] | HashPairs | ZSetPairs | StreamEntries;
114
- /** Absolute expiry deadline in epoch ms, or null for "no TTL". */
115
- pexpireAt: number | null;
119
+ /** Exact absolute expiry deadline in epoch ms, persisted as decimal digits, or null for "no TTL". */
120
+ pexpireAt: string | null;
116
121
  /** Stream only: the last id this stream minted, so `*` stays monotonic. */
117
122
  lastId?: string;
118
123
  /** Stream only: its consumer groups and counters, as the dialect that serves them keeps them (the redis pack's XGROUP). */
@@ -223,6 +228,7 @@ export const SERVED_COMMANDS: Record<string, [number, number]> = {
223
228
  ZADD: [3, -1], ZINCRBY: [3, 3], ZSCORE: [2, 2], ZCARD: [1, 1], ZCOUNT: [3, 3],
224
229
  ZRANK: [2, 3], ZREVRANK: [2, 3], ZREM: [2, -1], ZREMRANGEBYSCORE: [3, 3], ZREMRANGEBYRANK: [3, 3],
225
230
  ZRANGE: [3, -1], ZREVRANGE: [3, -1], ZRANGEBYSCORE: [3, -1], ZREVRANGEBYSCORE: [3, -1],
231
+ LPOS: [2, -1], RPOPLPUSH: [2, 2], ZPOPMIN: [1, 2], XTRIM: [3, -1],
226
232
  XADD: [4, -1], XLEN: [1, 1], XRANGE: [3, -1], XREVRANGE: [3, -1], XDEL: [2, -1],
227
233
  SCRIPT: [1, -1], EVAL: [2, -1], EVAL_RO: [2, -1], EVALSHA: [2, -1], EVALSHA_RO: [2, -1],
228
234
  };
@@ -234,7 +240,7 @@ const WRITE_COMMANDS = new Set([
234
240
  'EXPIRE', 'PEXPIRE', 'EXPIREAT', 'PEXPIREAT', 'PERSIST', 'RENAME', 'RENAMENX',
235
241
  'FLUSHDB', 'FLUSHALL', 'HSET', 'HSETNX', 'HMSET', 'HDEL', 'HINCRBY', 'HINCRBYFLOAT',
236
242
  'SADD', 'SREM', 'SPOP', 'LPUSH', 'RPUSH', 'LPOP', 'RPOP', 'LREM', 'LSET', 'LTRIM',
237
- 'ZADD', 'ZINCRBY', 'ZREM', 'ZREMRANGEBYSCORE', 'ZREMRANGEBYRANK', 'XADD', 'XDEL',
243
+ 'RPOPLPUSH', 'ZPOPMIN', 'XTRIM', 'ZADD', 'ZINCRBY', 'ZREM', 'ZREMRANGEBYSCORE', 'ZREMRANGEBYRANK', 'XADD', 'XDEL',
238
244
  // NB: 'SCRIPT' is deliberately ABSENT — it is classified per SUBCOMMAND below, because
239
245
  // `SCRIPT EXISTS` is a pure read and a read-only twin must serve every read (§9 round 1 found
240
246
  // it answering 405). Likewise `EVAL_RO`/`EVALSHA_RO` are reads by definition.
@@ -335,7 +341,7 @@ function patchImage(image: KeyspaceImage, changed: readonly TwinResource[], remo
335
341
  name,
336
342
  kind: rec.kind as KeyType,
337
343
  v: rec.v as KeyRow['v'],
338
- pexpireAt: typeof rec.pexpire_at === 'number' ? rec.pexpire_at : null,
344
+ pexpireAt: typeof rec.pexpire_at === 'string' || typeof rec.pexpire_at === 'number' ? String(rec.pexpire_at) : null,
339
345
  ...(typeof rec.last_id === 'string' && rec.last_id !== '' ? { lastId: rec.last_id } : {}),
340
346
  ...(typeof rec._rev === 'number' ? { _rev: rec._rev } : {}),
341
347
  ...(rec.meta !== undefined && rec.meta !== null ? { meta: rec.meta } : {}),
@@ -378,7 +384,7 @@ export class KeySpace {
378
384
  */
379
385
  private live(row: KeyRow | undefined): KeyRow | undefined {
380
386
  if (!row) return undefined;
381
- return row.pexpireAt !== null && row.pexpireAt < this.nowMs ? undefined : row;
387
+ return row.pexpireAt !== null && BigInt(row.pexpireAt) < BigInt(this.nowMs) ? undefined : row;
382
388
  }
383
389
  all(): KeyRow[] { return [...this.rows.values()].filter((r) => this.live(r) !== undefined); }
384
390
  // ── the EVAL script cache ────────────────────────────────────────────────────────────────
@@ -445,7 +451,7 @@ export class KeySpace {
445
451
  }
446
452
 
447
453
  /** One key as a backup holds it: its name, type, value, deadline and a stream's last id. */
448
- export type KeyImage = { name: string; kind: string; v: unknown; pexpireAt: number | null; lastId?: string };
454
+ export type KeyImage = { name: string; kind: string; v: unknown; pexpireAt: string | null; lastId?: string };
449
455
 
450
456
  /** A database's live keys as they stand at `ctx.occurredAt`: what a backup of it holds (the Developer API lane's
451
457
  * backups, ../api/src/semantics/backups.ts). */
@@ -535,12 +541,28 @@ export const storeZSet = (pairs: ZSetPairs): StoredZSet => pairs.map(([m, sc]) =
535
541
  export const asStream = (row: KeyRow | undefined): StreamEntries => { expectType(row, STREAM); expectShape(row, Array.isArray(row?.v)); return row ? (row.v as StreamEntries).map(([id, f]) => [id, f.map(([field, value]) => [field, value] as [string, string])] as [string, HashPairs]) : []; };
536
542
 
537
543
  // ── numeric parsing (Redis's exact acceptance rules) ──────────────────────────────────────────
538
- export function toInt(raw: string): number {
539
- if (raw !== raw.trim() || !/^[+-]?\d+$/.test(raw)) throw new RedisCommandError(NOT_INT);
540
- const n = Number(raw);
541
- if (!Number.isSafeInteger(n)) throw new RedisCommandError(NOT_INT);
544
+ // source: https://raw.githubusercontent.com/redis/redis/c9d29f6a918c335bc1778d9f68e521c1bbb36a0f/src/util.c "Convert a string into a long long."
545
+ export function integerArgument(raw: string, message = NOT_INT, unsigned = false): bigint {
546
+ // source: https://raw.githubusercontent.com/redis/redis/c9d29f6a918c335bc1778d9f68e521c1bbb36a0f/src/util.c "strtoull() is used instead."
547
+ if (unsigned) {
548
+ const canonical = /^(0|[1-9]\d*|-[1-9]\d*)$/.test(raw);
549
+ if (!canonical && !/^[ \t\r\n\v\f]*[+-]?\d+$/.test(raw)) throw new RedisCommandError(message);
550
+ const n = BigInt(raw.trim());
551
+ if (n > 18446744073709551615n || n < -18446744073709551615n || canonical && n < 0n) throw new RedisCommandError(message);
552
+ return BigInt.asUintN(64, n);
553
+ }
554
+ if (!/^(0|[1-9]\d*|-[1-9]\d*)$/.test(raw)) throw new RedisCommandError(message);
555
+ const n = BigInt(raw); if (n < -9223372036854775808n || n > 9223372036854775807n) throw new RedisCommandError(message);
542
556
  return n;
543
557
  }
558
+ // Collection positions/counts are bounded by their actual arrays, not by Number.MAX_SAFE_INTEGER.
559
+ export const commandInteger = (raw: string, message = NOT_INT): number => Number(integerArgument(raw, message));
560
+ export const toInt = commandInteger;
561
+ export const unsignedArgument = (raw: string, message: string): bigint => integerArgument(raw, message, true);
562
+ // source: https://raw.githubusercontent.com/redis/redis/c9d29f6a918c335bc1778d9f68e521c1bbb36a0f/src/object.c "value is out of range, must be positive"
563
+ export function positiveInteger(raw: string, message = 'ERR value is out of range, must be positive'): number {
564
+ const n = integerArgument(raw, message); if (n < 0) throw new RedisCommandError(message); return Number(n);
565
+ }
544
566
  export function toFloat(raw: string): number {
545
567
  if (raw !== raw.trim() || raw === '') throw new RedisCommandError(NOT_FLOAT);
546
568
  if (/^[+-]?inf(inity)?$/i.test(raw)) return raw.startsWith('-') ? Number.NEGATIVE_INFINITY : Number.POSITIVE_INFINITY;
@@ -606,12 +628,13 @@ export function globMatch(pattern: string, subject: string): boolean {
606
628
  }
607
629
 
608
630
  // ── TTL option parsing, shared by SET and GETEX ────────────────────────────────────────────────
609
- function ttlFromToken(token: string, value: string, nowMs: number, cmd: string): number {
610
- const n = toInt(value);
611
- if (token === 'ex') { if (n <= 0) throw new RedisCommandError(`ERR invalid expire time in '${cmd}' command`); return nowMs + n * 1000; }
612
- if (token === 'px') { if (n <= 0) throw new RedisCommandError(`ERR invalid expire time in '${cmd}' command`); return nowMs + n; }
613
- if (token === 'exat') return n * 1000;
614
- return n; // pxat
631
+ function ttlFromToken(token: string, value: string, nowMs: number, cmd: string): string {
632
+ const n = integerArgument(value);
633
+ const millis = token === 'ex' || token === 'exat' ? n * 1000n : n;
634
+ const at = token === 'ex' || token === 'px' ? BigInt(Math.trunc(nowMs)) + millis : millis;
635
+ // source: https://raw.githubusercontent.com/redis/redis/c9d29f6a918c335bc1778d9f68e521c1bbb36a0f/src/t_string.c "Overflow detected."
636
+ if (n <= 0n || millis > 9223372036854775807n || at > 9223372036854775807n) throw new RedisCommandError(`ERR invalid expire time in '${cmd}' command`);
637
+ return String(at);
615
638
  }
616
639
 
617
640
  // ─────────────────────────────────────────────────────────────────────────────────────────────
@@ -630,6 +653,46 @@ export function execOne(space: KeySpace, argv: string[]): RedisValue {
630
653
 
631
654
  switch (name) {
632
655
  // ── connection / server ──────────────────────────────────────────────────────────────────
656
+ // https://redis.io/docs/latest/commands/rpoplpush/: atomically moves the last element to the destination's head.
657
+ case 'RPOPLPUSH': {
658
+ const sourceRow = space.get(a[0]!); const source = asList(sourceRow); asList(space.get(a[1]!));
659
+ const value = source.pop(); if (value === undefined) return null;
660
+ // source: https://raw.githubusercontent.com/redis/redis/c9d29f6a918c335bc1778d9f68e521c1bbb36a0f/src/t_list.c "listTypePush(dstobj,value,where);"
661
+ // A rotation rewrites the existing list, including a singleton, without deleting its key or expiry.
662
+ if (a[0] === a[1]) { source.unshift(value); space.put({ ...sourceRow!, v: source }, 'list.move'); return value; }
663
+ if (source.length) space.put({ ...space.get(a[0]!)!, v: source }, 'list.move'); else space.remove(a[0]!, 'list.move');
664
+ const dest = space.get(a[1]!); const values = asList(dest); values.unshift(value);
665
+ space.put({ name: a[1]!, kind: 'list', v: values, pexpireAt: dest?.pexpireAt ?? null }, 'list.move'); return value;
666
+ }
667
+ // https://redis.io/docs/latest/commands/lpos/: rank, count and maxlen apply to matching positions.
668
+ case 'LPOS': {
669
+ const values = asList(space.get(a[0]!)); let rank = 1, count: number | undefined, max = values.length;
670
+ // source: https://raw.githubusercontent.com/redis/redis/c9d29f6a918c335bc1778d9f68e521c1bbb36a0f/src/t_list.c "COUNT can't be negative"
671
+ for (let i = 2; i < a.length; i += 2) {
672
+ const option = a[i]!.toUpperCase(); if (!['RANK', 'COUNT', 'MAXLEN'].includes(option) || i + 1 >= a.length) throw new RedisCommandError(SYNTAX);
673
+ let value: number; try { value = commandInteger(a[i + 1]!); } catch (error) { if (option === 'RANK') throw error; throw new RedisCommandError(`ERR ${option} can't be negative`); }
674
+ if (option === 'RANK') { if (a[i + 1] === '-9223372036854775808') throw new RedisCommandError('ERR value is out of range, value must between -9223372036854775807 and 9223372036854775807'); if (value === 0) throw new RedisCommandError("ERR RANK can't be zero: use 1 to start from the first match, 2 from the second ... or use negative to start from the end of the list"); rank = value; }
675
+ else { if (value < 0) throw new RedisCommandError(`ERR ${option} can't be negative`); if (option === 'COUNT') count = value; else max = value || values.length; }
676
+ }
677
+ const matches = Array.from({ length: values.length }, (_, i) => i); if (rank < 0) matches.reverse();
678
+ const hits = matches.slice(0, max).filter((i) => values[i] === a[1]).slice(Math.abs(rank) - 1);
679
+ return count === undefined ? hits[0] ?? null : hits.slice(0, count === 0 ? undefined : count);
680
+ }
681
+ // https://redis.io/docs/latest/commands/zpopmin/: lowest score first; ties by member.
682
+ case 'ZPOPMIN': {
683
+ const row = space.get(a[0]!); const pairs = sortZSet(asZSet(row)); let count = 1;
684
+ // source: https://raw.githubusercontent.com/redis/redis/c9d29f6a918c335bc1778d9f68e521c1bbb36a0f/src/object.c "value is out of range, must be positive"
685
+ try { if (a[1] !== undefined) count = commandInteger(a[1]); } catch { throw new RedisCommandError('ERR value is out of range, must be positive'); }
686
+ if (count < 0) throw new RedisCommandError('ERR value is out of range, must be positive');
687
+ const popped = pairs.splice(0, count);
688
+ if (popped.length) { if (pairs.length) space.put({ ...row!, v: storeZSet(sortZSet(pairs)) }, 'zset.pop'); else space.remove(a[0]!, 'zset.pop'); }
689
+ return popped.flatMap(([m, score]) => [m, fmtScore(score)]);
690
+ }
691
+ // https://redis.io/docs/latest/commands/xtrim/: exact trimming also satisfies the approximate upper bound.
692
+ case 'XTRIM': {
693
+ const row = space.get(a[0]!); const entries = asStream(row); const spec = trimSpec(a, 1); if (spec.next !== a.length) throw new RedisCommandError(SYNTAX);
694
+ const kept = trimEntries(entries, spec); if (row && kept.length !== entries.length) space.put({ ...row, v: kept }, 'stream.trim'); return entries.length - kept.length;
695
+ }
633
696
  case 'PING': return a.length === 0 ? new RedisStatus('PONG') : a[0]!;
634
697
  case 'ECHO': return a[0]!;
635
698
  case 'DBSIZE': return visibleKeys(space).length;
@@ -646,8 +709,8 @@ export function execOne(space: KeySpace, argv: string[]): RedisValue {
646
709
  return keys.length === 0 ? null : keys[0]!;
647
710
  }
648
711
  case 'SCAN': {
649
- const cursor = toInt(a[0]!);
650
- if (cursor < 0) throw new RedisCommandError('ERR invalid cursor');
712
+ // SCAN uses Redis's unsigned cursor parser, unlike signed COUNT.
713
+ const cursor = Number(unsignedArgument(a[0]!, 'ERR invalid cursor'));
651
714
  let match: string | null = null;
652
715
  let count = 10;
653
716
  let typeFilter: string | null = null;
@@ -694,36 +757,36 @@ export function execOne(space: KeySpace, argv: string[]): RedisValue {
694
757
  case 'DEL': case 'UNLINK': { let n = 0; for (const k of a) if (space.get(k)) { space.remove(k, 'key.del'); n++; } return n; }
695
758
  case 'EXPIRE': case 'PEXPIRE': case 'EXPIREAT': case 'PEXPIREAT': {
696
759
  const row = space.get(a[0]!);
697
- const n = toInt(a[1]!);
760
+ const n = integerArgument(a[1]!);
698
761
  // Redis 7.0's options (redis.io/docs/latest/commands/expire/, "Options"): NX sets only a key with no expiry, XX
699
762
  // only one with an expiry, GT only a later deadline, LT only an earlier one, a key with no expiry counting as
700
763
  // one that never expires; NX with any other, and GT with LT, are refused
701
764
  const opts = new Set(a.slice(2).map((o) => { const u = o.toUpperCase(); if (!['NX', 'XX', 'GT', 'LT'].includes(u)) throw new RedisCommandError(`ERR Unsupported option ${o}`); return u; }));
702
765
  if (opts.has('NX') && (opts.has('XX') || opts.has('GT') || opts.has('LT'))) throw new RedisCommandError('ERR NX and XX, GT or LT options at the same time are not compatible');
703
766
  if (opts.has('GT') && opts.has('LT')) throw new RedisCommandError('ERR GT and LT options at the same time are not compatible');
767
+ // source: https://raw.githubusercontent.com/redis/redis/c9d29f6a918c335bc1778d9f68e521c1bbb36a0f/src/expire.c "overflow by either unit conversion or basetime addition."
768
+ const millis = name === 'EXPIRE' || name === 'EXPIREAT' ? n * 1000n : n;
769
+ const deadline = name === 'EXPIRE' || name === 'PEXPIRE' ? BigInt(Math.trunc(nowMs)) + millis : millis;
770
+ if (millis < -9223372036854775808n || millis > 9223372036854775807n || deadline > 9223372036854775807n) throw new RedisCommandError(`ERR invalid expire time in '${name.toLowerCase()}' command`);
704
771
  if (!row) return 0;
705
- const at = name === 'EXPIRE' ? nowMs + n * 1000
706
- : name === 'PEXPIRE' ? nowMs + n
707
- : name === 'EXPIREAT' ? n * 1000
708
- : n;
709
772
  if (opts.has('NX') && row.pexpireAt !== null) return 0;
710
773
  if (opts.has('XX') && row.pexpireAt === null) return 0;
711
- if (opts.has('GT') && (row.pexpireAt === null || at <= row.pexpireAt)) return 0;
712
- if (opts.has('LT') && row.pexpireAt !== null && at >= row.pexpireAt) return 0;
774
+ if (opts.has('GT') && (row.pexpireAt === null || deadline <= BigInt(row.pexpireAt))) return 0;
775
+ if (opts.has('LT') && row.pexpireAt !== null && deadline >= BigInt(row.pexpireAt)) return 0;
713
776
  // A deadline already in the past DELETES the key, exactly as Redis does.
714
- if (at <= nowMs) { space.remove(a[0]!, 'key.expire_now'); return 1; }
715
- space.put({ ...row, pexpireAt: at }, 'key.expire');
777
+ if (deadline <= BigInt(nowMs)) { space.remove(a[0]!, 'key.expire_now'); return 1; }
778
+ space.put({ ...row, pexpireAt: String(deadline) }, 'key.expire');
716
779
  return 1;
717
780
  }
718
781
  case 'TTL': case 'PTTL': {
719
782
  const row = space.get(a[0]!);
720
783
  if (!row) return -2;
721
784
  if (row.pexpireAt === null) return -1;
722
- const remainingMs = row.pexpireAt - nowMs;
785
+ const remainingMs = BigInt(row.pexpireAt) - BigInt(nowMs);
723
786
  // Redis computes `(ttl+500)/1000` in integer arithmetic — round HALF-UP, not ceil. With ceil,
724
787
  // a key set `EX 10` and read 600ms later still reported 10 where the vendor says 9 (§9 round
725
788
  // 1); every earlier verify happened to land on an exact second boundary, where they agree.
726
- return name === 'TTL' ? Math.round(remainingMs / 1000) : remainingMs;
789
+ return integerReply(name === 'TTL' ? (remainingMs + 500n) / 1000n : remainingMs);
727
790
  }
728
791
  case 'PERSIST': {
729
792
  const row = space.get(a[0]!);
@@ -731,6 +794,7 @@ export function execOne(space: KeySpace, argv: string[]): RedisValue {
731
794
  space.put({ ...row, pexpireAt: null }, 'key.persist');
732
795
  return 1;
733
796
  }
797
+ // source: https://redis.io/docs/latest/commands/expire/ "the associated time to live is transferred to the new key name"
734
798
  case 'RENAME': case 'RENAMENX': {
735
799
  const row = space.get(a[0]!);
736
800
  if (!row) throw new RedisCommandError(NO_SUCH_KEY);
@@ -749,6 +813,7 @@ export function execOne(space: KeySpace, argv: string[]): RedisValue {
749
813
  if (row) space.remove(a[0]!, 'string.getdel');
750
814
  return v === undefined ? null : v;
751
815
  }
816
+ // source: https://redis.io/docs/latest/commands/getset/ "Any previous time to live associated with the key is discarded"
752
817
  case 'GETSET': {
753
818
  const old = asString(space.get(a[0]!));
754
819
  space.put({ name: a[0]!, kind: 'string', v: a[1]!, pexpireAt: null }, 'string.getset');
@@ -772,7 +837,7 @@ export function execOne(space: KeySpace, argv: string[]): RedisValue {
772
837
  let get = false;
773
838
  let keepTtl = false;
774
839
  let expiryFlag: string | null = null;
775
- let pexpireAt: number | null = null;
840
+ let pexpireAt: string | null = null;
776
841
  for (let i = 2; i < a.length; i++) {
777
842
  // Case-INSENSITIVE by necessity: the official SDK emits the TTL/flag tokens lowercase and
778
843
  // pushes `keepTtl` in literal camelCase (`pkg/commands/set.ts`), which the real server
@@ -801,6 +866,7 @@ export function execOne(space: KeySpace, argv: string[]): RedisValue {
801
866
  // track-sale.ts, …), so getting this wrong turns every distributed lock into a no-op.
802
867
  if (nx && existing) return get ? (old ?? null) : null;
803
868
  if (xx && !existing) return get ? (old ?? null) : null;
869
+ // source: https://redis.io/docs/latest/commands/set/ "Retain the time to live associated with the key"
804
870
  space.put({ name: key, kind: 'string', v: value, pexpireAt: keepTtl ? (existing?.pexpireAt ?? null) : pexpireAt }, 'string.set');
805
871
  return get ? (old ?? null) : OK;
806
872
  }
@@ -810,9 +876,8 @@ export function execOne(space: KeySpace, argv: string[]): RedisValue {
810
876
  return 1;
811
877
  }
812
878
  case 'SETEX': case 'PSETEX': {
813
- const n = toInt(a[1]!);
814
- if (n <= 0) throw new RedisCommandError(`ERR invalid expire time in '${name.toLowerCase()}' command`);
815
- space.put({ name: a[0]!, kind: 'string', v: a[2]!, pexpireAt: nowMs + (name === 'SETEX' ? n * 1000 : n) }, 'string.setex');
879
+ const pexpireAt = ttlFromToken(name === 'SETEX' ? 'ex' : 'px', a[1]!, nowMs, name.toLowerCase());
880
+ space.put({ name: a[0]!, kind: 'string', v: a[2]!, pexpireAt }, 'string.setex');
816
881
  return OK;
817
882
  }
818
883
  case 'MGET': return a.map((k) => { const r = space.get(k); return r && r.kind === 'string' ? (r.v as string) : null; });
@@ -830,16 +895,16 @@ export function execOne(space: KeySpace, argv: string[]): RedisValue {
830
895
  }
831
896
  case 'STRLEN': return (asString(space.get(a[0]!)) ?? '').length;
832
897
  case 'INCR': case 'DECR': case 'INCRBY': case 'DECRBY': {
833
- const by = name === 'INCR' ? 1 : name === 'DECR' ? -1 : toInt(a[1]!) * (name === 'DECRBY' ? -1 : 1);
898
+ const by = name === 'INCR' ? 1n : name === 'DECR' ? -1n : integerArgument(a[1]!) * (name === 'DECRBY' ? -1n : 1n);
834
899
  const row = space.get(a[0]!);
835
900
  const current = asString(row);
836
- const next = (current === undefined ? 0 : toInt(current)) + by;
837
- if (!Number.isSafeInteger(next)) throw new RedisCommandError('ERR increment or decrement would overflow');
901
+ const next = (current === undefined ? 0n : integerArgument(current)) + by;
902
+ if (next < -9223372036854775808n || next > 9223372036854775807n) throw new RedisCommandError('ERR increment or decrement would overflow');
838
903
  // INCR PRESERVES the key's TTL (real Redis semantics). This is exactly what makes
839
904
  // @upstash/ratelimit's fixed-window script correct: the PEXPIRE is armed on the first
840
905
  // increment and every later INCRBY must leave it alone, or the window would never close.
841
906
  space.put({ name: a[0]!, kind: 'string', v: String(next), pexpireAt: row?.pexpireAt ?? null }, 'string.incr');
842
- return next;
907
+ return integerReply(next);
843
908
  }
844
909
  case 'INCRBYFLOAT': {
845
910
  const row = space.get(a[0]!);
@@ -894,13 +959,14 @@ export function execOne(space: KeySpace, argv: string[]): RedisValue {
894
959
  const pairs = asHash(row);
895
960
  const idx = pairs.findIndex(([f]) => f === a[1]);
896
961
  const current = idx < 0 ? undefined : pairs[idx]![1];
897
- const base = current === undefined ? 0 : (isFloat ? toFloat(current) : toInt(current));
898
- const next = isFloat ? guardFloatResult(base + toFloat(a[2]!)) : base + toInt(a[2]!);
899
- if (!isFloat && !Number.isSafeInteger(next)) throw new RedisCommandError('ERR increment or decrement would overflow');
900
- const text = isFloat ? fmtScore(next) : String(next);
962
+ const by = isFloat ? toFloat(a[2]!) : integerArgument(a[2]!);
963
+ const base = current === undefined ? (isFloat ? 0 : 0n) : isFloat ? toFloat(current) : integerArgument(current, 'ERR hash value is not an integer');
964
+ const next = isFloat ? guardFloatResult((base as number) + (by as number)) : (base as bigint) + (by as bigint);
965
+ if (!isFloat && ((next as bigint) < -9223372036854775808n || (next as bigint) > 9223372036854775807n)) throw new RedisCommandError('ERR increment or decrement would overflow');
966
+ const text = isFloat ? fmtScore(next as number) : String(next);
901
967
  if (idx < 0) pairs.push([a[1]!, text]); else pairs[idx] = [a[1]!, text];
902
968
  space.put({ name: a[0]!, kind: 'hash', v: pairs, pexpireAt: row?.pexpireAt ?? null }, 'hash.incrby');
903
- return isFloat ? text : next;
969
+ return isFloat ? text : integerReply(next as bigint);
904
970
  }
905
971
 
906
972
  // ── sets ─────────────────────────────────────────────────────────────────────────────────
@@ -957,10 +1023,9 @@ export function execOne(space: KeySpace, argv: string[]): RedisValue {
957
1023
  return items.length;
958
1024
  }
959
1025
  case 'LPOP': case 'RPOP': {
1026
+ const count = a.length >= 2 ? positiveInteger(a[1]!) : null;
960
1027
  const row = space.get(a[0]!);
961
1028
  const items = asList(row);
962
- const count = a.length >= 2 ? toInt(a[1]!) : null;
963
- if (count !== null && count < 0) throw new RedisCommandError('ERR value is out of range, must be positive');
964
1029
  if (items.length === 0) return null;
965
1030
  const n = count === null ? 1 : count;
966
1031
  const taken = name === 'LPOP' ? items.splice(0, n) : items.splice(Math.max(0, items.length - n)).reverse();
@@ -989,8 +1054,8 @@ export function execOne(space: KeySpace, argv: string[]): RedisValue {
989
1054
  case 'LTRIM': {
990
1055
  const row = space.get(a[0]!);
991
1056
  const items = asList(row);
992
- if (!row) return OK;
993
1057
  const [start, stop] = normalizeRange(toInt(a[1]!), toInt(a[2]!), items.length);
1058
+ if (!row) return OK;
994
1059
  const kept = start > stop ? [] : items.slice(start, stop + 1);
995
1060
  if (kept.length === 0) space.remove(a[0]!, 'list.trim_empty');
996
1061
  else space.put({ ...row, kind: 'list', v: kept }, 'list.trim');
@@ -1126,6 +1191,10 @@ export function execOne(space: KeySpace, argv: string[]): RedisValue {
1126
1191
  throw new RedisCommandError(`ERR Unknown SCRIPT subcommand or wrong number of arguments for '${a[0]}'`);
1127
1192
  }
1128
1193
  case 'EVAL': case 'EVAL_RO': case 'EVALSHA': case 'EVALSHA_RO': {
1194
+ // source: https://raw.githubusercontent.com/redis/redis/c9d29f6a918c335bc1778d9f68e521c1bbb36a0f/src/eval.c "Get the number of arguments that are keys"
1195
+ const numKeys = toInt(a[1]!);
1196
+ if (numKeys < 0) throw new RedisCommandError("ERR Number of keys can't be negative");
1197
+ if (numKeys > a.length - 2) throw new RedisCommandError("ERR Number of keys can't be greater than number of args");
1129
1198
  const bySha = name.startsWith('EVALSHA');
1130
1199
  let script: string;
1131
1200
  if (bySha) {
@@ -1144,9 +1213,6 @@ export function execOne(space: KeySpace, argv: string[]): RedisValue {
1144
1213
  // script, which is the correct read-only outcome rather than a silent write.
1145
1214
  if (!space.ctx.readOnly) storeScript(space, script);
1146
1215
  }
1147
- const numKeys = toInt(a[1]!);
1148
- if (numKeys < 0) throw new RedisCommandError("ERR Number of keys can't be negative");
1149
- if (numKeys > a.length - 2) throw new RedisCommandError("ERR Number of keys can't be greater than number of args");
1150
1216
  return evalScript(space, script, a.slice(2, 2 + numKeys), a.slice(2 + numKeys), name.endsWith('_RO'));
1151
1217
  }
1152
1218
 
@@ -1280,7 +1346,7 @@ export function zrange(space: KeySpace, name: string, a: string[]): RedisValue {
1280
1346
  const hi = parseScoreBound(hiRaw);
1281
1347
  selected = sorted.filter(([, s]) => inScoreRange(s, lo, hi));
1282
1348
  if (_rev) selected = selected.reverse();
1283
- if (limit) selected = limit.count < 0 ? selected.slice(limit.offset) : selected.slice(limit.offset, limit.offset + limit.count);
1349
+ if (limit) selected = limit.offset < 0 ? [] : limit.count < 0 ? selected.slice(limit.offset) : selected.slice(limit.offset, limit.offset + limit.count);
1284
1350
  } else {
1285
1351
  const ordered = _rev ? [...sorted].reverse() : sorted;
1286
1352
  const [start, stop] = normalizeRange(toInt(a[1]!), toInt(a[2]!), ordered.length);
@@ -1291,56 +1357,94 @@ export function zrange(space: KeySpace, name: string, a: string[]): RedisValue {
1291
1357
 
1292
1358
  // ── streams ────────────────────────────────────────────────────────────────────────────────────
1293
1359
  export function compareStreamIds(left: string, right: string): number {
1294
- const [lms = 0, lseq = 0] = left.split('-').map(Number);
1295
- const [rms = 0, rseq = 0] = right.split('-').map(Number);
1360
+ const [lms = 0n, lseq = 0n] = left.split('-').map(BigInt);
1361
+ const [rms = 0n, rseq = 0n] = right.split('-').map(BigInt);
1296
1362
  if (lms !== rms) return lms < rms ? -1 : 1;
1297
1363
  return lseq === rseq ? 0 : lseq < rseq ? -1 : 1;
1298
1364
  }
1299
1365
  export function parseStreamBound(raw: string, side: 'min' | 'max'): string {
1300
1366
  if (raw === '-') return '0-0';
1301
- if (raw === '+') return `${Number.MAX_SAFE_INTEGER}-${Number.MAX_SAFE_INTEGER}`;
1367
+ if (raw === '+') return '18446744073709551615-18446744073709551615';
1302
1368
  const bare = raw.startsWith('(') ? raw.slice(1) : raw;
1303
1369
  if (!/^\d+(-\d+)?$/.test(bare)) throw new RedisCommandError('ERR Invalid stream ID specified as stream command argument');
1304
- return bare.includes('-') ? bare : `${bare}-${side === 'min' ? 0 : Number.MAX_SAFE_INTEGER}`;
1370
+ return bare.includes('-') ? bare : `${bare}-${side === 'min' ? '0' : '18446744073709551615'}`;
1371
+ }
1372
+ // source: https://raw.githubusercontent.com/redis/redis/c9d29f6a918c335bc1778d9f68e521c1bbb36a0f/src/util.c "strtoull() is used instead."
1373
+ // Stream components are uint64; Redis's fallback also accepts a leading plus or zeroes.
1374
+ function streamArgument(raw: string, wildcard = false): string {
1375
+ const at = raw.indexOf('-'); const ms = at < 0 ? raw : raw.slice(0, at), seq = at < 0 ? '0' : raw.slice(at + 1);
1376
+ const part = (v: string): string => String(unsignedArgument(v, 'ERR Invalid stream ID specified as stream command argument'));
1377
+ return `${part(ms)}-${wildcard && seq === '*' ? '*' : part(seq)}`;
1378
+ }
1379
+ // source: https://raw.githubusercontent.com/redis/redis/c9d29f6a918c335bc1778d9f68e521c1bbb36a0f/src/t_stream.c "The LIMIT argument must be >= 0."
1380
+ // MAXLEN/MINID use a strict unsigned threshold; LIMIT is allowed only with approximate trimming.
1381
+ function trimSpec(a: string[], start: number): { strategy: string; threshold: string; limit: number; next: number } {
1382
+ let i = start; const strategy = a[i++]!.toUpperCase(); if (!['MAXLEN', 'MINID'].includes(strategy)) throw new RedisCommandError(SYNTAX);
1383
+ const approximate = a[i] === '~'; if (['~', '='].includes(a[i] ?? '')) i++;
1384
+ if (i >= a.length) throw new RedisCommandError(SYNTAX); let threshold = a[i++]!;
1385
+ if (strategy === 'MAXLEN') { if (commandInteger(threshold) < 0) throw new RedisCommandError('ERR The MAXLEN argument must be >= 0.'); }
1386
+ else threshold = streamArgument(threshold);
1387
+ let limit = Infinity;
1388
+ if (a[i]?.toUpperCase() === 'LIMIT') {
1389
+ if (i + 1 >= a.length) throw new RedisCommandError(SYNTAX); i++; limit = commandInteger(a[i++]!);
1390
+ if (limit < 0) throw new RedisCommandError('ERR The LIMIT argument must be >= 0.');
1391
+ // source: https://raw.githubusercontent.com/redis/redis/c9d29f6a918c335bc1778d9f68e521c1bbb36a0f/src/t_stream.c "syntax error, LIMIT cannot be used without the special ~ option"
1392
+ if (!approximate) throw new RedisCommandError('ERR syntax error, LIMIT cannot be used without the special ~ option');
1393
+ if (limit === 0) limit = Infinity;
1394
+ }
1395
+ return { strategy, threshold, limit, next: i };
1396
+ }
1397
+ function trimEntries(entries: StreamEntries, spec: ReturnType<typeof trimSpec>): StreamEntries {
1398
+ const remove = spec.strategy === 'MAXLEN' ? Math.max(0, entries.length - Number(spec.threshold)) : entries.filter(([id]) => compareStreamIds(id, parseStreamBound(spec.threshold, 'min')) < 0).length;
1399
+ return entries.slice(Math.min(remove, spec.limit));
1305
1400
  }
1306
1401
  function xadd(space: KeySpace, a: string[], nowMs: number): RedisValue {
1307
1402
  let i = 1;
1308
1403
  let noMkStream = false;
1404
+ let trim: ReturnType<typeof trimSpec> | undefined;
1309
1405
  for (; i < a.length; i++) {
1310
1406
  const tok = a[i]!.toLowerCase();
1311
1407
  if (tok === 'nomkstream') { noMkStream = true; continue; }
1312
- // Accepting a trim option and silently NOT trimming would be a fake success, so it is refused.
1313
- if (tok === 'maxlen' || tok === 'minid') throw new RedisCommandError(space.ctx.dialect.unmodeled(`XADD ${tok.toUpperCase()} trimming`));
1408
+ if (tok === 'maxlen' || tok === 'minid') { if (trim) throw new RedisCommandError('ERR syntax error, MAXLEN and MINID options at the same time are not compatible'); trim = trimSpec(a, i); i = trim.next - 1; continue; }
1314
1409
  break;
1315
1410
  }
1316
1411
  const row = space.get(a[0]!);
1317
1412
  if (!row && noMkStream) return null;
1318
1413
  const entries = asStream(row);
1319
- const idSpec = a[i]!;
1414
+ if (row?.lastId === '18446744073709551615-18446744073709551615') throw new RedisCommandError('ERR The stream has exhausted the last possible ID, unable to add more items');
1415
+ const rawId = a[i]; if (rawId === undefined) throw new RedisCommandError(wrongArity('xadd'));
1320
1416
  const fields = a.slice(i + 1);
1321
1417
  if (fields.length === 0 || fields.length % 2 !== 0) throw new RedisCommandError(wrongArity('xadd'));
1418
+ const idSpec = rawId === '*' ? rawId : streamArgument(rawId, true);
1322
1419
  let id: string;
1323
1420
  if (idSpec === '*') {
1324
1421
  // The id's ms part is the REQUEST CLOCK (`occurredAt`), never `Date.now()`. Two entries added
1325
1422
  // in the same pinned millisecond get sequence 0, 1, 2 … exactly as Redis does — which is what
1326
1423
  // makes a stream verify reproducible.
1327
- const [lastMs = 0, lastSeq = 0] = (row?.lastId ?? '0-0').split('-').map(Number);
1328
- id = nowMs > lastMs ? `${nowMs}-0` : `${lastMs}-${lastSeq + 1}`;
1424
+ const [lastMs = 0n, lastSeq = 0n] = (row?.lastId ?? '0-0').split('-').map(BigInt);
1425
+ id = BigInt(nowMs) > lastMs ? `${nowMs}-0` : `${lastMs}-${lastSeq + 1n}`;
1426
+ } else if (/^(0|[1-9]\d*)-\*$/.test(idSpec) && BigInt(idSpec.split('-')[0]!) <= 18446744073709551615n) {
1427
+ const ms = BigInt(idSpec.split('-')[0]!); const [lastMs = 0n, lastSeq = 0n] = (row?.lastId ?? '0-0').split('-').map(BigInt);
1428
+ if (ms < lastMs) throw new RedisCommandError('ERR The ID specified in XADD is equal or smaller than the target stream top item');
1429
+ id = `${ms}-${ms === lastMs ? lastSeq + 1n : 0n}`;
1329
1430
  } else {
1330
1431
  id = idSpec.includes('-') ? idSpec : `${idSpec}-0`;
1331
- if (!/^\d+-\d+$/.test(id)) throw new RedisCommandError('ERR Invalid stream ID specified as stream command argument');
1432
+ if (!/^(0|[1-9]\d*)-(0|[1-9]\d*)$/.test(id) || id.split('-').some((v) => BigInt(v) > 18446744073709551615n)) throw new RedisCommandError('ERR Invalid stream ID specified as stream command argument');
1332
1433
  // Compare against the RETAINED last-id, not merely the last surviving entry: Redis keeps
1333
1434
  // `last_id` past an XDEL, so an id that was used and then deleted stays refused forever. §9
1334
1435
  // round 1 found the entries-only comparison re-accepting `5-5` after `XDEL st 5-5`.
1436
+ if (id === '0-0') throw new RedisCommandError('ERR The ID specified in XADD must be greater than 0-0');
1335
1437
  const top = row?.lastId ?? (entries.length > 0 ? entries[entries.length - 1]![0] : '0-0');
1336
1438
  if (compareStreamIds(id, top) <= 0) {
1337
1439
  throw new RedisCommandError('ERR The ID specified in XADD is equal or smaller than the target stream top item');
1338
1440
  }
1339
1441
  }
1442
+ if (id === '0-0') throw new RedisCommandError('ERR The ID specified in XADD must be greater than 0-0');
1443
+ if (id.split('-').some((v) => BigInt(v) > 18446744073709551615n)) throw new RedisCommandError('ERR The stream has exhausted the last possible ID, unable to add more items');
1340
1444
  const pairs: HashPairs = [];
1341
1445
  for (let j = 0; j < fields.length; j += 2) pairs.push([fields[j]!, fields[j + 1]!]);
1342
1446
  entries.push([id, pairs]);
1343
- space.put({ name: a[0]!, kind: STREAM, v: entries, pexpireAt: row?.pexpireAt ?? null, lastId: id }, 'stream.add');
1447
+ space.put({ name: a[0]!, kind: STREAM, v: trim ? trimEntries(entries, trim) : entries, pexpireAt: row?.pexpireAt ?? null, lastId: id }, 'stream.add');
1344
1448
  return id;
1345
1449
  }
1346
1450
 
@@ -1366,6 +1470,7 @@ function storeScript(space: KeySpace, script: string): string {
1366
1470
  function redisToLua(v: RedisValue): LuaValue {
1367
1471
  if (v === null) return false; // nil bulk reply → Lua `false`
1368
1472
  if (typeof v === 'number' || typeof v === 'string') return v;
1473
+ if (v instanceof RedisInteger) return Number(v.integer);
1369
1474
  if (v instanceof RedisStatus) {
1370
1475
  const t = new LuaTable();
1371
1476
  t.set('ok', v.value); // status reply → Lua table {ok=…}
@@ -1447,7 +1552,7 @@ export type RunItem = { result: RedisValue } | { error: string };
1447
1552
  * an unavailable command or a bad arity — are QUEUE-time and DO discard the whole batch, but the
1448
1553
  * caller (`upstash-twin.ts`) rejects those before this function is ever reached.
1449
1554
  */
1450
- export async function execRedisRun(commands: string[][], ctx: RedisContext): Promise<{ items: RunItem[]; wrote: boolean }> {
1555
+ export async function execRedisRun(commands: string[][], ctx: RedisContext): Promise<{ items: RunItem[]; wrote: boolean; instant: number }> {
1451
1556
  const nowMs = Date.parse(ctx.occurredAt);
1452
1557
  if (!Number.isFinite(nowMs)) throw new RedisCommandError(`ERR twin: unparseable occurredAt '${ctx.occurredAt}'`);
1453
1558
  const space = new KeySpace(ctx, nowMs);
@@ -1463,6 +1568,6 @@ export async function execRedisRun(commands: string[][], ctx: RedisContext): Pro
1463
1568
  }
1464
1569
  const wrote = space.dirty;
1465
1570
  await (space.ctx.dialect.storage?.flush(space) ?? flushKeySpace(space));
1466
- return { items, wrote };
1571
+ return { items, wrote, instant: nowMs };
1467
1572
  }
1468
1573
 
@@ -0,0 +1,53 @@
1
+ import { exactInteger } from '../exact-json.ts';
2
+ // RESP frame parsing and RESP2 replies, shared by the Redis stream and journey tooling.
3
+ // https://redis.io/docs/latest/develop/reference/protocol-spec/
4
+ import { RedisInteger, RedisStatus, type RedisValue } from './engine.ts';
5
+ export const binaryText = (b: Uint8Array): string => Array.from(b, (n) => String.fromCharCode(n)).join('');
6
+ export const binaryBytes = (s: string): Uint8Array => Uint8Array.from(s, (c) => c.charCodeAt(0));
7
+ export type Frame = { value: any; end: number; error?: boolean; type: string };
8
+ export function readResp(s: string, start = 0): Frame | undefined {
9
+ const e = s.indexOf('\r\n', start); if (e < 0) return undefined;
10
+ const tag = s[start]!, head = s.slice(start + 1, e); let at = e + 2;
11
+ if (tag === '+' || tag === '-') return { type: tag, value: head, end: at, ...(tag === '-' ? { error: true } : {}) };
12
+ if (tag === ':') return { type: tag, value: exactInteger(head), end: at };
13
+ if (tag === '_') return { type: tag, value: null, end: at };
14
+ if (tag === '#') return { type: tag, value: head === 't', end: at };
15
+ if (tag === ',') return { type: tag, value: Number(head), end: at };
16
+ const n = Number(head); if (!Number.isSafeInteger(n) || n < -1) throw new Error('Protocol error: invalid length');
17
+ if (tag === '$' || tag === '!') { if (n === -1) return { type: tag, value: null, end: at }; if (s.length < at + n + 2) return undefined; if (s.slice(at + n, at + n + 2) !== '\r\n') throw new Error('Protocol error: invalid bulk terminator'); return { type: tag, value: s.slice(at, at + n), end: at + n + 2, ...(tag === '!' ? { error: true } : {}) }; }
18
+ if (['*', '%', '~', '>'].includes(tag)) { if (n === -1) return { type: tag, value: null, end: at }; const out: any[] = []; for (let i = 0; i < n * (tag === '%' ? 2 : 1); i++) { const f = readResp(s, at); if (!f) return undefined; out.push(f.error ? { error: f.value } : f.value); at = f.end; } return { type: tag, value: tag === '%' ? Object.fromEntries(Array.from({ length: n }, (_, i) => [out[i * 2], out[i * 2 + 1]])) : out, end: at }; }
19
+ throw new Error('Protocol error: invalid type');
20
+ }
21
+ export function respReply(value: RedisValue | { error: string } | Record<string, any>): string {
22
+ if (value instanceof RedisInteger || typeof value === 'object' && value !== null && 'integer' in value) return `:${value.integer}\r\n`;
23
+ if (value === null) return '$-1\r\n';
24
+ if (value instanceof RedisStatus || typeof value === 'object' && !Array.isArray(value) && 'value' in value) return `+${String(value.value).replace(/[\r\n]/g, ' ')}\r\n`;
25
+ if (typeof value === 'object' && !Array.isArray(value) && 'error' in value) return `-${String(value.error).trim().replace(/[\r\n]/g, ' ')}\r\n`;
26
+ if (Array.isArray(value)) return `*${value.length}\r\n${value.map((x) => respReply(x)).join('')}`;
27
+ if (typeof value === 'object') return respReply(Object.entries(value).flat());
28
+ if (typeof value === 'number') return Number.isInteger(value) ? `:${value}\r\n` : respReply(String(value));
29
+ const s = String(value); return `$${s.length}\r\n${s}\r\n`;
30
+ }
31
+ export const commandFrame = (command: string[]): Uint8Array => {
32
+ const args = command.map((s) => binaryText(new TextEncoder().encode(s)));
33
+ return binaryBytes(`*${args.length}\r\n${args.map((s) => `$${s.length}\r\n${s}\r\n`).join('')}`);
34
+ };
35
+ export function inlineCommand(line: string): string[] {
36
+ const words: string[] = []; let word = '', quote = '', active = false;
37
+ for (let i = 0; i < line.length; i++) { const c = line[i]!;
38
+ if (c === '\\' && quote) { const next = line[++i]!; if (next === 'x' && /^[0-9a-f]{2}$/i.test(line.slice(i + 1, i + 3))) { word += String.fromCharCode(parseInt(line.slice(i + 1, i + 3), 16)); i += 2; } else word += ({ n: '\n', r: '\r', t: '\t' } as any)[next] ?? next; }
39
+ else if (quote) { if (c === quote) quote = ''; else word += c; }
40
+ else if (c === '"' || c === "'") { quote = c; active = true; }
41
+ else if (/\s/.test(c)) { if (active) { words.push(word); word = ''; active = false; } }
42
+ else { word += c; active = true; }
43
+ }
44
+ if (quote) throw new Error('Protocol error: unbalanced quotes in request'); if (active) words.push(word); return words;
45
+ }
46
+
47
+ /** Carry RESP integer/status tags across an internal JSON request without losing int64 bytes. */
48
+ export function wireValue(value: any): any {
49
+ if (value instanceof RedisInteger) return { integer: value.integer };
50
+ if (Array.isArray(value)) return value.map(wireValue);
51
+ if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, wireValue(item)]));
52
+ return value;
53
+ }
@@ -4,3 +4,9 @@
4
4
  // docs/contributing/architecture.md, "Other wires: lanes, headers and line protocols".
5
5
  export * from './engine.ts';
6
6
  export * from './lua.ts';
7
+ export * from './resp2.ts';
8
+ export * from './json.ts';
9
+ export * from './member-storage.ts';
10
+ export * from './lua-libs.ts';
11
+ export * from './frames.ts';
12
+ export * from './stream.ts';
@@ -0,0 +1,13 @@
1
+ // JSON envelopes for Redis replies. Integer replies carry decimal digits until the wire writes them;
2
+ // converting them to a Number here would round values outside JavaScript's safe integer range.
3
+ import { RedisInteger } from './engine.ts';
4
+
5
+ /** Serialize a JSON reply envelope, including nested exact Redis integer numeric tokens. */
6
+ export function jsonReply(value: unknown): string {
7
+ if (value instanceof RedisInteger) return value.integer;
8
+ if (Array.isArray(value)) return `[${value.map((item) => jsonReply(item)).join(',')}]`;
9
+ if (value !== null && typeof value === 'object') {
10
+ return `{${Object.entries(value).filter(([, item]) => item !== undefined).map(([key, item]) => `${JSON.stringify(key)}:${jsonReply(item)}`).join(',')}}`;
11
+ }
12
+ return JSON.stringify(value) ?? 'null';
13
+ }