@ecodrix/erix-client 1.0.5 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -19,9 +19,152 @@
19
19
  * const session = await store.get<{ role: string }>('session:user1')
20
20
  * ```
21
21
  */
22
+ import { HttpTransport } from "./transports/HttpTransport.js";
23
+ import { WebSocketTransport } from "./transports/WebSocketTransport.js";
24
+ export { HttpTransport } from "./transports/HttpTransport.js";
25
+ export { WebSocketTransport } from "./transports/WebSocketTransport.js";
22
26
  // ─── Client ─────────────────────────────────────────────────────────────────
23
27
  export class ErixClient {
24
28
  constructor(options) {
29
+ // ─── Server Commands (Redis-style) ────────────────────────────────────
30
+ this.server = {
31
+ /** Round-trip ping. Resolves to `true` on success. */
32
+ ping: async () => {
33
+ const res = await this.req("GET", "/server/ping");
34
+ return res.pong === true;
35
+ },
36
+ /**
37
+ * Server-wide info — version, memory, total keys, and a per-tenant
38
+ * key breakdown for the calling tenant.
39
+ */
40
+ info: async () => {
41
+ return this.req("GET", "/server/info");
42
+ },
43
+ /** Number of keys in the calling tenant's namespace. */
44
+ dbsize: async () => {
45
+ const res = await this.req("GET", "/server/dbsize");
46
+ return res.size;
47
+ },
48
+ /** Whether `key` exists in the calling tenant's namespace. */
49
+ exists: async (key) => {
50
+ const res = await this.req("GET", "/server/exists", undefined, { key });
51
+ return res.exists;
52
+ },
53
+ /** The data type of `key`, or `null` if it does not exist. */
54
+ type: async (key) => {
55
+ const res = await this.req("GET", "/server/type", undefined, { key });
56
+ return res.type;
57
+ },
58
+ /**
59
+ * List keys matching a glob pattern. Tenant-scoped — never returns
60
+ * keys from other tenants. Default pattern `*` returns every key.
61
+ *
62
+ * Supported glob syntax: `*`, `?`, `[abc]`, `[^abc]`, `[a-z]`, `\\<char>`.
63
+ */
64
+ keys: async (pattern = "*") => {
65
+ const res = await this.req("GET", "/server/keys", undefined, { pattern });
66
+ return res.keys;
67
+ },
68
+ /**
69
+ * Wipe every key in the calling tenant's namespace.
70
+ * Returns the number of keys deleted. There is intentionally no
71
+ * `flushall` — tenants cannot affect each other.
72
+ */
73
+ flushdb: async () => {
74
+ const res = await this.req("POST", "/server/flushdb");
75
+ return res.flushed;
76
+ },
77
+ /**
78
+ * Set a TTL (in seconds) on an existing key.
79
+ * @returns true if the TTL was applied, false if the key didn't exist.
80
+ */
81
+ expire: async (key, ttlSeconds) => {
82
+ const res = await this.req("POST", "/server/expire", {
83
+ key,
84
+ ttl: ttlSeconds,
85
+ });
86
+ return res.applied;
87
+ },
88
+ /**
89
+ * Remaining TTL in seconds. Redis convention:
90
+ * -2 → key does not exist
91
+ * -1 → key exists but has no TTL
92
+ * >0 → seconds remaining
93
+ */
94
+ ttl: async (key) => {
95
+ const res = await this.req("GET", "/server/ttl", undefined, { key });
96
+ return res.ttl;
97
+ },
98
+ /**
99
+ * Remove the TTL from a key. Returns true if a TTL was removed,
100
+ * false if the key did not exist or had no TTL.
101
+ */
102
+ persist: async (key) => {
103
+ const res = await this.req("POST", "/server/persist", { key });
104
+ return res.removed;
105
+ },
106
+ /**
107
+ * Read the slow-command log for the calling tenant. Newest entries
108
+ * first. Pass `count` to cap the result.
109
+ *
110
+ * Each entry's `args` are truncated server-side (max 32 args, 128
111
+ * chars each) so an MSET/HSET with a giant payload doesn't bloat
112
+ * the log buffer.
113
+ */
114
+ slowlogGet: async (count = 128) => {
115
+ return this.cmd("SLOWLOG", "GET", count);
116
+ },
117
+ /** Number of slowlog entries currently stored for this tenant. */
118
+ slowlogLen: async () => {
119
+ return this.cmd("SLOWLOG", "LEN");
120
+ },
121
+ /** Drop this tenant's slowlog entries. Returns the number dropped. */
122
+ slowlogReset: async () => {
123
+ return this.cmd("SLOWLOG", "RESET");
124
+ },
125
+ /**
126
+ * Read runtime tunables. Pass a glob pattern to filter; default `*`
127
+ * returns every registered parameter.
128
+ *
129
+ * @example
130
+ * const all = await client.server.configGet();
131
+ * const memory = await client.server.configGet("max*");
132
+ */
133
+ configGet: async (pattern = "*") => {
134
+ return this.cmd("CONFIG", "GET", pattern);
135
+ },
136
+ /**
137
+ * Update a runtime tunable.
138
+ *
139
+ * Server-side, this command is only accepted from the calling tenant
140
+ * if it matches the server's `ERIX_ADMIN_TENANT_ID`. Other tenants
141
+ * receive an error result. Resolves to `true` on success and throws
142
+ * on failure (matching `client.cmd` semantics).
143
+ */
144
+ configSet: async (name, value) => {
145
+ await this.cmd("CONFIG", "SET", name, String(value));
146
+ return true;
147
+ },
148
+ /**
149
+ * Trigger a non-blocking snapshot save (admin tenant only).
150
+ * Resolves immediately with the server's reply once the work is
151
+ * dispatched — actual persistence happens in the background.
152
+ */
153
+ bgsave: async () => {
154
+ return this.cmd("BGSAVE");
155
+ },
156
+ /**
157
+ * Rewrite the AOF in place (admin tenant only).
158
+ *
159
+ * The server walks the live in-memory state and emits a compact
160
+ * sequence of writes equivalent to it, then atomically replaces
161
+ * the on-disk log. The verb is admin-gated because the rewrite
162
+ * cost is bounded by the number of live keys.
163
+ */
164
+ bgrewriteaof: async () => {
165
+ return this.cmd("BGREWRITEAOF");
166
+ },
167
+ };
25
168
  // ─── Hash ─────────────────────────────────────────────────────────────
26
169
  this.hash = {
27
170
  /** Set a field in a hash */
@@ -49,22 +192,74 @@ export class ErixClient {
49
192
  const data = await this.req("GET", "/hash/hgetall", undefined, { key });
50
193
  return data.data;
51
194
  },
195
+ /** Set multiple fields atomically (HMSET). */
196
+ hmset: async (key, fields) => {
197
+ const args = [key];
198
+ for (const [f, v] of Object.entries(fields)) {
199
+ args.push(f, JSON.stringify(v));
200
+ }
201
+ await this.cmd("HMSET", ...args);
202
+ },
203
+ /** Get multiple fields at once. Missing fields return `null`. */
204
+ hmget: async (key, fields) => {
205
+ const raw = await this.cmd("HMGET", key, ...fields);
206
+ return raw.map((v) => {
207
+ if (v === null)
208
+ return null;
209
+ try {
210
+ return JSON.parse(v);
211
+ }
212
+ catch {
213
+ return v;
214
+ }
215
+ });
216
+ },
217
+ /** Whether `field` exists in the hash. */
218
+ hexists: async (key, field) => {
219
+ const r = await this.cmd("HEXISTS", key, field);
220
+ return r === 1;
221
+ },
222
+ /** All field names in the hash (no order guarantee). */
223
+ hkeys: async (key) => {
224
+ return this.cmd("HKEYS", key);
225
+ },
226
+ /** All field values in the hash (no order guarantee). */
227
+ hvals: async (key) => {
228
+ return this.cmd("HVALS", key);
229
+ },
230
+ /** Number of fields in the hash. */
231
+ hlen: async (key) => {
232
+ return this.cmd("HLEN", key);
233
+ },
234
+ /** Delete one or more fields. Returns the count actually removed. */
235
+ hdel: async (key, ...fields) => {
236
+ return this.cmd("HDEL", key, ...fields);
237
+ },
238
+ /**
239
+ * Atomic increment of a hash field by `delta`. Creates the field
240
+ * at `delta` when missing. Throws on non-integer values.
241
+ */
242
+ hincrBy: async (key, field, delta) => {
243
+ return this.cmd("HINCRBY", key, field, delta);
244
+ },
52
245
  };
53
246
  // ─── List ─────────────────────────────────────────────────────────────
54
247
  this.list = {
55
- /** Prepend a value to a list */
248
+ /** Prepend a value to a list. Returns the new list length. */
56
249
  lpush: async (key, value) => {
57
- await this.req("POST", "/list/lpush", {
250
+ const res = await this.req("POST", "/list/lpush", {
58
251
  key,
59
252
  value: JSON.stringify(value),
60
253
  });
254
+ return res.length;
61
255
  },
62
- /** Append a value to a list */
256
+ /** Append a value to a list. Returns the new list length. */
63
257
  rpush: async (key, value) => {
64
- await this.req("POST", "/list/rpush", {
258
+ const res = await this.req("POST", "/list/rpush", {
65
259
  key,
66
260
  value: JSON.stringify(value),
67
261
  });
262
+ return res.length;
68
263
  },
69
264
  /** Pop from the left (FIFO front) */
70
265
  lpop: async (key) => {
@@ -78,16 +273,148 @@ export class ErixClient {
78
273
  return data.value;
79
274
  }
80
275
  },
276
+ /** Pop from the right (LIFO back) */
277
+ rpop: async (key) => {
278
+ const data = await this.req("GET", "/list/rpop", undefined, { key });
279
+ if (!data.value)
280
+ return null;
281
+ try {
282
+ return JSON.parse(data.value);
283
+ }
284
+ catch {
285
+ return data.value;
286
+ }
287
+ },
288
+ /** List length. Returns 0 for missing keys. */
289
+ llen: async (key) => {
290
+ const data = await this.req("GET", "/list/llen", undefined, { key });
291
+ return data.length;
292
+ },
293
+ /**
294
+ * Element at `index` (negative indices count from the tail).
295
+ * Returns `null` if out of bounds.
296
+ */
297
+ lindex: async (key, index) => {
298
+ const data = await this.req("GET", "/list/lindex", undefined, { key, index: String(index) });
299
+ if (!data.value)
300
+ return null;
301
+ try {
302
+ return JSON.parse(data.value);
303
+ }
304
+ catch {
305
+ return data.value;
306
+ }
307
+ },
308
+ /**
309
+ * Inclusive range from `start` to `stop`, supporting negative indices
310
+ * (e.g. -1 = last element). Values stored as JSON are parsed; raw strings
311
+ * pass through unchanged.
312
+ */
313
+ lrange: async (key, start, stop) => {
314
+ const data = await this.req("GET", "/list/lrange", undefined, { key, start: String(start), stop: String(stop) });
315
+ return data.values.map((v) => {
316
+ try {
317
+ return JSON.parse(v);
318
+ }
319
+ catch {
320
+ return v;
321
+ }
322
+ });
323
+ },
324
+ /**
325
+ * Remove up to `count` occurrences of `value`.
326
+ * count > 0 → remove from head
327
+ * count < 0 → remove from tail
328
+ * count = 0 → remove all
329
+ * Returns the number of elements removed.
330
+ */
331
+ lrem: async (key, count, value) => {
332
+ const data = await this.req("POST", "/list/lrem", {
333
+ key,
334
+ count,
335
+ value: JSON.stringify(value),
336
+ });
337
+ return data.removed;
338
+ },
339
+ /**
340
+ * Trim the list to the inclusive range [start, stop].
341
+ * Negative indices count from the tail. Drops the key if the range
342
+ * leaves the list empty.
343
+ */
344
+ ltrim: async (key, start, stop) => {
345
+ await this.req("POST", "/list/ltrim", { key, start, stop });
346
+ },
81
347
  };
82
348
  // ─── Queue (FIFO job queue, built on List) ────────────────────────────
83
349
  this.queue = {
84
- /** Enqueue a job payload into a named queue (simple list-based) */
350
+ /**
351
+ * Enqueue a value with optional priority. Higher priority dequeues first;
352
+ * ties resolve FIFO.
353
+ *
354
+ * The value is JSON-serialized server-side; pass any JSON-compatible
355
+ * shape and `dequeue` returns the parsed result.
356
+ *
357
+ * @returns the new queue length
358
+ */
359
+ enqueue: async (name, value, priority = 0) => {
360
+ const res = await this.req("POST", "/q/enqueue", { name, value: JSON.stringify(value), priority });
361
+ return res.length;
362
+ },
363
+ /**
364
+ * Pop the highest-priority entry. Returns `null` when the queue is empty.
365
+ */
366
+ dequeue: async (name) => {
367
+ const res = await this.req("POST", "/q/dequeue", { name });
368
+ if (res.value === null)
369
+ return null;
370
+ try {
371
+ return JSON.parse(res.value);
372
+ }
373
+ catch {
374
+ return res.value;
375
+ }
376
+ },
377
+ /** Inspect the next entry without removing it. */
378
+ peek: async (name) => {
379
+ const res = await this.req("GET", "/q/peek", undefined, { name });
380
+ if (res.value === null)
381
+ return null;
382
+ try {
383
+ return JSON.parse(res.value);
384
+ }
385
+ catch {
386
+ return res.value;
387
+ }
388
+ },
389
+ /** Number of entries in the queue. 0 for empty/missing queues. */
390
+ len: async (name) => {
391
+ const res = await this.req("GET", "/q/len", undefined, { name });
392
+ return res.length;
393
+ },
394
+ /** Drop every entry. Returns the number cleared. */
395
+ clear: async (name) => {
396
+ const res = await this.req("POST", "/q/clear", { name });
397
+ return res.cleared;
398
+ },
399
+ /**
400
+ * Legacy push — kept for source compatibility with SDK 1.2.x. Forwards
401
+ * to {@link enqueue} with priority 0. Prefer `enqueue` in new code.
402
+ * @deprecated Use `enqueue` instead.
403
+ */
85
404
  push: async (name, data) => {
86
- await this.list.rpush(`q:${name}`, data);
405
+ await this.req("POST", "/q/enqueue", {
406
+ name,
407
+ value: JSON.stringify(data),
408
+ priority: 0,
409
+ });
87
410
  },
88
- /** Dequeue the oldest job from a named queue. Returns `null` if empty. */
411
+ /**
412
+ * Legacy pop — kept for source compatibility with SDK 1.2.x. Forwards
413
+ * to {@link dequeue}. Prefer `dequeue` in new code.
414
+ * @deprecated Use `dequeue` instead.
415
+ */
89
416
  pop: async (name) => {
90
- return this.list.lpop(`q:${name}`);
417
+ return this.queue.dequeue(name);
91
418
  },
92
419
  };
93
420
  /** Advanced Queue (v2) — supports priority, delay, retries, heartbeat, and SSE push */
@@ -160,9 +487,14 @@ export class ErixClient {
160
487
  };
161
488
  // ─── PubSub ───────────────────────────────────────────────────────────
162
489
  this.pubsub = {
163
- /** Publish a message to a channel */
490
+ /**
491
+ * Publish a message to a channel.
492
+ * @returns the number of subscribers that received the message
493
+ * (exact + pattern combined).
494
+ */
164
495
  publish: async (channel, message) => {
165
- await this.req("POST", "/pubsub/publish", { channel, message });
496
+ const res = await this.req("POST", "/pubsub/publish", { channel, message });
497
+ return res.delivered;
166
498
  },
167
499
  /**
168
500
  * Subscribe to a pub/sub channel via Server-Sent Events.
@@ -175,6 +507,43 @@ export class ErixClient {
175
507
  subscribe: (channel, onMessage, onError) => {
176
508
  return this.openEventStream(`/pubsub/${encodeURIComponent(channel)}/stream`, (_event, data) => onMessage(data), onError);
177
509
  },
510
+ /**
511
+ * Pattern subscribe (PSUBSCRIBE). The handler is invoked once per
512
+ * message, with the matched channel name (tenant prefix stripped).
513
+ *
514
+ * Supports the same glob syntax as `client.server.keys`:
515
+ * `*`, `?`, `[abc]`, `[^abc]`, `[a-z]`, `\\<char>`.
516
+ *
517
+ * @example
518
+ * const sub = client.pubsub.psubscribe("alerts:*", (msg, channel) => {
519
+ * console.log(`[${channel}]`, msg);
520
+ * });
521
+ * sub.close();
522
+ */
523
+ psubscribe: (pattern, onMessage, onError) => {
524
+ return this.openEventStream(`/pubsub/p/${encodeURIComponent(pattern)}/stream`, (_event, data) => {
525
+ const payload = data;
526
+ onMessage(payload.message, payload.channel);
527
+ }, onError);
528
+ },
529
+ /**
530
+ * List exact-subscriber channels in this tenant's namespace,
531
+ * optionally filtered by a glob pattern (default `*`).
532
+ */
533
+ channels: async (pattern = "*") => {
534
+ const res = await this.req("GET", "/pubsub/channels", undefined, { pattern });
535
+ return res.channels;
536
+ },
537
+ /** Per-channel exact-subscriber counts (0 for channels with none). */
538
+ numsub: async (channels) => {
539
+ const res = await this.req("POST", "/pubsub/numsub", { channels });
540
+ return res.counts;
541
+ },
542
+ /** Number of distinct active patterns (server-wide; matches Redis). */
543
+ numpat: async () => {
544
+ const res = await this.req("GET", "/pubsub/numpat");
545
+ return res.count;
546
+ },
178
547
  };
179
548
  // ─── Set ──────────────────────────────────────────────────────────────
180
549
  this.set_ = {
@@ -187,6 +556,74 @@ export class ErixClient {
187
556
  const data = await this.req("GET", "/set/smembers", undefined, { key });
188
557
  return data.members;
189
558
  },
559
+ /** Remove one or more members. Returns the count actually removed. */
560
+ srem: async (key, ...members) => {
561
+ return this.cmd("SREM", key, ...members);
562
+ },
563
+ /** Whether `member` is in the set. */
564
+ sismember: async (key, member) => {
565
+ const r = await this.cmd("SISMEMBER", key, member);
566
+ return r === 1;
567
+ },
568
+ /** Number of members in the set. */
569
+ scard: async (key) => {
570
+ return this.cmd("SCARD", key);
571
+ },
572
+ /** Intersection across the supplied sets. */
573
+ sinter: async (...keys) => {
574
+ return this.cmd("SINTER", ...keys);
575
+ },
576
+ /** Union across the supplied sets. */
577
+ sunion: async (...keys) => {
578
+ return this.cmd("SUNION", ...keys);
579
+ },
580
+ /** Members in the first set that are not in any of the others. */
581
+ sdiff: async (...keys) => {
582
+ return this.cmd("SDIFF", ...keys);
583
+ },
584
+ };
585
+ // ─── Sorted Set ───────────────────────────────────────────────────────
586
+ this.zset = {
587
+ /** Add or update a member's score. Returns the count of newly added members. */
588
+ zadd: async (key, score, member) => {
589
+ return this.cmd("ZADD", key, score, member);
590
+ },
591
+ /** Inclusive range by 0-based rank (lowest score first). Negative indices supported. */
592
+ zrange: async (key, start, stop) => {
593
+ return this.cmd("ZRANGE", key, start, stop);
594
+ },
595
+ /** Same as zrange but in descending score order. */
596
+ zrevrange: async (key, start, stop) => {
597
+ return this.cmd("ZREVRANGE", key, start, stop);
598
+ },
599
+ /** Members whose score is in [min, max], ascending. */
600
+ zrangeByScore: async (key, min, max) => {
601
+ return this.cmd("ZRANGEBYSCORE", key, min, max);
602
+ },
603
+ /** Score for a member; `null` if missing. */
604
+ zscore: async (key, member) => {
605
+ return this.cmd("ZSCORE", key, member);
606
+ },
607
+ /** 0-based rank of `member`; `null` if missing. */
608
+ zrank: async (key, member) => {
609
+ return this.cmd("ZRANK", key, member);
610
+ },
611
+ /** Number of members in the sorted set. */
612
+ zcard: async (key) => {
613
+ return this.cmd("ZCARD", key);
614
+ },
615
+ /** Number of members whose score is in [min, max]. */
616
+ zcount: async (key, min, max) => {
617
+ return this.cmd("ZCOUNT", key, min, max);
618
+ },
619
+ /** Increment a member's score by `delta`. Returns the new score. */
620
+ zincrBy: async (key, delta, member) => {
621
+ return this.cmd("ZINCRBY", key, delta, member);
622
+ },
623
+ /** Remove one or more members. Returns the count actually removed. */
624
+ zrem: async (key, ...members) => {
625
+ return this.cmd("ZREM", key, ...members);
626
+ },
190
627
  };
191
628
  // ─── Cache (advanced) ────────────────────────────────────────────────
192
629
  this.cache = {
@@ -323,39 +760,137 @@ export class ErixClient {
323
760
  };
324
761
  this.baseUrl = options.baseUrl.replace(/\/$/, ""); // strip trailing slash
325
762
  this.timeoutMs = options.timeoutMs ?? 5000;
763
+ this.transportMode = options.transport ?? "auto";
326
764
  this.headers = {
327
765
  "x-erix-key": options.apiKey,
328
766
  "x-tenant-id": options.tenantId,
329
767
  "Content-Type": "application/json",
330
768
  };
769
+ // Initialize transport based on mode
770
+ this.transport = this.createTransport();
331
771
  }
332
- // ── Internal fetch helper ─────────────────────────────────────────────
333
- async req(method, path, body, params) {
334
- const url = new URL(`${this.baseUrl}${path}`);
335
- if (params) {
336
- for (const [k, v] of Object.entries(params)) {
337
- url.searchParams.set(k, v);
338
- }
772
+ /**
773
+ * Create the appropriate transport based on the configured mode.
774
+ * In "auto" mode, starts with WebSocket and falls back to HTTP on failure.
775
+ */
776
+ createTransport() {
777
+ const httpTransport = new HttpTransport({
778
+ baseUrl: this.baseUrl,
779
+ headers: this.headers,
780
+ timeoutMs: this.timeoutMs,
781
+ });
782
+ if (this.transportMode === "http") {
783
+ return httpTransport;
339
784
  }
340
- const controller = new AbortController();
341
- const timer = setTimeout(() => controller.abort(), this.timeoutMs);
785
+ // For "ws" and "auto" modes, create a WebSocket transport
786
+ const wsUrl = this.baseUrl
787
+ .replace(/^https:/, "wss:")
788
+ .replace(/^http:/, "ws:");
342
789
  try {
343
- const res = await fetch(url.toString(), {
344
- method,
790
+ const wsTransport = new WebSocketTransport({
791
+ url: wsUrl,
345
792
  headers: this.headers,
346
- body: body !== undefined ? JSON.stringify(body) : undefined,
347
- signal: controller.signal,
793
+ timeoutMs: this.timeoutMs,
794
+ reconnect: {
795
+ initialDelayMs: 1000,
796
+ maxDelayMs: 30000,
797
+ backoffFactor: 2,
798
+ },
348
799
  });
349
- if (!res.ok) {
350
- const err = (await res.json().catch(() => ({})));
351
- throw new Error(`[erix-store] ${method} ${path} → ${res.status}: ${err.error ?? res.statusText}`);
800
+ if (this.transportMode === "ws") {
801
+ return wsTransport;
352
802
  }
353
- return (await res.json());
803
+ // "auto" mode: use WebSocket with HTTP as fallback
804
+ return new AutoTransport(wsTransport, httpTransport);
354
805
  }
355
- finally {
356
- clearTimeout(timer);
806
+ catch {
807
+ // If WebSocket creation fails in "auto" mode, fall back to HTTP
808
+ if (this.transportMode === "auto") {
809
+ return httpTransport;
810
+ }
811
+ throw new Error("[erix-store] Failed to create WebSocket transport in 'ws' mode");
357
812
  }
358
813
  }
814
+ // ── Internal fetch helper ─────────────────────────────────────────────
815
+ async req(method, path, body, params) {
816
+ return this.transport.request(method, path, body, params);
817
+ }
818
+ /**
819
+ * Execute multiple operations in a single round-trip (pipeline).
820
+ * Over WebSocket, this sends all requests in one frame.
821
+ * Over HTTP, requests are executed concurrently via Promise.all.
822
+ */
823
+ async pipeline(requests) {
824
+ return this.transport.pipeline(requests);
825
+ }
826
+ /**
827
+ * Begin a new transaction.
828
+ *
829
+ * Returns a builder you can queue commands onto with `.cmd(name, ...args)`,
830
+ * then send with `.exec()`. Every queued command runs atomically inside
831
+ * a single event-loop tick on the server — no other client request can
832
+ * interleave between them.
833
+ *
834
+ * Per-command failures are captured into the result array but do not
835
+ * abort the batch (matching Redis MULTI/EXEC).
836
+ *
837
+ * @example
838
+ * ```ts
839
+ * const results = await client
840
+ * .multi()
841
+ * .cmd("SET", "k", "v")
842
+ * .cmd("EXPIRE", "k", 60)
843
+ * .cmd("GET", "k")
844
+ * .exec();
845
+ * // → [{ ok: true, value: "OK" }, { ok: true, value: 1 }, { ok: true, value: "v" }]
846
+ * ```
847
+ */
848
+ multi() {
849
+ return new Transaction(this);
850
+ }
851
+ /** Alias for {@link multi}. */
852
+ tx() {
853
+ return this.multi();
854
+ }
855
+ /** @internal — called by Transaction.exec(). Keeps tx wire format private. */
856
+ async _execTransaction(commands) {
857
+ const res = await this.req("POST", "/tx/exec", { commands });
858
+ return res.results;
859
+ }
860
+ /**
861
+ * Run a single Redis-style command synchronously.
862
+ *
863
+ * Lightweight escape hatch for verbs we have not surfaced as named
864
+ * methods. Forwards the call through `/tx/exec` as a one-element
865
+ * transaction and unwraps the per-command result.
866
+ *
867
+ * Throws on per-command errors (`WRONGTYPE`, unknown verb, validation)
868
+ * so it composes naturally with `try`/`catch`. For multi-command
869
+ * batches use {@link multi} instead — that surface keeps each command's
870
+ * error isolated.
871
+ *
872
+ * @example
873
+ * ```ts
874
+ * const newLen = await client.cmd<number>("APPEND", "log", "entry");
875
+ * const all = await client.cmd<Record<string,string>>("HGETALL", "user");
876
+ * ```
877
+ */
878
+ async cmd(name, ...args) {
879
+ const [result] = await this._execTransaction([{ name, args }]);
880
+ if (!result.ok) {
881
+ const err = new Error(`[erix-store] ${name}: ${result.error}`);
882
+ if (result.code)
883
+ err.code = result.code;
884
+ throw err;
885
+ }
886
+ return result.value;
887
+ }
888
+ /**
889
+ * Close the transport and release resources.
890
+ */
891
+ close() {
892
+ this.transport.close();
893
+ }
359
894
  // ─── Health ───────────────────────────────────────────────────────────
360
895
  /** Check if the erix-store instance is reachable */
361
896
  async ping() {
@@ -391,6 +926,81 @@ export class ErixClient {
391
926
  async del(key) {
392
927
  await this.req("DELETE", "/core/del", { key });
393
928
  }
929
+ /**
930
+ * Atomic increment. Creates the counter at 1 when missing.
931
+ * Throws on non-integer values or overflow.
932
+ */
933
+ async incr(key) {
934
+ return this.cmd("INCR", key);
935
+ }
936
+ /** Atomic decrement. Creates the counter at -1 when missing. */
937
+ async decr(key) {
938
+ return this.cmd("DECR", key);
939
+ }
940
+ /** Atomic increment by `delta`. */
941
+ async incrBy(key, delta) {
942
+ return this.cmd("INCRBY", key, delta);
943
+ }
944
+ /** Atomic decrement by `delta`. */
945
+ async decrBy(key, delta) {
946
+ return this.cmd("DECRBY", key, delta);
947
+ }
948
+ /** Append `value` to the existing string at `key`. Returns the new length. */
949
+ async append(key, value) {
950
+ return this.cmd("APPEND", key, value);
951
+ }
952
+ /** Length of the string at `key`. 0 for missing keys. */
953
+ async strlen(key) {
954
+ return this.cmd("STRLEN", key);
955
+ }
956
+ /**
957
+ * Set multiple keys atomically. Either all writes succeed or none do
958
+ * (e.g. on WRONGTYPE for any of the target keys). Clears any prior TTL.
959
+ */
960
+ async mset(entries) {
961
+ const args = [];
962
+ for (const [k, v] of Object.entries(entries)) {
963
+ args.push(k, JSON.stringify(v));
964
+ }
965
+ await this.cmd("MSET", ...args);
966
+ }
967
+ /** Get multiple keys at once. Missing/non-string keys come back as `null`. */
968
+ async mget(keys) {
969
+ const raw = await this.cmd("MGET", ...keys);
970
+ return raw.map((v) => {
971
+ if (v === null)
972
+ return null;
973
+ try {
974
+ return JSON.parse(v);
975
+ }
976
+ catch {
977
+ return v;
978
+ }
979
+ });
980
+ }
981
+ /**
982
+ * Atomic compare-and-set: returns the previous value (or `null`) and
983
+ * stores `value`. Clears any prior TTL.
984
+ */
985
+ async getSet(key, value) {
986
+ const prev = await this.cmd("GETSET", key, JSON.stringify(value));
987
+ if (prev === null)
988
+ return null;
989
+ try {
990
+ return JSON.parse(prev);
991
+ }
992
+ catch {
993
+ return prev;
994
+ }
995
+ }
996
+ /**
997
+ * Set only if the key does not already exist. Returns true on success,
998
+ * false when the key was already taken (any type — no WRONGTYPE).
999
+ */
1000
+ async setNx(key, value) {
1001
+ const r = await this.cmd("SETNX", key, JSON.stringify(value));
1002
+ return r === 1;
1003
+ }
394
1004
  // ─── Rate Limiter ─────────────────────────────────────────────────────
395
1005
  /**
396
1006
  * Check if an operation is within rate limits.
@@ -462,3 +1072,96 @@ export class ErixClient {
462
1072
  return { close: () => controller.abort() };
463
1073
  }
464
1074
  }
1075
+ /**
1076
+ * Builder returned by {@link ErixClient.multi}.
1077
+ *
1078
+ * Chain `.cmd(name, ...args)` for each command, then `.exec()` to run
1079
+ * them atomically server-side, or `.discard()` to drop the batch
1080
+ * locally without sending a request.
1081
+ */
1082
+ export class Transaction {
1083
+ constructor(client) {
1084
+ this.client = client;
1085
+ this.commands = [];
1086
+ this.finalized = false;
1087
+ }
1088
+ /** Number of commands queued. */
1089
+ get size() {
1090
+ return this.commands.length;
1091
+ }
1092
+ /**
1093
+ * Queue a command. Returns `this` for chaining.
1094
+ *
1095
+ * The verb is validated server-side; unknown verbs come back as a
1096
+ * per-command error in the result array, not a thrown exception.
1097
+ */
1098
+ cmd(name, ...args) {
1099
+ if (this.finalized) {
1100
+ throw new Error("[erix-store] Transaction already finalized — create a new one with client.multi()");
1101
+ }
1102
+ this.commands.push({ name, args });
1103
+ return this;
1104
+ }
1105
+ /** Drop the queued commands locally. No server request is issued. */
1106
+ discard() {
1107
+ this.finalized = true;
1108
+ this.commands = [];
1109
+ }
1110
+ /**
1111
+ * Send the queued commands to the server and run them atomically.
1112
+ *
1113
+ * Per-command errors are captured into the result array; the call as
1114
+ * a whole still resolves successfully unless the network or auth
1115
+ * layer fails.
1116
+ */
1117
+ async exec() {
1118
+ if (this.finalized) {
1119
+ throw new Error("[erix-store] Transaction already finalized");
1120
+ }
1121
+ this.finalized = true;
1122
+ return this.client._execTransaction(this.commands);
1123
+ }
1124
+ }
1125
+ // ─── AutoTransport (internal) ────────────────────────────────────────────
1126
+ /**
1127
+ * Internal transport that tries WebSocket first and falls back to HTTP
1128
+ * if the WebSocket is not connected. Used in "auto" mode.
1129
+ */
1130
+ class AutoTransport {
1131
+ constructor(ws, http) {
1132
+ this.ws = ws;
1133
+ this.http = http;
1134
+ }
1135
+ get isConnected() {
1136
+ return this.ws.isConnected || this.http.isConnected;
1137
+ }
1138
+ async request(method, path, body, params) {
1139
+ // If WebSocket is connected, use it
1140
+ if (this.ws.isConnected) {
1141
+ try {
1142
+ return await this.ws.request(method, path, body, params);
1143
+ }
1144
+ catch {
1145
+ // Fall back to HTTP on WebSocket failure
1146
+ return this.http.request(method, path, body, params);
1147
+ }
1148
+ }
1149
+ // Otherwise use HTTP
1150
+ return this.http.request(method, path, body, params);
1151
+ }
1152
+ async pipeline(requests) {
1153
+ if (this.ws.isConnected) {
1154
+ try {
1155
+ return await this.ws.pipeline(requests);
1156
+ }
1157
+ catch {
1158
+ return this.http.pipeline(requests);
1159
+ }
1160
+ }
1161
+ return this.http.pipeline(requests);
1162
+ }
1163
+ close() {
1164
+ this.ws.close();
1165
+ this.http.close();
1166
+ }
1167
+ }