@chidchanun/bcp 0.2.14 → 0.2.16

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.
@@ -0,0 +1,1705 @@
1
+ import {
2
+ randomUUID,
3
+ } from "node:crypto";
4
+
5
+ export interface CacheAdapterEntry<T = unknown> {
6
+ key: string;
7
+ value: T;
8
+ expiresAt: number | null;
9
+ tags: string[];
10
+ paths: string[];
11
+ createdAt: number;
12
+ updatedAt: number;
13
+ }
14
+
15
+ export interface CacheAdapterSetOptions {
16
+ expiresAt?: number | null;
17
+ tags?: readonly string[];
18
+ paths?: readonly string[];
19
+ }
20
+
21
+ export interface CacheAdapter {
22
+ get<T = unknown>(
23
+ key: string
24
+ ): Promise<CacheAdapterEntry<T> | null>;
25
+ set<T = unknown>(
26
+ key: string,
27
+ value: T,
28
+ options?: CacheAdapterSetOptions
29
+ ): Promise<void>;
30
+ delete(key: string): Promise<boolean>;
31
+ clear(): Promise<number>;
32
+ revalidateTag(tag: string): Promise<number>;
33
+ revalidatePath(pathname: string): Promise<number>;
34
+ entries?(): Promise<number>;
35
+ close?(): Promise<void>;
36
+ }
37
+
38
+ export interface CacheLockAdapter {
39
+ acquire(
40
+ key: string,
41
+ ownerId: string,
42
+ ttlMs: number
43
+ ): Promise<boolean>;
44
+ extend?(
45
+ key: string,
46
+ ownerId: string,
47
+ ttlMs: number
48
+ ): Promise<boolean>;
49
+ release(
50
+ key: string,
51
+ ownerId: string
52
+ ): Promise<boolean>;
53
+ close?(): Promise<void>;
54
+ }
55
+
56
+ export interface MemoryCacheAdapter
57
+ extends CacheAdapter {
58
+ readonly size: number;
59
+ }
60
+
61
+ export interface MemoryCacheLockAdapter
62
+ extends CacheLockAdapter {
63
+ readonly size: number;
64
+ }
65
+
66
+ export interface CacheStoreSetOptions {
67
+ ttlMs?: number | false;
68
+ tags?: readonly string[];
69
+ paths?: readonly string[];
70
+ }
71
+
72
+ export interface CacheGetOrSetOptions
73
+ extends CacheStoreSetOptions {
74
+ lockTtlMs?: number;
75
+ waitTimeoutMs?: number;
76
+ pollIntervalMs?: number;
77
+ onLockTimeout?: "load" | "error";
78
+ }
79
+
80
+ export interface CacheStoreStats {
81
+ hits: number;
82
+ misses: number;
83
+ sets: number;
84
+ deletes: number;
85
+ invalidations: number;
86
+ loads: number;
87
+ loadErrors: number;
88
+ lockAcquired: number;
89
+ lockContentions: number;
90
+ lockTimeouts: number;
91
+ inFlight: number;
92
+ adapterEntries?: number;
93
+ }
94
+
95
+ export type CacheStoreEvent =
96
+ | "hit"
97
+ | "miss"
98
+ | "set"
99
+ | "delete"
100
+ | "invalidate"
101
+ | "load"
102
+ | "load-error"
103
+ | "lock-acquired"
104
+ | "lock-contention"
105
+ | "lock-timeout";
106
+
107
+ export interface CacheMetricsSink {
108
+ record(
109
+ event: CacheStoreEvent,
110
+ value?: number
111
+ ): void;
112
+ setInFlight?(value: number): void;
113
+ }
114
+
115
+ export interface CacheMetricsRegistryLike {
116
+ counter(
117
+ name: string,
118
+ options?: {
119
+ help?: string;
120
+ labelNames?: readonly string[];
121
+ }
122
+ ): {
123
+ inc(
124
+ value?: number,
125
+ labels?: Readonly<Record<
126
+ string,
127
+ string | number | boolean
128
+ >>
129
+ ): void;
130
+ };
131
+ gauge(
132
+ name: string,
133
+ options?: {
134
+ help?: string;
135
+ labelNames?: readonly string[];
136
+ }
137
+ ): {
138
+ set(
139
+ value: number,
140
+ labels?: Readonly<Record<
141
+ string,
142
+ string | number | boolean
143
+ >>
144
+ ): void;
145
+ };
146
+ }
147
+
148
+ export interface CacheStoreOptions {
149
+ adapter?: CacheAdapter;
150
+ lock?: CacheLockAdapter;
151
+ now?: () => number;
152
+ idFactory?: () => string;
153
+ metrics?: CacheMetricsSink;
154
+ }
155
+
156
+ export interface CacheStore {
157
+ readonly adapter: CacheAdapter;
158
+ readonly lock: CacheLockAdapter | undefined;
159
+ get<T = unknown>(key: string): Promise<T | undefined>;
160
+ set<T = unknown>(
161
+ key: string,
162
+ value: T,
163
+ options?: CacheStoreSetOptions
164
+ ): Promise<void>;
165
+ delete(key: string): Promise<boolean>;
166
+ clear(): Promise<number>;
167
+ revalidateTag(tag: string): Promise<number>;
168
+ revalidatePath(pathname: string): Promise<number>;
169
+ getOrSet<T>(
170
+ key: string,
171
+ loader: () => T | Promise<T>,
172
+ options?: CacheGetOrSetOptions
173
+ ): Promise<T>;
174
+ stats(): Promise<CacheStoreStats>;
175
+ close(): Promise<void>;
176
+ }
177
+
178
+ export interface RedisCacheCommandClient {
179
+ sendCommand(
180
+ command: string[]
181
+ ): Promise<unknown>;
182
+ }
183
+
184
+ export interface RedisCacheAdapterOptions {
185
+ client: RedisCacheCommandClient;
186
+ namespace?: string;
187
+ now?: () => number;
188
+ serialize?: (value: unknown) => string;
189
+ deserialize?: (value: string) => unknown;
190
+ close?: () => Promise<void>;
191
+ }
192
+
193
+ export interface RedisCacheAdapter
194
+ extends CacheAdapter {
195
+ readonly namespace: string;
196
+ }
197
+
198
+ export interface RedisCacheLockAdapterOptions {
199
+ client: RedisCacheCommandClient;
200
+ namespace?: string;
201
+ close?: () => Promise<void>;
202
+ }
203
+
204
+ export interface RedisCacheLockAdapter
205
+ extends CacheLockAdapter {
206
+ readonly namespace: string;
207
+ }
208
+
209
+ interface MemoryCacheRecord
210
+ extends CacheAdapterEntry {
211
+ }
212
+
213
+ interface MemoryLockRecord {
214
+ ownerId: string;
215
+ expiresAt: number;
216
+ }
217
+
218
+ interface RedisStoredRecord {
219
+ key: string;
220
+ value: string;
221
+ expiresAt: number | null;
222
+ tags: string[];
223
+ paths: string[];
224
+ tagIndexKeys: string[];
225
+ pathIndexPairs: Array<{
226
+ path: string;
227
+ key: string;
228
+ }>;
229
+ createdAt: number;
230
+ updatedAt: number;
231
+ }
232
+
233
+ const DEFAULT_LOCK_TTL_MS = 30_000;
234
+ const DEFAULT_WAIT_TIMEOUT_MS = 5_000;
235
+ const DEFAULT_POLL_INTERVAL_MS = 25;
236
+
237
+ export function createMemoryCacheAdapter(
238
+ options: {
239
+ now?: () => number;
240
+ } = {}
241
+ ): MemoryCacheAdapter {
242
+ const now =
243
+ options.now ?? Date.now;
244
+ const records =
245
+ new Map<string, MemoryCacheRecord>();
246
+
247
+ const adapter:
248
+ MemoryCacheAdapter = {
249
+ get size() {
250
+ prune();
251
+ return records.size;
252
+ },
253
+
254
+ async get<T>(
255
+ rawKey: string
256
+ ): Promise<CacheAdapterEntry<T> | null> {
257
+ const key = normalizeKey(rawKey);
258
+ const entry = records.get(key);
259
+ if (!entry) {
260
+ return null;
261
+ }
262
+ if (isExpired(entry, now())) {
263
+ records.delete(key);
264
+ return null;
265
+ }
266
+ return cloneEntry(entry) as CacheAdapterEntry<T>;
267
+ },
268
+
269
+ async set<T>(
270
+ rawKey: string,
271
+ value: T,
272
+ setOptions: CacheAdapterSetOptions = {}
273
+ ): Promise<void> {
274
+ const key = normalizeKey(rawKey);
275
+ const timestamp = now();
276
+ assertFiniteTimestamp(timestamp, "cache timestamp");
277
+ const current = records.get(key);
278
+ const expiresAt = normalizeExpiresAt(
279
+ setOptions.expiresAt
280
+ );
281
+ records.set(key, {
282
+ key,
283
+ value,
284
+ expiresAt,
285
+ tags: normalizeTags(setOptions.tags),
286
+ paths: normalizePaths(setOptions.paths),
287
+ createdAt: current?.createdAt ?? timestamp,
288
+ updatedAt: timestamp,
289
+ });
290
+ },
291
+
292
+ async delete(rawKey) {
293
+ return records.delete(
294
+ normalizeKey(rawKey)
295
+ );
296
+ },
297
+
298
+ async clear() {
299
+ const count = records.size;
300
+ records.clear();
301
+ return count;
302
+ },
303
+
304
+ async revalidateTag(rawTag) {
305
+ const tag = normalizeTag(rawTag);
306
+ let removed = 0;
307
+ for (const [key, record] of records) {
308
+ if (record.tags.includes(tag)) {
309
+ records.delete(key);
310
+ removed++;
311
+ }
312
+ }
313
+ return removed;
314
+ },
315
+
316
+ async revalidatePath(rawPath) {
317
+ const pathname = normalizePath(rawPath);
318
+ let removed = 0;
319
+ for (const [key, record] of records) {
320
+ if (
321
+ record.paths.some(path =>
322
+ pathMatches(path, pathname)
323
+ )
324
+ ) {
325
+ records.delete(key);
326
+ removed++;
327
+ }
328
+ }
329
+ return removed;
330
+ },
331
+
332
+ async entries() {
333
+ prune();
334
+ return records.size;
335
+ },
336
+ };
337
+
338
+ return adapter;
339
+
340
+ function prune(): void {
341
+ const timestamp = now();
342
+ for (const [key, record] of records) {
343
+ if (isExpired(record, timestamp)) {
344
+ records.delete(key);
345
+ }
346
+ }
347
+ }
348
+ }
349
+
350
+ export function createMemoryCacheLockAdapter(
351
+ options: {
352
+ now?: () => number;
353
+ } = {}
354
+ ): MemoryCacheLockAdapter {
355
+ const now =
356
+ options.now ?? Date.now;
357
+ const locks =
358
+ new Map<string, MemoryLockRecord>();
359
+
360
+ const adapter:
361
+ MemoryCacheLockAdapter = {
362
+ get size() {
363
+ prune();
364
+ return locks.size;
365
+ },
366
+
367
+ async acquire(rawKey, rawOwnerId, ttlMs) {
368
+ const key = normalizeKey(rawKey);
369
+ const ownerId = normalizeOwnerId(rawOwnerId);
370
+ const ttl = positiveInteger(ttlMs, "lock ttlMs");
371
+ const timestamp = now();
372
+ const current = locks.get(key);
373
+ if (
374
+ current &&
375
+ current.expiresAt > timestamp
376
+ ) {
377
+ return false;
378
+ }
379
+ locks.set(key, {
380
+ ownerId,
381
+ expiresAt: timestamp + ttl,
382
+ });
383
+ return true;
384
+ },
385
+
386
+ async extend(rawKey, rawOwnerId, ttlMs) {
387
+ const key = normalizeKey(rawKey);
388
+ const ownerId = normalizeOwnerId(rawOwnerId);
389
+ const ttl = positiveInteger(ttlMs, "lock ttlMs");
390
+ const timestamp = now();
391
+ const current = locks.get(key);
392
+ if (
393
+ !current ||
394
+ current.ownerId !== ownerId ||
395
+ current.expiresAt <= timestamp
396
+ ) {
397
+ return false;
398
+ }
399
+ current.expiresAt = timestamp + ttl;
400
+ return true;
401
+ },
402
+
403
+ async release(rawKey, rawOwnerId) {
404
+ const key = normalizeKey(rawKey);
405
+ const ownerId = normalizeOwnerId(rawOwnerId);
406
+ const current = locks.get(key);
407
+ if (!current || current.ownerId !== ownerId) {
408
+ return false;
409
+ }
410
+ locks.delete(key);
411
+ return true;
412
+ },
413
+ };
414
+
415
+ return adapter;
416
+
417
+ function prune(): void {
418
+ const timestamp = now();
419
+ for (const [key, record] of locks) {
420
+ if (record.expiresAt <= timestamp) {
421
+ locks.delete(key);
422
+ }
423
+ }
424
+ }
425
+ }
426
+
427
+ export function createCacheStore(
428
+ options: CacheStoreOptions = {}
429
+ ): CacheStore {
430
+ const adapter =
431
+ options.adapter ??
432
+ createMemoryCacheAdapter({
433
+ now: options.now,
434
+ });
435
+ const lock = options.lock;
436
+ const now = options.now ?? Date.now;
437
+ const idFactory =
438
+ options.idFactory ?? randomUUID;
439
+ const metrics = options.metrics;
440
+ const inFlight =
441
+ new Map<string, Promise<unknown>>();
442
+ const counters = {
443
+ hits: 0,
444
+ misses: 0,
445
+ sets: 0,
446
+ deletes: 0,
447
+ invalidations: 0,
448
+ loads: 0,
449
+ loadErrors: 0,
450
+ lockAcquired: 0,
451
+ lockContentions: 0,
452
+ lockTimeouts: 0,
453
+ };
454
+ let closed = false;
455
+
456
+ const store:
457
+ CacheStore = {
458
+ adapter,
459
+ lock,
460
+
461
+ async get<T>(
462
+ rawKey: string
463
+ ): Promise<T | undefined> {
464
+ assertOpen();
465
+ const key = normalizeKey(rawKey);
466
+ const entry = await adapter.get<T>(key);
467
+ if (!entry) {
468
+ count("misses", "miss");
469
+ return undefined;
470
+ }
471
+ if (
472
+ entry.expiresAt !== null &&
473
+ entry.expiresAt <= now()
474
+ ) {
475
+ await adapter.delete(key);
476
+ count("misses", "miss");
477
+ return undefined;
478
+ }
479
+ count("hits", "hit");
480
+ return entry.value;
481
+ },
482
+
483
+ async set<T>(
484
+ rawKey: string,
485
+ value: T,
486
+ setOptions: CacheStoreSetOptions = {}
487
+ ): Promise<void> {
488
+ assertOpen();
489
+ const key = normalizeKey(rawKey);
490
+ if (setOptions.ttlMs === 0) {
491
+ await store.delete(key);
492
+ return;
493
+ }
494
+ const expiresAt =
495
+ setOptions.ttlMs === undefined ||
496
+ setOptions.ttlMs === false
497
+ ? null
498
+ : now() + positiveInteger(
499
+ setOptions.ttlMs,
500
+ "cache ttlMs"
501
+ );
502
+ await adapter.set(
503
+ key,
504
+ value,
505
+ {
506
+ expiresAt,
507
+ tags: normalizeTags(setOptions.tags),
508
+ paths: normalizePaths(setOptions.paths),
509
+ }
510
+ );
511
+ count("sets", "set");
512
+ },
513
+
514
+ async delete(rawKey) {
515
+ assertOpen();
516
+ const removed =
517
+ await adapter.delete(
518
+ normalizeKey(rawKey)
519
+ );
520
+ if (removed) {
521
+ count("deletes", "delete");
522
+ }
523
+ return removed;
524
+ },
525
+
526
+ async clear() {
527
+ assertOpen();
528
+ const removed = await adapter.clear();
529
+ if (removed > 0) {
530
+ counters.invalidations += removed;
531
+ metrics?.record("invalidate", removed);
532
+ }
533
+ return removed;
534
+ },
535
+
536
+ async revalidateTag(rawTag) {
537
+ assertOpen();
538
+ const removed =
539
+ await adapter.revalidateTag(
540
+ normalizeTag(rawTag)
541
+ );
542
+ if (removed > 0) {
543
+ counters.invalidations += removed;
544
+ metrics?.record("invalidate", removed);
545
+ }
546
+ return removed;
547
+ },
548
+
549
+ async revalidatePath(rawPath) {
550
+ assertOpen();
551
+ const removed =
552
+ await adapter.revalidatePath(
553
+ normalizePath(rawPath)
554
+ );
555
+ if (removed > 0) {
556
+ counters.invalidations += removed;
557
+ metrics?.record("invalidate", removed);
558
+ }
559
+ return removed;
560
+ },
561
+
562
+ async getOrSet<T>(
563
+ rawKey: string,
564
+ loader: () => T | Promise<T>,
565
+ loadOptions: CacheGetOrSetOptions = {}
566
+ ): Promise<T> {
567
+ assertOpen();
568
+ if (typeof loader !== "function") {
569
+ throw new TypeError(
570
+ "BCP Cache: loader must be a function."
571
+ );
572
+ }
573
+ const key = normalizeKey(rawKey);
574
+ const cached = await store.get<T>(key);
575
+ if (cached !== undefined) {
576
+ return cached;
577
+ }
578
+
579
+ const pending = inFlight.get(key);
580
+ if (pending) {
581
+ return await pending as T;
582
+ }
583
+
584
+ const promise =
585
+ loadWithStampedeProtection<T>(
586
+ key,
587
+ loader,
588
+ loadOptions
589
+ );
590
+ inFlight.set(key, promise);
591
+ metrics?.setInFlight?.(inFlight.size);
592
+ try {
593
+ return await promise;
594
+ } finally {
595
+ inFlight.delete(key);
596
+ metrics?.setInFlight?.(inFlight.size);
597
+ }
598
+ },
599
+
600
+ async stats() {
601
+ assertOpen();
602
+ return {
603
+ ...counters,
604
+ inFlight: inFlight.size,
605
+ adapterEntries:
606
+ adapter.entries
607
+ ? await adapter.entries()
608
+ : undefined,
609
+ };
610
+ },
611
+
612
+ async close() {
613
+ if (closed) {
614
+ return;
615
+ }
616
+ closed = true;
617
+ inFlight.clear();
618
+ metrics?.setInFlight?.(0);
619
+ const lockClose =
620
+ lock &&
621
+ !Object.is(lock, adapter)
622
+ ? lock.close?.()
623
+ : undefined;
624
+ await Promise.all([
625
+ adapter.close?.(),
626
+ lockClose,
627
+ ]);
628
+ },
629
+ };
630
+
631
+ return store;
632
+
633
+ async function loadWithStampedeProtection<T>(
634
+ key: string,
635
+ loader: () => T | Promise<T>,
636
+ loadOptions: CacheGetOrSetOptions
637
+ ): Promise<T> {
638
+ if (!lock) {
639
+ return executeLoader(
640
+ key,
641
+ loader,
642
+ loadOptions
643
+ );
644
+ }
645
+
646
+ const lockTtlMs =
647
+ positiveInteger(
648
+ loadOptions.lockTtlMs ??
649
+ DEFAULT_LOCK_TTL_MS,
650
+ "lock ttlMs"
651
+ );
652
+ const waitTimeoutMs =
653
+ nonNegativeInteger(
654
+ loadOptions.waitTimeoutMs ??
655
+ DEFAULT_WAIT_TIMEOUT_MS,
656
+ "lock waitTimeoutMs"
657
+ );
658
+ const pollIntervalMs =
659
+ positiveInteger(
660
+ loadOptions.pollIntervalMs ??
661
+ DEFAULT_POLL_INTERVAL_MS,
662
+ "lock pollIntervalMs"
663
+ );
664
+ const ownerId =
665
+ normalizeOwnerId(idFactory());
666
+ const lockKey =
667
+ `cache-load:${key}`;
668
+
669
+ if (
670
+ await lock.acquire(
671
+ lockKey,
672
+ ownerId,
673
+ lockTtlMs
674
+ )
675
+ ) {
676
+ count(
677
+ "lockAcquired",
678
+ "lock-acquired"
679
+ );
680
+ return executeUnderLock(
681
+ key,
682
+ ownerId,
683
+ lockKey,
684
+ lockTtlMs,
685
+ loader,
686
+ loadOptions
687
+ );
688
+ }
689
+
690
+ count(
691
+ "lockContentions",
692
+ "lock-contention"
693
+ );
694
+ const waitStartedAt = Date.now();
695
+ while (
696
+ Date.now() - waitStartedAt <
697
+ waitTimeoutMs
698
+ ) {
699
+ await sleep(pollIntervalMs);
700
+ const cached = await store.get<T>(key);
701
+ if (cached !== undefined) {
702
+ return cached;
703
+ }
704
+ }
705
+
706
+ count("lockTimeouts", "lock-timeout");
707
+ if (loadOptions.onLockTimeout === "error") {
708
+ throw new Error(
709
+ `BCP Cache: timed out waiting for distributed cache lock for "${key}".`
710
+ );
711
+ }
712
+
713
+ if (
714
+ await lock.acquire(
715
+ lockKey,
716
+ ownerId,
717
+ lockTtlMs
718
+ )
719
+ ) {
720
+ count(
721
+ "lockAcquired",
722
+ "lock-acquired"
723
+ );
724
+ return executeUnderLock(
725
+ key,
726
+ ownerId,
727
+ lockKey,
728
+ lockTtlMs,
729
+ loader,
730
+ loadOptions
731
+ );
732
+ }
733
+
734
+ return executeLoader(
735
+ key,
736
+ loader,
737
+ loadOptions
738
+ );
739
+ }
740
+
741
+ async function executeUnderLock<T>(
742
+ key: string,
743
+ ownerId: string,
744
+ lockKey: string,
745
+ lockTtlMs: number,
746
+ loader: () => T | Promise<T>,
747
+ loadOptions: CacheGetOrSetOptions
748
+ ): Promise<T> {
749
+ let timer:
750
+ ReturnType<typeof setTimeout> |
751
+ undefined;
752
+ let active = true;
753
+
754
+ if (lock?.extend) {
755
+ const intervalMs =
756
+ Math.max(
757
+ 1,
758
+ Math.floor(lockTtlMs / 3)
759
+ );
760
+ const heartbeat = async () => {
761
+ if (!active) {
762
+ return;
763
+ }
764
+ try {
765
+ await lock.extend?.(
766
+ lockKey,
767
+ ownerId,
768
+ lockTtlMs
769
+ );
770
+ } catch {
771
+ // A failed renewal is allowed to expire naturally.
772
+ } finally {
773
+ if (active) {
774
+ timer = setTimeout(
775
+ heartbeat,
776
+ intervalMs
777
+ );
778
+ }
779
+ }
780
+ };
781
+ timer = setTimeout(
782
+ heartbeat,
783
+ intervalMs
784
+ );
785
+ }
786
+
787
+ try {
788
+ const cached = await store.get<T>(key);
789
+ if (cached !== undefined) {
790
+ return cached;
791
+ }
792
+ return await executeLoader(
793
+ key,
794
+ loader,
795
+ loadOptions
796
+ );
797
+ } finally {
798
+ active = false;
799
+ if (timer) {
800
+ clearTimeout(timer);
801
+ }
802
+ await lock?.release(
803
+ lockKey,
804
+ ownerId
805
+ );
806
+ }
807
+ }
808
+
809
+ async function executeLoader<T>(
810
+ key: string,
811
+ loader: () => T | Promise<T>,
812
+ loadOptions: CacheGetOrSetOptions
813
+ ): Promise<T> {
814
+ count("loads", "load");
815
+ try {
816
+ const value = await loader();
817
+ await store.set(
818
+ key,
819
+ value,
820
+ loadOptions
821
+ );
822
+ return value;
823
+ } catch (error) {
824
+ count(
825
+ "loadErrors",
826
+ "load-error"
827
+ );
828
+ throw error;
829
+ }
830
+ }
831
+
832
+ function count(
833
+ key: keyof typeof counters,
834
+ event: CacheStoreEvent,
835
+ value = 1
836
+ ): void {
837
+ counters[key] += value;
838
+ metrics?.record(event, value);
839
+ }
840
+
841
+ function assertOpen(): void {
842
+ if (closed) {
843
+ throw new Error(
844
+ "BCP Cache: cache store is closed."
845
+ );
846
+ }
847
+ }
848
+ }
849
+
850
+ export function createCacheMetrics(
851
+ registry: CacheMetricsRegistryLike,
852
+ options: {
853
+ prefix?: string;
854
+ } = {}
855
+ ): CacheMetricsSink {
856
+ const prefix =
857
+ normalizeMetricPrefix(
858
+ options.prefix ?? "bcp_cache"
859
+ );
860
+ const operations =
861
+ registry.counter(
862
+ `${prefix}_operations_total`,
863
+ {
864
+ help:
865
+ "BCP Cache operations by event.",
866
+ labelNames: [
867
+ "event",
868
+ ],
869
+ }
870
+ );
871
+ const inFlight =
872
+ registry.gauge(
873
+ `${prefix}_in_flight`,
874
+ {
875
+ help:
876
+ "BCP Cache local in-flight loaders.",
877
+ }
878
+ );
879
+
880
+ return {
881
+ record(event, value = 1) {
882
+ operations.inc(
883
+ value,
884
+ {
885
+ event,
886
+ }
887
+ );
888
+ },
889
+ setInFlight(value) {
890
+ inFlight.set(value);
891
+ },
892
+ };
893
+ }
894
+
895
+ export function createRedisCacheAdapter(
896
+ options: RedisCacheAdapterOptions
897
+ ): RedisCacheAdapter {
898
+ assertRedisClient(options.client);
899
+ const namespace =
900
+ normalizeNamespace(
901
+ options.namespace ??
902
+ "bcp:{cache}"
903
+ );
904
+ const now = options.now ?? Date.now;
905
+ const serialize =
906
+ options.serialize ?? JSON.stringify;
907
+ const deserialize =
908
+ options.deserialize ?? JSON.parse;
909
+ const client = options.client;
910
+
911
+ const adapter:
912
+ RedisCacheAdapter = {
913
+ namespace,
914
+
915
+ async get<T>(
916
+ rawKey: string
917
+ ): Promise<CacheAdapterEntry<T> | null> {
918
+ const key = normalizeKey(rawKey);
919
+ const entryKey = redisEntryKey(
920
+ namespace,
921
+ key
922
+ );
923
+ const raw =
924
+ redisString(
925
+ await client.sendCommand([
926
+ "GET",
927
+ entryKey,
928
+ ])
929
+ );
930
+ if (raw === null) {
931
+ await client.sendCommand([
932
+ "SREM",
933
+ redisAllKeysKey(namespace),
934
+ entryKey,
935
+ ]);
936
+ return null;
937
+ }
938
+ const stored =
939
+ parseStoredRecord(raw);
940
+ if (
941
+ stored.expiresAt !== null &&
942
+ stored.expiresAt <= now()
943
+ ) {
944
+ await deleteStoredRecord(
945
+ stored,
946
+ entryKey
947
+ );
948
+ return null;
949
+ }
950
+ return {
951
+ key: stored.key,
952
+ value:
953
+ deserialize(stored.value) as T,
954
+ expiresAt: stored.expiresAt,
955
+ tags: [...stored.tags],
956
+ paths: [...stored.paths],
957
+ createdAt: stored.createdAt,
958
+ updatedAt: stored.updatedAt,
959
+ };
960
+ },
961
+
962
+ async set<T>(
963
+ rawKey: string,
964
+ value: T,
965
+ setOptions: CacheAdapterSetOptions = {}
966
+ ): Promise<void> {
967
+ const key = normalizeKey(rawKey);
968
+ const entryKey = redisEntryKey(
969
+ namespace,
970
+ key
971
+ );
972
+ const timestamp = now();
973
+ const tags = normalizeTags(setOptions.tags);
974
+ const paths = normalizePaths(setOptions.paths);
975
+ const current = await adapter.get(key);
976
+ const stored:
977
+ RedisStoredRecord = {
978
+ key,
979
+ value: serializeValue(
980
+ value,
981
+ serialize
982
+ ),
983
+ expiresAt:
984
+ normalizeExpiresAt(
985
+ setOptions.expiresAt
986
+ ),
987
+ tags,
988
+ paths,
989
+ tagIndexKeys:
990
+ tags.map(tag =>
991
+ redisTagKey(
992
+ namespace,
993
+ tag
994
+ )
995
+ ),
996
+ pathIndexPairs:
997
+ paths.map(pathname => ({
998
+ path: pathname,
999
+ key: redisPathKey(
1000
+ namespace,
1001
+ pathname
1002
+ ),
1003
+ })),
1004
+ createdAt:
1005
+ current?.createdAt ??
1006
+ timestamp,
1007
+ updatedAt: timestamp,
1008
+ };
1009
+ const ttlMs =
1010
+ stored.expiresAt === null
1011
+ ? 0
1012
+ : Math.max(
1013
+ 1,
1014
+ stored.expiresAt - timestamp
1015
+ );
1016
+ await evalRedis(
1017
+ client,
1018
+ REDIS_SET_SCRIPT,
1019
+ [
1020
+ entryKey,
1021
+ redisAllKeysKey(namespace),
1022
+ redisPathsIndexKey(namespace),
1023
+ ],
1024
+ [
1025
+ JSON.stringify(stored),
1026
+ String(ttlMs),
1027
+ ]
1028
+ );
1029
+ },
1030
+
1031
+ async delete(rawKey) {
1032
+ const key = normalizeKey(rawKey);
1033
+ const entryKey = redisEntryKey(
1034
+ namespace,
1035
+ key
1036
+ );
1037
+ const result = await evalRedis(
1038
+ client,
1039
+ REDIS_DELETE_SCRIPT,
1040
+ [
1041
+ entryKey,
1042
+ redisAllKeysKey(namespace),
1043
+ redisPathsIndexKey(namespace),
1044
+ ],
1045
+ []
1046
+ );
1047
+ return Number(result) > 0;
1048
+ },
1049
+
1050
+ async clear() {
1051
+ const entryKeys =
1052
+ redisStringArray(
1053
+ await client.sendCommand([
1054
+ "SMEMBERS",
1055
+ redisAllKeysKey(namespace),
1056
+ ])
1057
+ );
1058
+ let removed = 0;
1059
+ for (const entryKey of entryKeys) {
1060
+ const result = await evalRedis(
1061
+ client,
1062
+ REDIS_DELETE_SCRIPT,
1063
+ [
1064
+ entryKey,
1065
+ redisAllKeysKey(namespace),
1066
+ redisPathsIndexKey(namespace),
1067
+ ],
1068
+ []
1069
+ );
1070
+ removed += Number(result) > 0
1071
+ ? 1
1072
+ : 0;
1073
+ }
1074
+ await client.sendCommand([
1075
+ "DEL",
1076
+ redisAllKeysKey(namespace),
1077
+ redisPathsIndexKey(namespace),
1078
+ ]);
1079
+ return removed;
1080
+ },
1081
+
1082
+ async revalidateTag(rawTag) {
1083
+ const tag = normalizeTag(rawTag);
1084
+ const tagKey = redisTagKey(
1085
+ namespace,
1086
+ tag
1087
+ );
1088
+ const result = await evalRedis(
1089
+ client,
1090
+ REDIS_INVALIDATE_SET_SCRIPT,
1091
+ [
1092
+ tagKey,
1093
+ redisAllKeysKey(namespace),
1094
+ redisPathsIndexKey(namespace),
1095
+ ],
1096
+ []
1097
+ );
1098
+ return Number(result) || 0;
1099
+ },
1100
+
1101
+ async revalidatePath(rawPath) {
1102
+ const pathname = normalizePath(rawPath);
1103
+ const registeredPaths =
1104
+ redisStringArray(
1105
+ await client.sendCommand([
1106
+ "SMEMBERS",
1107
+ redisPathsIndexKey(namespace),
1108
+ ])
1109
+ );
1110
+ const matches = registeredPaths
1111
+ .filter(path =>
1112
+ pathMatches(path, pathname)
1113
+ )
1114
+ .map(path => ({
1115
+ path,
1116
+ key: redisPathKey(
1117
+ namespace,
1118
+ path
1119
+ ),
1120
+ }));
1121
+ if (matches.length === 0) {
1122
+ return 0;
1123
+ }
1124
+ const result = await evalRedis(
1125
+ client,
1126
+ REDIS_INVALIDATE_PATHS_SCRIPT,
1127
+ [
1128
+ redisAllKeysKey(namespace),
1129
+ redisPathsIndexKey(namespace),
1130
+ ],
1131
+ [
1132
+ JSON.stringify(matches),
1133
+ ]
1134
+ );
1135
+ return Number(result) || 0;
1136
+ },
1137
+
1138
+ async entries() {
1139
+ return Number(
1140
+ await client.sendCommand([
1141
+ "SCARD",
1142
+ redisAllKeysKey(namespace),
1143
+ ])
1144
+ ) || 0;
1145
+ },
1146
+
1147
+ async close() {
1148
+ await options.close?.();
1149
+ },
1150
+ };
1151
+
1152
+ return adapter;
1153
+
1154
+ async function deleteStoredRecord(
1155
+ _stored: RedisStoredRecord,
1156
+ entryKey: string
1157
+ ): Promise<void> {
1158
+ await evalRedis(
1159
+ client,
1160
+ REDIS_DELETE_SCRIPT,
1161
+ [
1162
+ entryKey,
1163
+ redisAllKeysKey(namespace),
1164
+ redisPathsIndexKey(namespace),
1165
+ ],
1166
+ []
1167
+ );
1168
+ }
1169
+ }
1170
+
1171
+ export function createRedisCacheLockAdapter(
1172
+ options: RedisCacheLockAdapterOptions
1173
+ ): RedisCacheLockAdapter {
1174
+ assertRedisClient(options.client);
1175
+ const namespace =
1176
+ normalizeNamespace(
1177
+ options.namespace ??
1178
+ "bcp:{cache}"
1179
+ );
1180
+ const client = options.client;
1181
+
1182
+ return {
1183
+ namespace,
1184
+
1185
+ async acquire(rawKey, rawOwnerId, ttlMs) {
1186
+ const key = redisLockKey(
1187
+ namespace,
1188
+ normalizeKey(rawKey)
1189
+ );
1190
+ const ownerId = normalizeOwnerId(rawOwnerId);
1191
+ const ttl = positiveInteger(
1192
+ ttlMs,
1193
+ "lock ttlMs"
1194
+ );
1195
+ const result =
1196
+ redisString(
1197
+ await client.sendCommand([
1198
+ "SET",
1199
+ key,
1200
+ ownerId,
1201
+ "NX",
1202
+ "PX",
1203
+ String(ttl),
1204
+ ])
1205
+ );
1206
+ return result === "OK";
1207
+ },
1208
+
1209
+ async extend(rawKey, rawOwnerId, ttlMs) {
1210
+ const result = await evalRedis(
1211
+ client,
1212
+ REDIS_LOCK_EXTEND_SCRIPT,
1213
+ [
1214
+ redisLockKey(
1215
+ namespace,
1216
+ normalizeKey(rawKey)
1217
+ ),
1218
+ ],
1219
+ [
1220
+ normalizeOwnerId(rawOwnerId),
1221
+ String(
1222
+ positiveInteger(
1223
+ ttlMs,
1224
+ "lock ttlMs"
1225
+ )
1226
+ ),
1227
+ ]
1228
+ );
1229
+ return Number(result) > 0;
1230
+ },
1231
+
1232
+ async release(rawKey, rawOwnerId) {
1233
+ const result = await evalRedis(
1234
+ client,
1235
+ REDIS_LOCK_RELEASE_SCRIPT,
1236
+ [
1237
+ redisLockKey(
1238
+ namespace,
1239
+ normalizeKey(rawKey)
1240
+ ),
1241
+ ],
1242
+ [
1243
+ normalizeOwnerId(rawOwnerId),
1244
+ ]
1245
+ );
1246
+ return Number(result) > 0;
1247
+ },
1248
+
1249
+ async close() {
1250
+ await options.close?.();
1251
+ },
1252
+ };
1253
+ }
1254
+
1255
+ function cloneEntry<T>(
1256
+ entry: CacheAdapterEntry<T>
1257
+ ): CacheAdapterEntry<T> {
1258
+ return {
1259
+ ...entry,
1260
+ tags: [...entry.tags],
1261
+ paths: [...entry.paths],
1262
+ };
1263
+ }
1264
+
1265
+ function isExpired(
1266
+ entry: CacheAdapterEntry,
1267
+ now: number
1268
+ ): boolean {
1269
+ return (
1270
+ entry.expiresAt !== null &&
1271
+ entry.expiresAt <= now
1272
+ );
1273
+ }
1274
+
1275
+ function normalizeExpiresAt(
1276
+ value: number | null | undefined
1277
+ ): number | null {
1278
+ if (value === undefined || value === null) {
1279
+ return null;
1280
+ }
1281
+ assertFiniteTimestamp(value, "cache expiresAt");
1282
+ return value;
1283
+ }
1284
+
1285
+ function normalizeKey(value: string): string {
1286
+ const normalized = String(value ?? "").trim();
1287
+ if (!normalized) {
1288
+ throw new TypeError(
1289
+ "BCP Cache: key must be a non-empty string."
1290
+ );
1291
+ }
1292
+ return normalized;
1293
+ }
1294
+
1295
+ function normalizeOwnerId(value: string): string {
1296
+ const normalized = String(value ?? "").trim();
1297
+ if (!normalized) {
1298
+ throw new TypeError(
1299
+ "BCP Cache: lock ownerId must be a non-empty string."
1300
+ );
1301
+ }
1302
+ return normalized;
1303
+ }
1304
+
1305
+ function normalizeTag(value: string): string {
1306
+ const normalized = String(value ?? "").trim();
1307
+ if (!normalized) {
1308
+ throw new TypeError(
1309
+ "BCP Cache: cache tags cannot be empty."
1310
+ );
1311
+ }
1312
+ return normalized;
1313
+ }
1314
+
1315
+ function normalizeTags(
1316
+ values: readonly string[] | undefined
1317
+ ): string[] {
1318
+ return Array.from(
1319
+ new Set(
1320
+ (values ?? []).map(normalizeTag)
1321
+ )
1322
+ ).sort();
1323
+ }
1324
+
1325
+ function normalizePath(value: string): string {
1326
+ let pathname = String(value ?? "").trim();
1327
+ if (!pathname) {
1328
+ return "/";
1329
+ }
1330
+ try {
1331
+ if (
1332
+ pathname.startsWith("http://") ||
1333
+ pathname.startsWith("https://")
1334
+ ) {
1335
+ pathname = new URL(pathname).pathname;
1336
+ }
1337
+ } catch {
1338
+ // Fall through to pathname normalization.
1339
+ }
1340
+ pathname = pathname
1341
+ .split("?")[0]
1342
+ .split("#")[0];
1343
+ if (!pathname.startsWith("/")) {
1344
+ pathname = `/${pathname}`;
1345
+ }
1346
+ if (
1347
+ pathname.length > 1 &&
1348
+ pathname.endsWith("/")
1349
+ ) {
1350
+ pathname = pathname.slice(0, -1);
1351
+ }
1352
+ return pathname;
1353
+ }
1354
+
1355
+ function normalizePaths(
1356
+ values: readonly string[] | undefined
1357
+ ): string[] {
1358
+ return Array.from(
1359
+ new Set(
1360
+ (values ?? []).map(normalizePath)
1361
+ )
1362
+ ).sort();
1363
+ }
1364
+
1365
+ function pathMatches(
1366
+ cachedPath: string,
1367
+ invalidatedPath: string
1368
+ ): boolean {
1369
+ if (invalidatedPath === "/") {
1370
+ return true;
1371
+ }
1372
+ return (
1373
+ cachedPath === invalidatedPath ||
1374
+ cachedPath.startsWith(
1375
+ `${invalidatedPath}/`
1376
+ )
1377
+ );
1378
+ }
1379
+
1380
+ function positiveInteger(
1381
+ value: number,
1382
+ field: string
1383
+ ): number {
1384
+ if (!Number.isInteger(value) || value <= 0) {
1385
+ throw new TypeError(
1386
+ `BCP Cache: ${field} must be a positive integer.`
1387
+ );
1388
+ }
1389
+ return value;
1390
+ }
1391
+
1392
+ function nonNegativeInteger(
1393
+ value: number,
1394
+ field: string
1395
+ ): number {
1396
+ if (!Number.isInteger(value) || value < 0) {
1397
+ throw new TypeError(
1398
+ `BCP Cache: ${field} must be a non-negative integer.`
1399
+ );
1400
+ }
1401
+ return value;
1402
+ }
1403
+
1404
+ function assertFiniteTimestamp(
1405
+ value: number,
1406
+ field: string
1407
+ ): void {
1408
+ if (!Number.isFinite(value)) {
1409
+ throw new TypeError(
1410
+ `BCP Cache: ${field} must be a finite number.`
1411
+ );
1412
+ }
1413
+ }
1414
+
1415
+ function sleep(ms: number): Promise<void> {
1416
+ return new Promise(resolve => {
1417
+ setTimeout(resolve, ms);
1418
+ });
1419
+ }
1420
+
1421
+ function normalizeMetricPrefix(
1422
+ value: string
1423
+ ): string {
1424
+ const normalized = value.trim();
1425
+ if (!/^[a-zA-Z_:][a-zA-Z0-9_:]*$/.test(normalized)) {
1426
+ throw new TypeError(
1427
+ "BCP Cache: metrics prefix contains unsupported characters."
1428
+ );
1429
+ }
1430
+ return normalized;
1431
+ }
1432
+
1433
+ function normalizeNamespace(value: string): string {
1434
+ const normalized = value.trim();
1435
+ if (!normalized) {
1436
+ throw new TypeError(
1437
+ "BCP Cache: Redis namespace must be a non-empty string."
1438
+ );
1439
+ }
1440
+ return normalized.replace(/:+$/g, "");
1441
+ }
1442
+
1443
+ function encodeRedisPart(value: string): string {
1444
+ return encodeURIComponent(value);
1445
+ }
1446
+
1447
+ function redisEntryKey(
1448
+ namespace: string,
1449
+ key: string
1450
+ ): string {
1451
+ return `${namespace}:entry:${encodeRedisPart(key)}`;
1452
+ }
1453
+
1454
+ function redisTagKey(
1455
+ namespace: string,
1456
+ tag: string
1457
+ ): string {
1458
+ return `${namespace}:tag:${encodeRedisPart(tag)}`;
1459
+ }
1460
+
1461
+ function redisPathKey(
1462
+ namespace: string,
1463
+ pathname: string
1464
+ ): string {
1465
+ return `${namespace}:path:${encodeRedisPart(pathname)}`;
1466
+ }
1467
+
1468
+ function redisAllKeysKey(namespace: string): string {
1469
+ return `${namespace}:entries`;
1470
+ }
1471
+
1472
+ function redisPathsIndexKey(namespace: string): string {
1473
+ return `${namespace}:paths`;
1474
+ }
1475
+
1476
+ function redisLockKey(
1477
+ namespace: string,
1478
+ key: string
1479
+ ): string {
1480
+ return `${namespace}:lock:${encodeRedisPart(key)}`;
1481
+ }
1482
+
1483
+ function serializeValue(
1484
+ value: unknown,
1485
+ serialize: (value: unknown) => string
1486
+ ): string {
1487
+ const result = serialize(value);
1488
+ if (typeof result !== "string") {
1489
+ throw new TypeError(
1490
+ "BCP Cache: Redis serializer must return a string."
1491
+ );
1492
+ }
1493
+ return result;
1494
+ }
1495
+
1496
+ function parseStoredRecord(
1497
+ value: string
1498
+ ): RedisStoredRecord {
1499
+ const parsed = JSON.parse(value) as
1500
+ Partial<RedisStoredRecord>;
1501
+ if (
1502
+ !parsed ||
1503
+ typeof parsed.key !== "string" ||
1504
+ typeof parsed.value !== "string" ||
1505
+ !Array.isArray(parsed.tags) ||
1506
+ !Array.isArray(parsed.paths) ||
1507
+ !Array.isArray(parsed.tagIndexKeys) ||
1508
+ !Array.isArray(parsed.pathIndexPairs) ||
1509
+ typeof parsed.createdAt !== "number" ||
1510
+ typeof parsed.updatedAt !== "number"
1511
+ ) {
1512
+ throw new Error(
1513
+ "BCP Cache: invalid Redis cache record."
1514
+ );
1515
+ }
1516
+ return parsed as RedisStoredRecord;
1517
+ }
1518
+
1519
+ function assertRedisClient(
1520
+ client: RedisCacheCommandClient
1521
+ ): void {
1522
+ if (
1523
+ !client ||
1524
+ typeof client.sendCommand !== "function"
1525
+ ) {
1526
+ throw new TypeError(
1527
+ "BCP Cache: Redis adapter requires a sendCommand client."
1528
+ );
1529
+ }
1530
+ }
1531
+
1532
+ function redisString(value: unknown): string | null {
1533
+ if (value === null || value === undefined) {
1534
+ return null;
1535
+ }
1536
+ if (typeof value === "string") {
1537
+ return value;
1538
+ }
1539
+ if (value instanceof Uint8Array) {
1540
+ return new TextDecoder().decode(value);
1541
+ }
1542
+ return String(value);
1543
+ }
1544
+
1545
+ function redisStringArray(value: unknown): string[] {
1546
+ if (!Array.isArray(value)) {
1547
+ return [];
1548
+ }
1549
+ return value
1550
+ .map(redisString)
1551
+ .filter((item): item is string =>
1552
+ item !== null
1553
+ );
1554
+ }
1555
+
1556
+ async function evalRedis(
1557
+ client: RedisCacheCommandClient,
1558
+ script: string,
1559
+ keys: string[],
1560
+ args: string[]
1561
+ ): Promise<unknown> {
1562
+ return client.sendCommand([
1563
+ "EVAL",
1564
+ script,
1565
+ String(keys.length),
1566
+ ...keys,
1567
+ ...args,
1568
+ ]);
1569
+ }
1570
+
1571
+ const REDIS_SET_SCRIPT = `
1572
+ local old = redis.call('GET', KEYS[1])
1573
+ if old then
1574
+ local previous = cjson.decode(old)
1575
+ for _, tagKey in ipairs(previous.tagIndexKeys or {}) do
1576
+ redis.call('SREM', tagKey, KEYS[1])
1577
+ if redis.call('SCARD', tagKey) == 0 then redis.call('DEL', tagKey) end
1578
+ end
1579
+ for _, pair in ipairs(previous.pathIndexPairs or {}) do
1580
+ redis.call('SREM', pair.key, KEYS[1])
1581
+ if redis.call('SCARD', pair.key) == 0 then
1582
+ redis.call('DEL', pair.key)
1583
+ redis.call('SREM', KEYS[3], pair.path)
1584
+ end
1585
+ end
1586
+ end
1587
+ local record = cjson.decode(ARGV[1])
1588
+ if tonumber(ARGV[2]) > 0 then
1589
+ redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[2])
1590
+ else
1591
+ redis.call('SET', KEYS[1], ARGV[1])
1592
+ end
1593
+ redis.call('SADD', KEYS[2], KEYS[1])
1594
+ for _, tagKey in ipairs(record.tagIndexKeys or {}) do
1595
+ redis.call('SADD', tagKey, KEYS[1])
1596
+ end
1597
+ for _, pair in ipairs(record.pathIndexPairs or {}) do
1598
+ redis.call('SADD', pair.key, KEYS[1])
1599
+ redis.call('SADD', KEYS[3], pair.path)
1600
+ end
1601
+ return 1
1602
+ `;
1603
+
1604
+ const REDIS_DELETE_SCRIPT = `
1605
+ local raw = redis.call('GET', KEYS[1])
1606
+ if not raw then
1607
+ redis.call('SREM', KEYS[2], KEYS[1])
1608
+ return 0
1609
+ end
1610
+ local record = cjson.decode(raw)
1611
+ for _, tagKey in ipairs(record.tagIndexKeys or {}) do
1612
+ redis.call('SREM', tagKey, KEYS[1])
1613
+ if redis.call('SCARD', tagKey) == 0 then redis.call('DEL', tagKey) end
1614
+ end
1615
+ for _, pair in ipairs(record.pathIndexPairs or {}) do
1616
+ redis.call('SREM', pair.key, KEYS[1])
1617
+ if redis.call('SCARD', pair.key) == 0 then
1618
+ redis.call('DEL', pair.key)
1619
+ redis.call('SREM', KEYS[3], pair.path)
1620
+ end
1621
+ end
1622
+ redis.call('DEL', KEYS[1])
1623
+ redis.call('SREM', KEYS[2], KEYS[1])
1624
+ return 1
1625
+ `;
1626
+
1627
+ const REDIS_INVALIDATE_SET_SCRIPT = `
1628
+ local members = redis.call('SMEMBERS', KEYS[1])
1629
+ local removed = 0
1630
+ for _, entryKey in ipairs(members) do
1631
+ local raw = redis.call('GET', entryKey)
1632
+ if raw then
1633
+ local record = cjson.decode(raw)
1634
+ for _, tagKey in ipairs(record.tagIndexKeys or {}) do
1635
+ redis.call('SREM', tagKey, entryKey)
1636
+ if redis.call('SCARD', tagKey) == 0 then redis.call('DEL', tagKey) end
1637
+ end
1638
+ for _, pair in ipairs(record.pathIndexPairs or {}) do
1639
+ redis.call('SREM', pair.key, entryKey)
1640
+ if redis.call('SCARD', pair.key) == 0 then
1641
+ redis.call('DEL', pair.key)
1642
+ redis.call('SREM', KEYS[3], pair.path)
1643
+ end
1644
+ end
1645
+ redis.call('DEL', entryKey)
1646
+ redis.call('SREM', KEYS[2], entryKey)
1647
+ removed = removed + 1
1648
+ else
1649
+ redis.call('SREM', KEYS[2], entryKey)
1650
+ end
1651
+ end
1652
+ redis.call('DEL', KEYS[1])
1653
+ return removed
1654
+ `;
1655
+
1656
+ const REDIS_INVALIDATE_PATHS_SCRIPT = `
1657
+ local pairs = cjson.decode(ARGV[1])
1658
+ local seen = {}
1659
+ local removed = 0
1660
+ for _, pair in ipairs(pairs) do
1661
+ local members = redis.call('SMEMBERS', pair.key)
1662
+ for _, entryKey in ipairs(members) do
1663
+ if not seen[entryKey] then
1664
+ seen[entryKey] = true
1665
+ local raw = redis.call('GET', entryKey)
1666
+ if raw then
1667
+ local record = cjson.decode(raw)
1668
+ for _, tagKey in ipairs(record.tagIndexKeys or {}) do
1669
+ redis.call('SREM', tagKey, entryKey)
1670
+ if redis.call('SCARD', tagKey) == 0 then redis.call('DEL', tagKey) end
1671
+ end
1672
+ for _, pathPair in ipairs(record.pathIndexPairs or {}) do
1673
+ redis.call('SREM', pathPair.key, entryKey)
1674
+ if redis.call('SCARD', pathPair.key) == 0 then
1675
+ redis.call('DEL', pathPair.key)
1676
+ redis.call('SREM', KEYS[2], pathPair.path)
1677
+ end
1678
+ end
1679
+ redis.call('DEL', entryKey)
1680
+ redis.call('SREM', KEYS[1], entryKey)
1681
+ removed = removed + 1
1682
+ else
1683
+ redis.call('SREM', KEYS[1], entryKey)
1684
+ end
1685
+ end
1686
+ end
1687
+ redis.call('DEL', pair.key)
1688
+ redis.call('SREM', KEYS[2], pair.path)
1689
+ end
1690
+ return removed
1691
+ `;
1692
+
1693
+ const REDIS_LOCK_RELEASE_SCRIPT = `
1694
+ if redis.call('GET', KEYS[1]) == ARGV[1] then
1695
+ return redis.call('DEL', KEYS[1])
1696
+ end
1697
+ return 0
1698
+ `;
1699
+
1700
+ const REDIS_LOCK_EXTEND_SCRIPT = `
1701
+ if redis.call('GET', KEYS[1]) == ARGV[1] then
1702
+ return redis.call('PEXPIRE', KEYS[1], ARGV[2])
1703
+ end
1704
+ return 0
1705
+ `;