memcache 0.2.0 → 1.0.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/src/index.ts ADDED
@@ -0,0 +1,1130 @@
1
+ import { Hookified } from "hookified";
2
+ import { KetamaHash } from "./ketama.js";
3
+ import { createNode, MemcacheNode } from "./node.js";
4
+
5
+ export enum MemcacheEvents {
6
+ CONNECT = "connect",
7
+ QUIT = "quit",
8
+ HIT = "hit",
9
+ MISS = "miss",
10
+ ERROR = "error",
11
+ WARN = "warn",
12
+ INFO = "info",
13
+ TIMEOUT = "timeout",
14
+ CLOSE = "close",
15
+ }
16
+
17
+ export interface HashProvider {
18
+ name: string;
19
+ nodes: Array<MemcacheNode>;
20
+ addNode: (node: MemcacheNode) => void;
21
+ removeNode: (id: string) => void;
22
+ getNode: (id: string) => MemcacheNode | undefined;
23
+ getNodesByKey: (key: string) => Array<MemcacheNode>;
24
+ }
25
+
26
+ export interface MemcacheOptions {
27
+ /**
28
+ * Array of node URIs or MemcacheNode instances to add to the consistent hashing ring.
29
+ * Examples: ["localhost:11211", "memcache://192.168.1.100:11212", "server3:11213"]
30
+ * Can also pass MemcacheNode instances directly: [createNode("localhost", 11211), createNode("server2", 11211)]
31
+ */
32
+ nodes?: (string | MemcacheNode)[];
33
+ /**
34
+ * The timeout for Memcache operations.
35
+ * @default 5000
36
+ */
37
+ timeout?: number;
38
+ /**
39
+ * Whether to keep the connection alive.
40
+ * @default true
41
+ */
42
+ keepAlive?: boolean;
43
+ /**
44
+ * The delay before the connection is kept alive.
45
+ * @default 1000
46
+ */
47
+ keepAliveDelay?: number;
48
+
49
+ /**
50
+ * The hash provider used to determine the distribution on each item is placed based
51
+ * on the number of nodes and hashing. By default it uses KetamaHash as the provider
52
+ */
53
+ hash?: HashProvider;
54
+ }
55
+
56
+ export interface MemcacheStats {
57
+ [key: string]: string;
58
+ }
59
+
60
+ export class Memcache extends Hookified {
61
+ private _nodes: Array<MemcacheNode> = [];
62
+ private _timeout: number;
63
+ private _keepAlive: boolean;
64
+ private _keepAliveDelay: number;
65
+ private _hash: HashProvider;
66
+
67
+ constructor(options?: MemcacheOptions) {
68
+ super();
69
+
70
+ this._hash = new KetamaHash();
71
+
72
+ this._timeout = options?.timeout || 5000;
73
+ this._keepAlive = options?.keepAlive !== false;
74
+ this._keepAliveDelay = options?.keepAliveDelay || 1000;
75
+
76
+ // Add nodes if provided, otherwise add default node
77
+ const nodeUris = options?.nodes || ["localhost:11211"];
78
+ for (const nodeUri of nodeUris) {
79
+ this.addNode(nodeUri);
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Get the list of nodes
85
+ * @returns {MemcacheNode[]} Array of MemcacheNode
86
+ */
87
+ public get nodes(): MemcacheNode[] {
88
+ return this._nodes;
89
+ }
90
+
91
+ /**
92
+ * Get the list of node IDs (e.g., ["localhost:11211", "127.0.0.1:11212"])
93
+ * @returns {string[]} Array of node ID strings
94
+ */
95
+ public get nodeIds(): string[] {
96
+ return this._nodes.map((node) => node.id);
97
+ }
98
+
99
+ /**
100
+ * Get the hash provider used for consistent hashing distribution.
101
+ * @returns {HashProvider} The current hash provider instance
102
+ * @default KetamaHash
103
+ *
104
+ * @example
105
+ * ```typescript
106
+ * const client = new Memcache();
107
+ * const hashProvider = client.hash;
108
+ * console.log(hashProvider.name); // "ketama"
109
+ * ```
110
+ */
111
+ public get hash(): HashProvider {
112
+ return this._hash;
113
+ }
114
+
115
+ /**
116
+ * Set the hash provider used for consistent hashing distribution.
117
+ * This allows you to customize the hashing strategy for distributing keys across nodes.
118
+ * @param {HashProvider} hash - The hash provider instance to use
119
+ *
120
+ * @example
121
+ * ```typescript
122
+ * const client = new Memcache();
123
+ * const customHashProvider = new KetamaHash();
124
+ * client.hash = customHashProvider;
125
+ * ```
126
+ */
127
+ public set hash(hash: HashProvider) {
128
+ this._hash = hash;
129
+ }
130
+
131
+ /**
132
+ * Get the timeout for Memcache operations.
133
+ * @returns {number}
134
+ * @default 5000
135
+ */
136
+ public get timeout(): number {
137
+ return this._timeout;
138
+ }
139
+
140
+ /**
141
+ * Set the timeout for Memcache operations.
142
+ * @param {number} value
143
+ * @default 5000
144
+ */
145
+ public set timeout(value: number) {
146
+ this._timeout = value;
147
+ }
148
+
149
+ /**
150
+ * Get the keepAlive setting for the Memcache connection.
151
+ * @returns {boolean}
152
+ * @default true
153
+ */
154
+ public get keepAlive(): boolean {
155
+ return this._keepAlive;
156
+ }
157
+
158
+ /**
159
+ * Set the keepAlive setting for the Memcache connection.
160
+ * Updates all existing nodes with the new value.
161
+ * Note: To apply the new value, you need to call reconnect() on the nodes.
162
+ * @param {boolean} value
163
+ * @default true
164
+ */
165
+ public set keepAlive(value: boolean) {
166
+ this._keepAlive = value;
167
+ // Update all existing nodes
168
+ this.updateNodes();
169
+ }
170
+
171
+ /**
172
+ * Get the delay before the connection is kept alive.
173
+ * @returns {number}
174
+ * @default 1000
175
+ */
176
+ public get keepAliveDelay(): number {
177
+ return this._keepAliveDelay;
178
+ }
179
+
180
+ /**
181
+ * Set the delay before the connection is kept alive.
182
+ * Updates all existing nodes with the new value.
183
+ * Note: To apply the new value, you need to call reconnect() on the nodes.
184
+ * @param {number} value
185
+ * @default 1000
186
+ */
187
+ public set keepAliveDelay(value: number) {
188
+ this._keepAliveDelay = value;
189
+ // Update all existing nodes
190
+ this.updateNodes();
191
+ }
192
+
193
+ /**
194
+ * Get an array of all MemcacheNode instances
195
+ * @returns {MemcacheNode[]}
196
+ */
197
+ public getNodes(): MemcacheNode[] {
198
+ return [...this._nodes];
199
+ }
200
+
201
+ /**
202
+ * Get a specific node by its ID
203
+ * @param {string} id - The node ID (e.g., "localhost:11211")
204
+ * @returns {MemcacheNode | undefined}
205
+ */
206
+ public getNode(id: string): MemcacheNode | undefined {
207
+ return this._nodes.find((n) => n.id === id);
208
+ }
209
+
210
+ /**
211
+ * Add a new node to the cluster
212
+ * @param {string | MemcacheNode} uri - Node URI (e.g., "localhost:11212") or a MemcacheNode instance
213
+ * @param {number} weight - Optional weight for consistent hashing (only used for string URIs)
214
+ */
215
+ public async addNode(
216
+ uri: string | MemcacheNode,
217
+ weight?: number,
218
+ ): Promise<void> {
219
+ let node: MemcacheNode;
220
+ let nodeKey: string;
221
+
222
+ if (typeof uri === "string") {
223
+ // Handle string URI
224
+ const { host, port } = this.parseUri(uri);
225
+ nodeKey = port === 0 ? host : `${host}:${port}`;
226
+
227
+ if (this._nodes.some((n) => n.id === nodeKey)) {
228
+ throw new Error(`Node ${nodeKey} already exists`);
229
+ }
230
+
231
+ // Create and connect node
232
+ node = new MemcacheNode(host, port, {
233
+ timeout: this._timeout,
234
+ keepAlive: this._keepAlive,
235
+ keepAliveDelay: this._keepAliveDelay,
236
+ weight,
237
+ });
238
+ } else {
239
+ // Handle MemcacheNode instance
240
+ node = uri;
241
+ nodeKey = node.id;
242
+
243
+ if (this._nodes.some((n) => n.id === nodeKey)) {
244
+ throw new Error(`Node ${nodeKey} already exists`);
245
+ }
246
+ }
247
+
248
+ this.forwardNodeEvents(node);
249
+ this._nodes.push(node);
250
+
251
+ this._hash.addNode(node);
252
+ }
253
+
254
+ /**
255
+ * Remove a node from the cluster
256
+ * @param {string} uri - Node URI (e.g., "localhost:11212")
257
+ */
258
+ public async removeNode(uri: string): Promise<void> {
259
+ const { host, port } = this.parseUri(uri);
260
+ const nodeKey = port === 0 ? host : `${host}:${port}`;
261
+
262
+ const node = this._nodes.find((n) => n.id === nodeKey);
263
+ if (!node) return;
264
+
265
+ // Disconnect and remove
266
+ await node.disconnect();
267
+ this._nodes = this._nodes.filter((n) => n.id !== nodeKey);
268
+ this._hash.removeNode(node.id);
269
+ }
270
+
271
+ /**
272
+ * Parse a URI string into host and port.
273
+ * Supports multiple formats:
274
+ * - Simple: "localhost:11211" or "localhost"
275
+ * - Protocol: "memcache://localhost:11211", "memcached://localhost:11211", "tcp://localhost:11211"
276
+ * - IPv6: "[::1]:11211" or "memcache://[2001:db8::1]:11212"
277
+ * - Unix socket: "/var/run/memcached.sock" or "unix:///var/run/memcached.sock"
278
+ *
279
+ * @param {string} uri - URI string
280
+ * @returns {{ host: string; port: number }} Object containing host and port (port is 0 for Unix sockets)
281
+ * @throws {Error} If URI format is invalid
282
+ */
283
+ public parseUri(uri: string): { host: string; port: number } {
284
+ // Handle Unix domain sockets
285
+ if (uri.startsWith("unix://")) {
286
+ return { host: uri.slice(7), port: 0 };
287
+ }
288
+ if (uri.startsWith("/")) {
289
+ return { host: uri, port: 0 };
290
+ }
291
+
292
+ // Remove protocol if present
293
+ let cleanUri = uri;
294
+ if (uri.includes("://")) {
295
+ const protocolParts = uri.split("://");
296
+ const protocol = protocolParts[0];
297
+ if (!["memcache", "memcached", "tcp"].includes(protocol)) {
298
+ throw new Error(
299
+ `Invalid protocol '${protocol}'. Supported protocols: memcache://, memcached://, tcp://, unix://`,
300
+ );
301
+ }
302
+ cleanUri = protocolParts[1];
303
+ }
304
+
305
+ // Handle IPv6 addresses with brackets [::1]:11211
306
+ if (cleanUri.startsWith("[")) {
307
+ const bracketEnd = cleanUri.indexOf("]");
308
+ if (bracketEnd === -1) {
309
+ throw new Error("Invalid IPv6 format: missing closing bracket");
310
+ }
311
+
312
+ const host = cleanUri.slice(1, bracketEnd);
313
+ if (!host) {
314
+ throw new Error("Invalid URI format: host is required");
315
+ }
316
+
317
+ // Check if there's a port after the bracket
318
+ const remainder = cleanUri.slice(bracketEnd + 1);
319
+ if (remainder === "") {
320
+ return { host, port: 11211 };
321
+ }
322
+ if (!remainder.startsWith(":")) {
323
+ throw new Error("Invalid IPv6 format: expected ':' after bracket");
324
+ }
325
+
326
+ const portStr = remainder.slice(1);
327
+ const port = Number.parseInt(portStr, 10);
328
+ if (Number.isNaN(port) || port <= 0 || port > 65535) {
329
+ throw new Error("Invalid port number");
330
+ }
331
+
332
+ return { host, port };
333
+ }
334
+
335
+ // Parse host and port for regular format
336
+ const parts = cleanUri.split(":");
337
+ if (parts.length === 0 || parts.length > 2) {
338
+ throw new Error("Invalid URI format");
339
+ }
340
+
341
+ const host = parts[0];
342
+ if (!host) {
343
+ throw new Error("Invalid URI format: host is required");
344
+ }
345
+
346
+ const port = parts.length === 2 ? Number.parseInt(parts[1], 10) : 11211;
347
+ if (Number.isNaN(port) || port < 0 || port > 65535) {
348
+ throw new Error("Invalid port number");
349
+ }
350
+
351
+ // Port 0 is only valid for Unix sockets (already handled above)
352
+ if (port === 0) {
353
+ throw new Error("Invalid port number");
354
+ }
355
+
356
+ return { host, port };
357
+ }
358
+
359
+ /**
360
+ * Connect to all Memcache servers or a specific node.
361
+ * @param {string} nodeId - Optional node ID to connect to (e.g., "localhost:11211")
362
+ * @returns {Promise<void>}
363
+ */
364
+ public async connect(nodeId?: string): Promise<void> {
365
+ if (nodeId) {
366
+ const node = this._nodes.find((n) => n.id === nodeId);
367
+ /* v8 ignore next -- @preserve */
368
+ if (!node) throw new Error(`Node ${nodeId} not found`);
369
+ /* v8 ignore next -- @preserve */
370
+ await node.connect();
371
+ /* v8 ignore next -- @preserve */
372
+ return;
373
+ }
374
+
375
+ // Connect to all nodes
376
+ await Promise.all(this._nodes.map((node) => node.connect()));
377
+ }
378
+
379
+ /**
380
+ * Get a value from the Memcache server.
381
+ * When multiple nodes are returned by the hash provider (for replication),
382
+ * queries all nodes and returns the first successful result.
383
+ * @param {string} key
384
+ * @returns {Promise<string | undefined>}
385
+ */
386
+ public async get(key: string): Promise<string | undefined> {
387
+ await this.beforeHook("get", { key });
388
+
389
+ this.validateKey(key);
390
+
391
+ const nodes = await this.getNodesByKey(key);
392
+
393
+ // Query all nodes (supports replication strategies)
394
+ const promises = nodes.map(async (node) => {
395
+ try {
396
+ const result = await node.command(`get ${key}`, {
397
+ isMultiline: true,
398
+ requestedKeys: [key],
399
+ });
400
+
401
+ if (result?.values && result.values.length > 0) {
402
+ return result.values[0];
403
+ }
404
+ return undefined;
405
+ } catch {
406
+ // If one node fails, try the others
407
+ return undefined;
408
+ }
409
+ });
410
+
411
+ // Wait for all nodes to respond
412
+ const results = await Promise.all(promises);
413
+
414
+ // Return the first successful result
415
+ const value = results.find((v) => v !== undefined);
416
+
417
+ await this.afterHook("get", { key, value });
418
+
419
+ return value;
420
+ }
421
+
422
+ /**
423
+ * Get multiple values from the Memcache server.
424
+ * When multiple nodes are returned by the hash provider (for replication),
425
+ * queries all replica nodes and returns the first successful result for each key.
426
+ * @param keys {string[]}
427
+ * @returns {Promise<Map<string, string>>}
428
+ */
429
+ public async gets(keys: string[]): Promise<Map<string, string>> {
430
+ await this.beforeHook("gets", { keys });
431
+
432
+ // Validate all keys
433
+ for (const key of keys) {
434
+ this.validateKey(key);
435
+ }
436
+
437
+ // Group keys by all their replica nodes
438
+ const keysByNode = new Map<MemcacheNode, string[]>();
439
+
440
+ for (const key of keys) {
441
+ const nodes = this._hash.getNodesByKey(key);
442
+ if (nodes.length === 0) {
443
+ /* v8 ignore next -- @preserve */
444
+ throw new Error(`No node available for key: ${key}`);
445
+ }
446
+
447
+ // Add key to all replica nodes
448
+ for (const node of nodes) {
449
+ if (!keysByNode.has(node)) {
450
+ keysByNode.set(node, []);
451
+ }
452
+ // biome-ignore lint/style/noNonNullAssertion: we just set it
453
+ keysByNode.get(node)!.push(key);
454
+ }
455
+ }
456
+
457
+ // Execute commands in parallel across all nodes (including replicas)
458
+ const promises = Array.from(keysByNode.entries()).map(
459
+ async ([node, nodeKeys]) => {
460
+ try {
461
+ if (!node.isConnected()) await node.connect();
462
+
463
+ const keysStr = nodeKeys.join(" ");
464
+ const result = await node.command(`get ${keysStr}`, {
465
+ isMultiline: true,
466
+ requestedKeys: nodeKeys,
467
+ });
468
+
469
+ return result;
470
+ } catch {
471
+ // If one node fails, continue with others
472
+ /* v8 ignore next -- @preserve */
473
+ return undefined;
474
+ }
475
+ },
476
+ );
477
+
478
+ const results = await Promise.all(promises);
479
+
480
+ // Merge results into Map (first successful value for each key wins)
481
+ const map = new Map<string, string>();
482
+
483
+ for (const result of results) {
484
+ if (result?.foundKeys && result.values) {
485
+ // Map found keys to their values
486
+ for (let i = 0; i < result.foundKeys.length; i++) {
487
+ if (result.values[i] !== undefined) {
488
+ // Only set if key doesn't exist yet (first successful result wins)
489
+ if (!map.has(result.foundKeys[i])) {
490
+ map.set(result.foundKeys[i], result.values[i]);
491
+ }
492
+ }
493
+ }
494
+ }
495
+ }
496
+
497
+ await this.afterHook("gets", { keys, values: map });
498
+
499
+ return map;
500
+ }
501
+
502
+ /**
503
+ * Check-And-Set: Store a value only if it hasn't been modified since last fetch.
504
+ * When multiple nodes are returned by the hash provider (for replication),
505
+ * executes on all nodes and returns true only if all succeed.
506
+ * @param key {string}
507
+ * @param value {string}
508
+ * @param casToken {string}
509
+ * @param exptime {number}
510
+ * @param flags {number}
511
+ * @returns {Promise<boolean>}
512
+ */
513
+ public async cas(
514
+ key: string,
515
+ value: string,
516
+ casToken: string,
517
+ exptime: number = 0,
518
+ flags: number = 0,
519
+ ): Promise<boolean> {
520
+ await this.beforeHook("cas", { key, value, casToken, exptime, flags });
521
+
522
+ this.validateKey(key);
523
+ const valueStr = String(value);
524
+ const bytes = Buffer.byteLength(valueStr);
525
+ const command = `cas ${key} ${flags} ${exptime} ${bytes} ${casToken}\r\n${valueStr}`;
526
+
527
+ const nodes = await this.getNodesByKey(key);
528
+
529
+ // Execute CAS on all replica nodes
530
+ const promises = nodes.map(async (node) => {
531
+ try {
532
+ const result = await node.command(command);
533
+ return result === "STORED";
534
+ } catch {
535
+ // If one node fails, the entire operation fails
536
+ /* v8 ignore next -- @preserve */
537
+ return false;
538
+ }
539
+ });
540
+
541
+ const results = await Promise.all(promises);
542
+
543
+ // All nodes must succeed for CAS to be considered successful
544
+ const success = results.every((result) => result === true);
545
+
546
+ await this.afterHook("cas", {
547
+ key,
548
+ value,
549
+ casToken,
550
+ exptime,
551
+ flags,
552
+ success,
553
+ });
554
+
555
+ return success;
556
+ }
557
+
558
+ /**
559
+ * Set a value in the Memcache server.
560
+ * When multiple nodes are returned by the hash provider (for replication),
561
+ * executes on all nodes and returns true only if all succeed.
562
+ * @param key {string}
563
+ * @param value {string}
564
+ * @param exptime {number}
565
+ * @param flags {number}
566
+ * @returns {Promise<boolean>}
567
+ */
568
+ public async set(
569
+ key: string,
570
+ value: string,
571
+ exptime: number = 0,
572
+ flags: number = 0,
573
+ ): Promise<boolean> {
574
+ await this.beforeHook("set", { key, value, exptime, flags });
575
+
576
+ this.validateKey(key);
577
+ const valueStr = String(value);
578
+ const bytes = Buffer.byteLength(valueStr);
579
+ const command = `set ${key} ${flags} ${exptime} ${bytes}\r\n${valueStr}`;
580
+
581
+ const nodes = await this.getNodesByKey(key);
582
+
583
+ // Execute SET on all replica nodes
584
+ const promises = nodes.map(async (node) => {
585
+ try {
586
+ const result = await node.command(command);
587
+ return result === "STORED";
588
+ } catch {
589
+ // If one node fails, the entire operation fails
590
+ /* v8 ignore next -- @preserve */
591
+ return false;
592
+ }
593
+ });
594
+
595
+ const results = await Promise.all(promises);
596
+
597
+ // All nodes must succeed for SET to be considered successful
598
+ const success = results.every((result) => result === true);
599
+
600
+ await this.afterHook("set", { key, value, exptime, flags, success });
601
+
602
+ return success;
603
+ }
604
+
605
+ /**
606
+ * Add a value to the Memcache server (only if key doesn't exist).
607
+ * When multiple nodes are returned by the hash provider (for replication),
608
+ * executes on all nodes and returns true only if all succeed.
609
+ * @param key {string}
610
+ * @param value {string}
611
+ * @param exptime {number}
612
+ * @param flags {number}
613
+ * @returns {Promise<boolean>}
614
+ */
615
+ public async add(
616
+ key: string,
617
+ value: string,
618
+ exptime: number = 0,
619
+ flags: number = 0,
620
+ ): Promise<boolean> {
621
+ await this.beforeHook("add", { key, value, exptime, flags });
622
+
623
+ this.validateKey(key);
624
+ const valueStr = String(value);
625
+ const bytes = Buffer.byteLength(valueStr);
626
+ const command = `add ${key} ${flags} ${exptime} ${bytes}\r\n${valueStr}`;
627
+
628
+ const nodes = await this.getNodesByKey(key);
629
+
630
+ // Execute ADD on all replica nodes
631
+ const promises = nodes.map(async (node) => {
632
+ try {
633
+ const result = await node.command(command);
634
+ return result === "STORED";
635
+ } catch {
636
+ // If one node fails, the entire operation fails
637
+ /* v8 ignore next -- @preserve */
638
+ return false;
639
+ }
640
+ });
641
+
642
+ const results = await Promise.all(promises);
643
+
644
+ // All nodes must succeed for ADD to be considered successful
645
+ const success = results.every((result) => result === true);
646
+
647
+ await this.afterHook("add", { key, value, exptime, flags, success });
648
+
649
+ return success;
650
+ }
651
+
652
+ /**
653
+ * Replace a value in the Memcache server (only if key exists).
654
+ * When multiple nodes are returned by the hash provider (for replication),
655
+ * executes on all nodes and returns true only if all succeed.
656
+ * @param key {string}
657
+ * @param value {string}
658
+ * @param exptime {number}
659
+ * @param flags {number}
660
+ * @returns {Promise<boolean>}
661
+ */
662
+ public async replace(
663
+ key: string,
664
+ value: string,
665
+ exptime: number = 0,
666
+ flags: number = 0,
667
+ ): Promise<boolean> {
668
+ await this.beforeHook("replace", { key, value, exptime, flags });
669
+
670
+ this.validateKey(key);
671
+ const valueStr = String(value);
672
+ const bytes = Buffer.byteLength(valueStr);
673
+ const command = `replace ${key} ${flags} ${exptime} ${bytes}\r\n${valueStr}`;
674
+
675
+ const nodes = await this.getNodesByKey(key);
676
+
677
+ // Execute REPLACE on all replica nodes
678
+ const promises = nodes.map(async (node) => {
679
+ try {
680
+ const result = await node.command(command);
681
+ return result === "STORED";
682
+ } catch {
683
+ // If one node fails, the entire operation fails
684
+ /* v8 ignore next -- @preserve */
685
+ return false;
686
+ }
687
+ });
688
+
689
+ const results = await Promise.all(promises);
690
+
691
+ // All nodes must succeed for REPLACE to be considered successful
692
+ const success = results.every((result) => result === true);
693
+
694
+ await this.afterHook("replace", { key, value, exptime, flags, success });
695
+
696
+ return success;
697
+ }
698
+
699
+ /**
700
+ * Append a value to an existing key in the Memcache server.
701
+ * When multiple nodes are returned by the hash provider (for replication),
702
+ * executes on all nodes and returns true only if all succeed.
703
+ * @param key {string}
704
+ * @param value {string}
705
+ * @returns {Promise<boolean>}
706
+ */
707
+ public async append(key: string, value: string): Promise<boolean> {
708
+ await this.beforeHook("append", { key, value });
709
+
710
+ this.validateKey(key);
711
+ const valueStr = String(value);
712
+ const bytes = Buffer.byteLength(valueStr);
713
+ const command = `append ${key} 0 0 ${bytes}\r\n${valueStr}`;
714
+
715
+ const nodes = await this.getNodesByKey(key);
716
+
717
+ // Execute APPEND on all replica nodes
718
+ const promises = nodes.map(async (node) => {
719
+ try {
720
+ const result = await node.command(command);
721
+ return result === "STORED";
722
+ } catch {
723
+ // If one node fails, the entire operation fails
724
+ /* v8 ignore next -- @preserve */
725
+ return false;
726
+ }
727
+ });
728
+
729
+ const results = await Promise.all(promises);
730
+
731
+ // All nodes must succeed for APPEND to be considered successful
732
+ const success = results.every((result) => result === true);
733
+
734
+ await this.afterHook("append", { key, value, success });
735
+
736
+ return success;
737
+ }
738
+
739
+ /**
740
+ * Prepend a value to an existing key in the Memcache server.
741
+ * When multiple nodes are returned by the hash provider (for replication),
742
+ * executes on all nodes and returns true only if all succeed.
743
+ * @param key {string}
744
+ * @param value {string}
745
+ * @returns {Promise<boolean>}
746
+ */
747
+ public async prepend(key: string, value: string): Promise<boolean> {
748
+ await this.beforeHook("prepend", { key, value });
749
+
750
+ this.validateKey(key);
751
+ const valueStr = String(value);
752
+ const bytes = Buffer.byteLength(valueStr);
753
+ const command = `prepend ${key} 0 0 ${bytes}\r\n${valueStr}`;
754
+
755
+ const nodes = await this.getNodesByKey(key);
756
+
757
+ // Execute PREPEND on all replica nodes
758
+ const promises = nodes.map(async (node) => {
759
+ try {
760
+ const result = await node.command(command);
761
+ return result === "STORED";
762
+ } catch {
763
+ // If one node fails, the entire operation fails
764
+ /* v8 ignore next -- @preserve */
765
+ return false;
766
+ }
767
+ });
768
+
769
+ const results = await Promise.all(promises);
770
+
771
+ // All nodes must succeed for PREPEND to be considered successful
772
+ const success = results.every((result) => result === true);
773
+
774
+ await this.afterHook("prepend", { key, value, success });
775
+
776
+ return success;
777
+ }
778
+
779
+ /**
780
+ * Delete a value from the Memcache server.
781
+ * When multiple nodes are returned by the hash provider (for replication),
782
+ * executes on all nodes and returns true only if all succeed.
783
+ * @param key {string}
784
+ * @returns {Promise<boolean>}
785
+ */
786
+ public async delete(key: string): Promise<boolean> {
787
+ await this.beforeHook("delete", { key });
788
+
789
+ this.validateKey(key);
790
+
791
+ const nodes = await this.getNodesByKey(key);
792
+
793
+ // Execute DELETE on all replica nodes
794
+ const promises = nodes.map(async (node) => {
795
+ try {
796
+ const result = await node.command(`delete ${key}`);
797
+ return result === "DELETED";
798
+ } catch {
799
+ // If one node fails, the entire operation fails
800
+ /* v8 ignore next -- @preserve */
801
+ return false;
802
+ }
803
+ });
804
+
805
+ const results = await Promise.all(promises);
806
+
807
+ // All nodes must succeed for DELETE to be considered successful
808
+ const success = results.every((result) => result === true);
809
+
810
+ await this.afterHook("delete", { key, success });
811
+
812
+ return success;
813
+ }
814
+
815
+ /**
816
+ * Increment a value in the Memcache server.
817
+ * When multiple nodes are returned by the hash provider (for replication),
818
+ * executes on all nodes and returns the first successful result.
819
+ * @param key {string}
820
+ * @param value {number}
821
+ * @returns {Promise<number | undefined>}
822
+ */
823
+ public async incr(
824
+ key: string,
825
+ value: number = 1,
826
+ ): Promise<number | undefined> {
827
+ await this.beforeHook("incr", { key, value });
828
+
829
+ this.validateKey(key);
830
+
831
+ const nodes = await this.getNodesByKey(key);
832
+
833
+ // Execute INCR on all replica nodes
834
+ const promises = nodes.map(async (node) => {
835
+ try {
836
+ const result = await node.command(`incr ${key} ${value}`);
837
+ return typeof result === "number" ? result : undefined;
838
+ } catch {
839
+ // If one node fails, continue with others
840
+ /* v8 ignore next -- @preserve */
841
+ return undefined;
842
+ }
843
+ });
844
+
845
+ const results = await Promise.all(promises);
846
+
847
+ // Return the first successful result
848
+ const newValue = results.find((v) => v !== undefined);
849
+
850
+ await this.afterHook("incr", { key, value, newValue });
851
+
852
+ return newValue;
853
+ }
854
+
855
+ /**
856
+ * Decrement a value in the Memcache server.
857
+ * When multiple nodes are returned by the hash provider (for replication),
858
+ * executes on all nodes and returns the first successful result.
859
+ * @param key {string}
860
+ * @param value {number}
861
+ * @returns {Promise<number | undefined>}
862
+ */
863
+ public async decr(
864
+ key: string,
865
+ value: number = 1,
866
+ ): Promise<number | undefined> {
867
+ await this.beforeHook("decr", { key, value });
868
+
869
+ this.validateKey(key);
870
+
871
+ const nodes = await this.getNodesByKey(key);
872
+
873
+ // Execute DECR on all replica nodes
874
+ const promises = nodes.map(async (node) => {
875
+ try {
876
+ const result = await node.command(`decr ${key} ${value}`);
877
+ return typeof result === "number" ? result : undefined;
878
+ } catch {
879
+ // If one node fails, continue with others
880
+ /* v8 ignore next -- @preserve */
881
+ return undefined;
882
+ }
883
+ });
884
+
885
+ const results = await Promise.all(promises);
886
+
887
+ // Return the first successful result
888
+ const newValue = results.find((v) => v !== undefined);
889
+
890
+ await this.afterHook("decr", { key, value, newValue });
891
+
892
+ return newValue;
893
+ }
894
+
895
+ /**
896
+ * Touch a value in the Memcache server (update expiration time).
897
+ * When multiple nodes are returned by the hash provider (for replication),
898
+ * executes on all nodes and returns true only if all succeed.
899
+ * @param key {string}
900
+ * @param exptime {number}
901
+ * @returns {Promise<boolean>}
902
+ */
903
+ public async touch(key: string, exptime: number): Promise<boolean> {
904
+ await this.beforeHook("touch", { key, exptime });
905
+
906
+ this.validateKey(key);
907
+
908
+ const nodes = await this.getNodesByKey(key);
909
+
910
+ // Execute TOUCH on all replica nodes
911
+ const promises = nodes.map(async (node) => {
912
+ try {
913
+ const result = await node.command(`touch ${key} ${exptime}`);
914
+ return result === "TOUCHED";
915
+ } catch {
916
+ // If one node fails, the entire operation fails
917
+ /* v8 ignore next -- @preserve */
918
+ return false;
919
+ }
920
+ });
921
+
922
+ const results = await Promise.all(promises);
923
+
924
+ // All nodes must succeed for TOUCH to be considered successful
925
+ const success = results.every((result) => result === true);
926
+
927
+ await this.afterHook("touch", { key, exptime, success });
928
+
929
+ return success;
930
+ }
931
+
932
+ /**
933
+ * Flush all values from all Memcache servers.
934
+ * @param delay {number}
935
+ * @returns {Promise<boolean>}
936
+ */
937
+ public async flush(delay?: number): Promise<boolean> {
938
+ let command = "flush_all";
939
+
940
+ // If a delay is specified, use the delayed flush command
941
+ if (delay !== undefined) {
942
+ command += ` ${delay}`;
943
+ }
944
+
945
+ // Execute on ALL nodes
946
+ const results = await Promise.all(
947
+ this._nodes.map(async (node) => {
948
+ /* v8 ignore next -- @preserve */
949
+ if (!node.isConnected()) {
950
+ await node.connect();
951
+ }
952
+ return node.command(command);
953
+ }),
954
+ );
955
+
956
+ // All must return OK
957
+ return results.every((r) => r === "OK");
958
+ }
959
+
960
+ /**
961
+ * Get statistics from all Memcache servers.
962
+ * @param type {string}
963
+ * @returns {Promise<Map<string, MemcacheStats>>}
964
+ */
965
+ public async stats(type?: string): Promise<Map<string, MemcacheStats>> {
966
+ const command = type ? `stats ${type}` : "stats";
967
+
968
+ // Get stats from ALL nodes
969
+ const results = new Map<string, MemcacheStats>();
970
+
971
+ await Promise.all(
972
+ /* v8 ignore next -- @preserve */
973
+ this._nodes.map(async (node) => {
974
+ if (!node.isConnected()) {
975
+ await node.connect();
976
+ }
977
+
978
+ const stats = await node.command(command, { isStats: true });
979
+ results.set(node.id, stats as MemcacheStats);
980
+ }),
981
+ );
982
+
983
+ return results;
984
+ }
985
+
986
+ /**
987
+ * Get the Memcache server version from all nodes.
988
+ * @returns {Promise<Map<string, string>>} Map of node IDs to version strings
989
+ */
990
+ public async version(): Promise<Map<string, string>> {
991
+ // Get version from all nodes
992
+ const results = new Map<string, string>();
993
+
994
+ await Promise.all(
995
+ /* v8 ignore next -- @preserve */
996
+ this._nodes.map(async (node) => {
997
+ if (!node.isConnected()) {
998
+ await node.connect();
999
+ }
1000
+
1001
+ const version = await node.command("version");
1002
+ results.set(node.id, version);
1003
+ }),
1004
+ );
1005
+
1006
+ return results;
1007
+ }
1008
+
1009
+ /**
1010
+ * Quit all connections gracefully.
1011
+ * @returns {Promise<void>}
1012
+ */
1013
+ public async quit(): Promise<void> {
1014
+ await Promise.all(
1015
+ this._nodes.map(async (node) => {
1016
+ if (node.isConnected()) {
1017
+ await node.quit();
1018
+ }
1019
+ }),
1020
+ );
1021
+ }
1022
+
1023
+ /**
1024
+ * Disconnect all connections.
1025
+ * @returns {Promise<void>}
1026
+ */
1027
+ public async disconnect(): Promise<void> {
1028
+ await Promise.all(this._nodes.map((node) => node.disconnect()));
1029
+ }
1030
+
1031
+ /**
1032
+ * Reconnect all nodes by disconnecting and connecting them again.
1033
+ * @returns {Promise<void>}
1034
+ */
1035
+ public async reconnect(): Promise<void> {
1036
+ await Promise.all(this._nodes.map((node) => node.reconnect()));
1037
+ }
1038
+
1039
+ /**
1040
+ * Check if any node is connected to a Memcache server.
1041
+ * @returns {boolean}
1042
+ */
1043
+ public isConnected(): boolean {
1044
+ return this._nodes.some((node) => node.isConnected());
1045
+ }
1046
+
1047
+ /**
1048
+ * Get the nodes for a given key using consistent hashing, with lazy connection.
1049
+ * This method will automatically connect to the nodes if they're not already connected.
1050
+ * Returns an array to support replication strategies.
1051
+ * @param {string} key - The cache key
1052
+ * @returns {Promise<Array<MemcacheNode>>} The nodes responsible for this key
1053
+ * @throws {Error} If no nodes are available for the key
1054
+ */
1055
+ public async getNodesByKey(key: string): Promise<Array<MemcacheNode>> {
1056
+ const nodes = this._hash.getNodesByKey(key);
1057
+ /* v8 ignore next -- @preserve */
1058
+ if (nodes.length === 0) {
1059
+ throw new Error(`No node available for key: ${key}`);
1060
+ }
1061
+
1062
+ // Lazy connect if not connected
1063
+ for (const node of nodes) {
1064
+ if (!node.isConnected()) {
1065
+ await node.connect();
1066
+ }
1067
+ }
1068
+
1069
+ return nodes;
1070
+ }
1071
+
1072
+ /**
1073
+ * Validates a Memcache key according to protocol requirements.
1074
+ * @param {string} key - The key to validate
1075
+ * @throws {Error} If the key is empty, exceeds 250 characters, or contains invalid characters
1076
+ *
1077
+ * @example
1078
+ * ```typescript
1079
+ * client.validateKey("valid-key"); // OK
1080
+ * client.validateKey(""); // Throws: Key cannot be empty
1081
+ * client.validateKey("a".repeat(251)); // Throws: Key length cannot exceed 250 characters
1082
+ * client.validateKey("key with spaces"); // Throws: Key cannot contain spaces, newlines, or null characters
1083
+ * ```
1084
+ */
1085
+ public validateKey(key: string): void {
1086
+ if (!key || key.length === 0) {
1087
+ throw new Error("Key cannot be empty");
1088
+ }
1089
+ if (key.length > 250) {
1090
+ throw new Error("Key length cannot exceed 250 characters");
1091
+ }
1092
+ if (/[\s\r\n\0]/.test(key)) {
1093
+ throw new Error(
1094
+ "Key cannot contain spaces, newlines, or null characters",
1095
+ );
1096
+ }
1097
+ }
1098
+
1099
+ // Private methods
1100
+
1101
+ /**
1102
+ * Update all nodes with current keepAlive settings
1103
+ */
1104
+ private updateNodes(): void {
1105
+ // Update all nodes with the current keepAlive settings
1106
+ for (const node of this._nodes) {
1107
+ node.keepAlive = this._keepAlive;
1108
+ node.keepAliveDelay = this._keepAliveDelay;
1109
+ }
1110
+ }
1111
+
1112
+ /**
1113
+ * Forward events from a MemcacheNode to the Memcache instance
1114
+ */
1115
+ private forwardNodeEvents(node: MemcacheNode): void {
1116
+ node.on("connect", () => this.emit(MemcacheEvents.CONNECT, node.id));
1117
+ node.on("close", () => this.emit(MemcacheEvents.CLOSE, node.id));
1118
+ node.on("error", (err: Error) =>
1119
+ this.emit(MemcacheEvents.ERROR, node.id, err),
1120
+ );
1121
+ node.on("timeout", () => this.emit(MemcacheEvents.TIMEOUT, node.id));
1122
+ node.on("hit", (key: string, value: string) =>
1123
+ this.emit(MemcacheEvents.HIT, key, value),
1124
+ );
1125
+ node.on("miss", (key: string) => this.emit(MemcacheEvents.MISS, key));
1126
+ }
1127
+ }
1128
+
1129
+ export { createNode };
1130
+ export default Memcache;