@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.
- package/app-route.cjs +26 -25
- package/app-route.d.cts +1 -0
- package/dist/app-route.cjs +26 -25
- package/dist/app-route.d.cts +1 -0
- package/dist/generated/pack-facts.json +409 -352
- package/dist/inject.cjs +474 -95
- package/dist/network-policy.cjs +110 -13
- package/dist/network-policy.d.cts +8 -3
- package/dist/src/actions.js +2 -1
- package/dist/src/anthropic-wire.d.ts +2 -0
- package/dist/src/anthropic-wire.js +5 -0
- package/dist/src/attestation.d.ts +6 -0
- package/dist/src/attestation.js +27 -0
- package/dist/src/bytes.d.ts +3 -0
- package/dist/src/bytes.js +17 -0
- package/dist/src/changeset.js +2 -1
- package/dist/src/clickhouse/sql.js +73 -14
- package/dist/src/derived-core.d.ts +210 -32
- package/dist/src/derived-core.js +510 -244
- package/dist/src/derived-real.d.ts +1 -1
- package/dist/src/derived-real.js +704 -93
- package/dist/src/derived.d.ts +18 -2
- package/dist/src/derived.js +21 -2
- package/dist/src/events.d.ts +28 -45
- package/dist/src/events.js +40 -58
- package/dist/src/exact-json.d.ts +10 -0
- package/dist/src/exact-json.js +98 -0
- package/dist/src/executor.d.ts +17 -0
- package/dist/src/executor.js +91 -30
- package/dist/src/file-response.js +1 -1
- package/dist/src/form.d.ts +8 -0
- package/dist/src/form.js +76 -0
- package/dist/src/git/format.d.ts +16 -0
- package/dist/src/git/format.js +110 -0
- package/dist/src/git/index.d.ts +1 -0
- package/dist/src/git/index.js +1 -0
- package/dist/src/git/lfs.d.ts +1 -1
- package/dist/src/git/lfs.js +4 -3
- package/dist/src/graphql-wire.d.ts +6 -0
- package/dist/src/graphql-wire.js +6 -3
- package/dist/src/grpc-wire.js +2 -1
- package/dist/src/head.d.ts +1 -0
- package/dist/src/head.js +15 -4
- package/dist/src/held-reads.d.ts +6 -0
- package/dist/src/held-reads.js +60 -0
- package/dist/src/history.js +5 -4
- package/dist/src/host-port.d.ts +5 -0
- package/dist/src/host-port.js +14 -0
- package/dist/src/index.d.ts +11 -3
- package/dist/src/index.js +9 -4
- package/dist/src/log.js +12 -7
- package/dist/src/machines.d.ts +81 -5
- package/dist/src/machines.js +125 -9
- package/dist/src/managed-database.d.ts +15 -11
- package/dist/src/managed-database.js +38 -37
- package/dist/src/multipart.d.ts +21 -3
- package/dist/src/multipart.js +112 -38
- package/dist/src/observe.d.ts +3 -0
- package/dist/src/observe.js +6 -2
- package/dist/src/openai-wire.d.ts +16 -0
- package/dist/src/openai-wire.js +43 -12
- package/dist/src/pack-fetch.d.ts +4 -6
- package/dist/src/pack-fetch.js +189 -57
- package/dist/src/packRegistry.d.ts +7 -1
- package/dist/src/packRegistry.js +20 -3
- package/dist/src/private-transport.d.ts +1 -0
- package/dist/src/private-transport.js +11 -0
- package/dist/src/protobuf.js +10 -4
- package/dist/src/rateBudget.d.ts +8 -1
- package/dist/src/rateBudget.js +1 -0
- package/dist/src/redis/engine.d.ts +14 -5
- package/dist/src/redis/engine.js +271 -72
- package/dist/src/redis/frames.d.ts +17 -0
- package/dist/src/redis/frames.js +123 -0
- package/dist/src/redis/index.d.ts +6 -0
- package/dist/src/redis/index.js +6 -0
- package/dist/src/redis/json.d.ts +2 -0
- package/dist/src/redis/json.js +14 -0
- package/dist/src/redis/lua-libs.d.ts +4 -0
- package/dist/src/redis/lua-libs.js +173 -0
- package/dist/src/redis/lua.d.ts +1 -0
- package/dist/src/redis/lua.js +36 -0
- package/dist/src/redis/member-storage.d.ts +5 -0
- package/dist/src/redis/member-storage.js +75 -0
- package/dist/src/redis/resp2.d.ts +4 -0
- package/dist/src/redis/resp2.js +29 -0
- package/dist/src/redis/stream.d.ts +14 -0
- package/dist/src/redis/stream.js +335 -0
- package/dist/src/remote-execute.d.ts +2 -2
- package/dist/src/request-body.d.ts +11 -0
- package/dist/src/request-body.js +11 -0
- package/dist/src/runtime.d.ts +6 -5
- package/dist/src/runtime.js +5 -5
- package/dist/src/s3/wire.d.ts +4 -4
- package/dist/src/s3/wire.js +5 -3
- package/dist/src/scenario.d.ts +22 -0
- package/dist/src/scenario.js +35 -31
- package/dist/src/serve-http.d.ts +2 -1
- package/dist/src/serve-http.js +10 -1
- package/dist/src/serve.js +7 -4
- package/dist/src/signing.d.ts +23 -5
- package/dist/src/signing.js +74 -11
- package/dist/src/sigv4.d.ts +3 -1
- package/dist/src/sigv4.js +4 -2
- package/dist/src/smtp.js +2 -1
- package/dist/src/sockets.js +8 -3
- package/dist/src/state-system.d.ts +3 -0
- package/dist/src/storage.js +5 -2
- package/dist/src/twin-fetch.d.ts +3 -1
- package/dist/src/twin-fetch.js +3 -2
- package/dist/src/vendor-call.d.ts +10 -3
- package/dist/src/vendor-call.js +58 -14
- package/dist/test-fixtures/attestation.SOURCE.md +1 -0
- package/dist/test-fixtures/attestation.json +50 -0
- package/generated/pack-facts.json +409 -352
- package/inject.cjs +474 -95
- package/network-policy.cjs +110 -13
- package/network-policy.d.cts +8 -3
- package/package.json +11 -4
- package/src/actions.ts +2 -1
- package/src/anthropic-wire.ts +6 -0
- package/src/attestation.ts +23 -0
- package/src/bytes.ts +21 -0
- package/src/changeset.ts +2 -1
- package/src/clickhouse/sql.ts +50 -16
- package/src/derived-core.ts +573 -249
- package/src/derived-real.ts +552 -42
- package/src/derived.ts +23 -4
- package/src/events.ts +54 -85
- package/src/exact-json.ts +71 -0
- package/src/executor.ts +86 -27
- package/src/file-response.ts +1 -1
- package/src/form.ts +66 -0
- package/src/git/format.ts +89 -0
- package/src/git/index.ts +1 -0
- package/src/git/lfs.ts +5 -4
- package/src/graphql-wire.ts +8 -3
- package/src/grpc-wire.ts +2 -1
- package/src/head.ts +15 -5
- package/src/held-reads.ts +54 -0
- package/src/history.ts +5 -4
- package/src/host-port.ts +9 -0
- package/src/index.ts +10 -4
- package/src/log.ts +11 -7
- package/src/machines.ts +135 -13
- package/src/managed-database.ts +48 -40
- package/src/multipart.ts +110 -35
- package/src/observe.ts +6 -3
- package/src/openai-wire.ts +45 -14
- package/src/pack-fetch.ts +173 -50
- package/src/packRegistry.ts +27 -4
- package/src/private-transport.ts +8 -0
- package/src/protobuf.ts +8 -4
- package/src/rateBudget.ts +6 -1
- package/src/redis/engine.ts +168 -63
- package/src/redis/frames.ts +53 -0
- package/src/redis/index.ts +6 -0
- package/src/redis/json.ts +13 -0
- package/src/redis/lua-libs.ts +62 -0
- package/src/redis/lua.ts +20 -0
- package/src/redis/member-storage.ts +58 -0
- package/src/redis/resp2.ts +25 -0
- package/src/redis/stream.ts +102 -0
- package/src/remote-execute.ts +2 -2
- package/src/request-body.ts +12 -0
- package/src/runtime.ts +6 -5
- package/src/s3/wire.ts +8 -5
- package/src/scenario.ts +34 -13
- package/src/serve-http.ts +11 -2
- package/src/serve.ts +4 -3
- package/src/signing.ts +71 -17
- package/src/sigv4.ts +5 -3
- package/src/smtp.ts +3 -2
- package/src/sockets.ts +8 -3
- package/src/state-system.ts +3 -0
- package/src/storage.ts +5 -2
- package/src/twin-fetch.ts +4 -3
- package/src/vendor-call.ts +59 -12
- package/test-fixtures/attestation.json +50 -0
package/src/redis/engine.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
115
|
-
pexpireAt:
|
|
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:
|
|
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
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
if (
|
|
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):
|
|
610
|
-
const n =
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
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
|
-
|
|
650
|
-
|
|
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 =
|
|
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 ||
|
|
712
|
-
if (opts.has('LT') && row.pexpireAt !== null &&
|
|
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 (
|
|
715
|
-
space.put({ ...row, pexpireAt:
|
|
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' ?
|
|
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:
|
|
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
|
|
814
|
-
|
|
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' ?
|
|
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 ?
|
|
837
|
-
if (
|
|
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
|
|
898
|
-
const
|
|
899
|
-
|
|
900
|
-
|
|
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 =
|
|
1295
|
-
const [rms =
|
|
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
|
|
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 :
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
1328
|
-
id = nowMs > lastMs ? `${nowMs}-0` : `${lastMs}-${lastSeq +
|
|
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 (
|
|
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
|
+
}
|
package/src/redis/index.ts
CHANGED
|
@@ -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
|
+
}
|