safe-memory-layer 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.
@@ -0,0 +1,554 @@
1
+ /**
2
+ * Main MemoryStore implementation for safe-memory-layer.
3
+ *
4
+ * Provides a secure, lightweight, dependency-free abstraction for storing
5
+ * temporary in-memory data with TTL support, automatic cleanup, and
6
+ * memory leak prevention.
7
+ *
8
+ * @module MemoryStore
9
+ */
10
+
11
+ import { Scheduler } from "./Scheduler.js";
12
+ import { createEntry, isExpired, touchEntry } from "./Entry.js";
13
+ import { Stats } from "./Stats.js";
14
+ import { EventEmitter } from "./Events.js";
15
+ import { createTimer } from "./utils/timer.js";
16
+ import { createSafeFinalizationRegistry } from "./utils/featureDetection.js";
17
+ import type {
18
+ InternalEntry,
19
+ InternalStoreOptions,
20
+ MemoryStoreOptions,
21
+ SetOptions,
22
+ StoreStats,
23
+ } from "./types.js";
24
+
25
+ /** Default cleanup interval in milliseconds (10 seconds). */
26
+ const DEFAULT_CLEANUP_INTERVAL = 10_000;
27
+
28
+ /** Default auto-dispose delay in milliseconds (60 seconds). */
29
+ const DEFAULT_AUTO_DISPOSE_DELAY = 60_000;
30
+
31
+ /**
32
+ * A secure, high-performance in-memory store with TTL support,
33
+ * automatic cleanup, and memory leak prevention.
34
+ *
35
+ * @typeParam K - The type of keys stored in the map.
36
+ * @typeParam V - The type of values stored in the map.
37
+ *
38
+ * @example
39
+ * ```ts
40
+ * const store = new MemoryStore<string, User>();
41
+ *
42
+ * // Set with TTL
43
+ * store.set("user:123", user, { ttl: 60000 });
44
+ *
45
+ * // Get
46
+ * const user = store.get("user:123");
47
+ * ```
48
+ */
49
+ export class MemoryStore<K, V> implements Iterable<[K, V]> {
50
+ /** Internal map of entries. */
51
+ #map = new Map<K, InternalEntry<K, V>>();
52
+
53
+ /** Store configuration options. */
54
+ readonly #options: InternalStoreOptions<K, V>;
55
+
56
+ /** Statistics tracker. */
57
+ readonly #stats: Stats;
58
+
59
+ /** Event emitter for store events. */
60
+ readonly #events: EventEmitter<K, V>;
61
+
62
+ /** Cleanup scheduler. */
63
+ #scheduler: Scheduler;
64
+
65
+ /** Auto-dispose timer handle. */
66
+ #autoDisposeTimer: ReturnType<typeof createTimer> | null = null;
67
+
68
+ /** Whether the store has been disposed. */
69
+ #disposed = false;
70
+
71
+ /** Timestamp when the store was created. */
72
+ readonly #createdAt: number;
73
+
74
+ /** Timestamp when the store became empty (for auto-dispose). */
75
+ #emptySince: number | null = null; // Used for auto-dispose timing
76
+
77
+ /**
78
+ * Creates a new MemoryStore instance.
79
+ *
80
+ * @param options - Configuration options for the store.
81
+ *
82
+ * @example
83
+ * ```ts
84
+ * const store = new MemoryStore({
85
+ * defaultTTL: 60000,
86
+ * cleanupInterval: 5000,
87
+ * autoCleanup: true,
88
+ * maxEntries: 1000,
89
+ * maxEntriesStrategy: "LRU"
90
+ * });
91
+ * ```
92
+ */
93
+ constructor(options: MemoryStoreOptions<K, V> = {}) {
94
+ const opts = options;
95
+ this.#options = {
96
+ defaultTTL: opts.defaultTTL,
97
+ cleanupInterval: opts.cleanupInterval ?? DEFAULT_CLEANUP_INTERVAL,
98
+ autoCleanup: opts.autoCleanup ?? true,
99
+ autoDispose: opts.autoDispose ?? false,
100
+ autoDisposeDelay: opts.autoDisposeDelay ?? DEFAULT_AUTO_DISPOSE_DELAY,
101
+ maxEntries: opts.maxEntries,
102
+ maxEntriesStrategy: opts.maxEntriesStrategy ?? "reject",
103
+ onExpire: opts.onExpire,
104
+ onDelete: opts.onDelete,
105
+ onCleanup: opts.onCleanup,
106
+ };
107
+
108
+ this.#createdAt = Date.now();
109
+ this.#stats = new Stats(this.#createdAt);
110
+ this.#events = new EventEmitter<K, V>();
111
+
112
+ // Wire up event callbacks
113
+ if (this.#options.onExpire !== undefined) {
114
+ this.#events.onExpire(this.#options.onExpire);
115
+ }
116
+ if (this.#options.onDelete !== undefined) {
117
+ this.#events.onDelete(this.#options.onDelete);
118
+ }
119
+ if (this.#options.onCleanup !== undefined) {
120
+ this.#events.onCleanup(this.#options.onCleanup);
121
+ }
122
+
123
+ // Create scheduler
124
+ this.#scheduler = new Scheduler(
125
+ () => this.#runCleanup(),
126
+ this.#options.cleanupInterval,
127
+ () => this.#onSchedulerEmpty(),
128
+ );
129
+
130
+ // Start scheduler if auto-cleanup is enabled
131
+ if (this.#options.autoCleanup) {
132
+ this.#scheduler.start();
133
+ }
134
+
135
+ // Set up FinalizationRegistry if available (for leak detection)
136
+ this.#setupFinalizationRegistry();
137
+ }
138
+
139
+ /**
140
+ * Sets up FinalizationRegistry for weak reference tracking.
141
+ * This is used only for monitoring, not for core functionality.
142
+ */
143
+ #setupFinalizationRegistry(): void {
144
+ // We don't rely on FinalizationRegistry for correctness,
145
+ // but we can use it to detect potential leaks
146
+ const registry = createSafeFinalizationRegistry<K>(() => {
147
+ // This is called when a key is garbage collected
148
+ // We don't take action here, just log for debugging
149
+ });
150
+
151
+ if (registry !== null) {
152
+ // Store registry reference to prevent it from being collected
153
+ (this as unknown as { _registry: FinalizationRegistry<K> })._registry =
154
+ registry;
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Stores a value with the given key.
160
+ *
161
+ * @param key - The key to store the value under.
162
+ * @param value - The value to store.
163
+ * @param options - Optional settings (e.g., TTL).
164
+ * @returns True if the value was stored, false if rejected (e.g., max entries reached).
165
+ *
166
+ * @example
167
+ * ```ts
168
+ * store.set("token", token, { ttl: 60000 });
169
+ * ```
170
+ */
171
+ set(key: K, value: V, options: SetOptions = {}): boolean {
172
+ this.#checkDisposed();
173
+ this.#clearAutoDisposeTimer();
174
+
175
+ // Check max entries limit
176
+ if (this.#options.maxEntries !== undefined && !this.#map.has(key)) {
177
+ if (this.#map.size >= this.#options.maxEntries!) {
178
+ const strategy = this.#options.maxEntriesStrategy;
179
+ if (strategy === "reject") {
180
+ return false;
181
+ }
182
+
183
+ if (strategy === "FIFO") {
184
+ const firstKey = this.#map.keys().next().value;
185
+ if (firstKey !== undefined) {
186
+ this.delete(firstKey);
187
+ }
188
+ } else if (strategy === "LRU") {
189
+ // Find and evict the least recently used entry
190
+ let lruKey: K | undefined;
191
+ let lruTime = Infinity;
192
+ for (const [k, entry] of this.#map) {
193
+ if (entry.lastAccessed < lruTime) {
194
+ lruTime = entry.lastAccessed;
195
+ lruKey = k;
196
+ }
197
+ }
198
+ if (lruKey !== undefined) {
199
+ this.delete(lruKey);
200
+ }
201
+ }
202
+ }
203
+ }
204
+
205
+ const now = Date.now();
206
+ const ttl = options.ttl ?? this.#options.defaultTTL;
207
+ const entry = createEntry(
208
+ key,
209
+ value,
210
+ ttl !== undefined ? { ttl } : {},
211
+ now,
212
+ );
213
+
214
+ this.#map.set(key, entry);
215
+
216
+ // Start scheduler if not running
217
+ if (!this.#scheduler.running) {
218
+ this.#scheduler.start();
219
+ }
220
+
221
+ return true;
222
+ }
223
+
224
+ /**
225
+ * Retrieves a value by key.
226
+ *
227
+ * @param key - The key to look up.
228
+ * @returns The value if found and not expired, undefined otherwise.
229
+ *
230
+ * @example
231
+ * ```ts
232
+ * const user = store.get("user:123");
233
+ * ```
234
+ */
235
+ get(key: K): V | undefined {
236
+ this.#checkDisposed();
237
+
238
+ const entry = this.#map.get(key);
239
+ if (entry === undefined) return undefined;
240
+
241
+ // Lazy expiration check
242
+ if (isExpired(entry)) {
243
+ // Remove expired entry
244
+ this.#map.delete(key);
245
+ this.#stats.incrementExpired();
246
+ this.#events.emitExpire(entry.key, entry.value);
247
+ return undefined;
248
+ }
249
+
250
+ // Update last accessed time for LRU
251
+ touchEntry(entry);
252
+ return entry.value;
253
+ }
254
+
255
+ /**
256
+ * Checks if a key exists in the store and is not expired.
257
+ *
258
+ * @param key - The key to check.
259
+ * @returns True if the key exists and is not expired.
260
+ */
261
+ has(key: K): boolean {
262
+ this.#checkDisposed();
263
+
264
+ const entry = this.#map.get(key);
265
+ if (entry === undefined) return false;
266
+
267
+ // Lazy expiration check
268
+ if (isExpired(entry)) {
269
+ // Remove expired entry
270
+ this.#map.delete(key);
271
+ this.#stats.incrementExpired();
272
+ this.#events.emitExpire(entry.key, entry.value);
273
+ return false;
274
+ }
275
+
276
+ return true;
277
+ }
278
+
279
+ /**
280
+ * Deletes a value by key.
281
+ *
282
+ * @param key - The key to delete.
283
+ * @returns True if the key was present, false otherwise.
284
+ */
285
+ delete(key: K): boolean {
286
+ this.#checkDisposed();
287
+
288
+ const entry = this.#map.get(key);
289
+ if (entry === undefined) return false;
290
+
291
+ this.#map.delete(key);
292
+ this.#stats.incrementDeleted();
293
+ this.#events.emitDelete(entry.key, entry.value);
294
+
295
+ // Check if store is now empty
296
+ if (this.#map.size === 0) {
297
+ this.#onEmpty();
298
+ }
299
+
300
+ return true;
301
+ }
302
+
303
+ /**
304
+ * Removes all entries from the store.
305
+ */
306
+ clear(): void {
307
+ this.#checkDisposed();
308
+
309
+ // Emit delete events for all entries
310
+ for (const entry of this.#map.values()) {
311
+ this.#events.emitDelete(entry.key, entry.value);
312
+ this.#stats.incrementDeleted();
313
+ }
314
+
315
+ this.#map.clear();
316
+ this.#onEmpty();
317
+ }
318
+
319
+ /**
320
+ * Returns the number of entries in the store.
321
+ * Excludes expired entries.
322
+ */
323
+ get size(): number {
324
+ if (this.#disposed) return 0;
325
+
326
+ // Count non-expired entries
327
+ let count = 0;
328
+ const now = Date.now();
329
+ for (const entry of this.#map.values()) {
330
+ if (!isExpired(entry, now)) {
331
+ count++;
332
+ }
333
+ }
334
+ return count;
335
+ }
336
+
337
+ /**
338
+ * Returns an iterator of all keys.
339
+ */
340
+ keys(): IterableIterator<K> {
341
+ this.#checkDisposed();
342
+ return this.#map.keys();
343
+ }
344
+
345
+ /**
346
+ * Returns an iterator of all values.
347
+ */
348
+ values(): IterableIterator<V> {
349
+ this.#checkDisposed();
350
+ return this.#getNonExpiredValues();
351
+ }
352
+
353
+ /**
354
+ * Returns an iterator of all [key, value] pairs.
355
+ */
356
+ entries(): IterableIterator<[K, V]> {
357
+ this.#checkDisposed();
358
+ return this.#getNonExpiredEntries();
359
+ }
360
+
361
+ /**
362
+ * Returns an iterator for the store (for...of support).
363
+ */
364
+ [Symbol.iterator](): IterableIterator<[K, V]> {
365
+ return this.entries();
366
+ }
367
+
368
+ /**
369
+ * Returns an iterator of non-expired values.
370
+ */
371
+ *#getNonExpiredValues(): IterableIterator<V> {
372
+ const now = Date.now();
373
+ for (const entry of this.#map.values()) {
374
+ if (!isExpired(entry, now)) {
375
+ yield entry.value;
376
+ }
377
+ }
378
+ }
379
+
380
+ /**
381
+ * Returns an iterator of non-expired entries.
382
+ */
383
+ *#getNonExpiredEntries(): IterableIterator<[K, V]> {
384
+ const now = Date.now();
385
+ for (const entry of this.#map.values()) {
386
+ if (!isExpired(entry, now)) {
387
+ yield [entry.key, entry.value];
388
+ }
389
+ }
390
+ }
391
+
392
+ /**
393
+ * Runs a cleanup cycle, removing all expired entries.
394
+ *
395
+ * @returns The number of entries removed.
396
+ */
397
+ cleanup(): number {
398
+ this.#checkDisposed();
399
+ return this.#runCleanup();
400
+ }
401
+
402
+ /**
403
+ * Runs the cleanup cycle.
404
+ * This is called by the scheduler and can be called manually.
405
+ *
406
+ * @returns The number of entries removed.
407
+ */
408
+ #runCleanup(): number {
409
+ const startTime = Date.now();
410
+ const totalBefore = this.#map.size;
411
+ let removed = 0;
412
+ const now = Date.now();
413
+
414
+ // Iterate and remove expired entries
415
+ for (const [entryKey, entry] of this.#map) {
416
+ if (isExpired(entry, now)) {
417
+ this.#map.delete(entryKey);
418
+ this.#stats.incrementExpired();
419
+ this.#events.emitExpire(entry.key, entry.value);
420
+ removed++;
421
+ }
422
+ }
423
+
424
+ // Use emptySince to track when store became empty
425
+ if (this.#map.size === 0 && this.#emptySince !== null) {
426
+ // Store is empty, auto-dispose timer may be running
427
+ }
428
+
429
+ const totalAfter = this.#map.size;
430
+ const duration = Date.now() - startTime;
431
+
432
+ this.#stats.incrementCleaned();
433
+
434
+ // Emit cleanup event
435
+ const cleanupStats = this.#stats.createCleanupStats(
436
+ removed,
437
+ totalBefore,
438
+ totalAfter,
439
+ duration,
440
+ );
441
+ this.#events.emitCleanup(cleanupStats);
442
+
443
+ // Check if store is empty
444
+ if (this.#map.size === 0) {
445
+ this.#onEmpty();
446
+ }
447
+
448
+ return removed;
449
+ }
450
+
451
+ /**
452
+ * Compacts the store by removing expired entries.
453
+ * Alias for cleanup().
454
+ */
455
+ compact(): number {
456
+ return this.cleanup();
457
+ }
458
+
459
+ /**
460
+ * Returns statistics about the store.
461
+ *
462
+ * @returns A snapshot of the current statistics.
463
+ */
464
+ stats(): StoreStats {
465
+ this.#checkDisposed();
466
+ return this.#stats.snapshot(this.#map.size);
467
+ }
468
+
469
+ /**
470
+ * Disposes the store, stopping all timers and releasing references.
471
+ * After disposal, the store cannot be used.
472
+ */
473
+ dispose(): void {
474
+ if (this.#disposed) return;
475
+
476
+ this.#disposed = true;
477
+
478
+ // Stop scheduler
479
+ this.#scheduler.dispose();
480
+
481
+ // Stop auto-dispose timer
482
+ this.#clearAutoDisposeTimer();
483
+
484
+ // Clear all entries
485
+ this.#map.clear();
486
+
487
+ // Dispose event emitter
488
+ this.#events.dispose();
489
+
490
+ // Remove FinalizationRegistry reference
491
+ delete (this as unknown as { _registry?: FinalizationRegistry<K> })._registry;
492
+ }
493
+
494
+ /**
495
+ * Checks if the store has been disposed.
496
+ */
497
+ get disposed(): boolean {
498
+ return this.#disposed;
499
+ }
500
+
501
+ /**
502
+ * Called when the store becomes empty.
503
+ */
504
+ #onEmpty(): void {
505
+ this.#emptySince = Date.now();
506
+
507
+ // Start auto-dispose timer if enabled
508
+ if (this.#options.autoDispose) {
509
+ this.#startAutoDisposeTimer();
510
+ }
511
+ }
512
+
513
+ /**
514
+ * Starts the auto-dispose timer.
515
+ */
516
+ #startAutoDisposeTimer(): void {
517
+ this.#clearAutoDisposeTimer();
518
+
519
+ this.#autoDisposeTimer = createTimer(() => {
520
+ // Only dispose if still empty
521
+ if (this.#map.size === 0 && !this.#disposed) {
522
+ this.dispose();
523
+ }
524
+ }, this.#options.autoDisposeDelay);
525
+ }
526
+
527
+ /**
528
+ * Clears the auto-dispose timer.
529
+ */
530
+ #clearAutoDisposeTimer(): void {
531
+ if (this.#autoDisposeTimer !== null) {
532
+ this.#autoDisposeTimer.cancel();
533
+ this.#autoDisposeTimer = null;
534
+ }
535
+ this.#emptySince = null;
536
+ }
537
+
538
+ /**
539
+ * Called when the scheduler finds the store empty.
540
+ */
541
+ #onSchedulerEmpty(): void {
542
+ // Scheduler stopped itself
543
+ // This is called when cleanup finds the store empty
544
+ }
545
+
546
+ /**
547
+ * Checks if the store has been disposed and throws if so.
548
+ */
549
+ #checkDisposed(): void {
550
+ if (this.#disposed) {
551
+ throw new Error("MemoryStore has been disposed");
552
+ }
553
+ }
554
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Scheduler for automatic cleanup of expired entries.
3
+ *
4
+ * Design:
5
+ * - Single interval timer for all cleanup operations
6
+ * - Automatically stops when the store is empty to save CPU
7
+ * - Restarts when new entries are added
8
+ * - Very low CPU usage with configurable interval
9
+ *
10
+ * @module Scheduler
11
+ */
12
+
13
+ import { createInterval } from "./utils/timer.js";
14
+ import type { TimerHandle } from "./utils/timer.js";
15
+
16
+ /**
17
+ * Function type for cleanup callbacks.
18
+ * Returns the number of entries that were cleaned up.
19
+ */
20
+ export type CleanupFn = () => number;
21
+
22
+ /**
23
+ * Manages a periodic cleanup scheduler.
24
+ * Stops automatically when empty, restarts when needed.
25
+ */
26
+ export class Scheduler {
27
+ /** The cleanup interval timer handle. */
28
+ #timer: TimerHandle | null = null;
29
+
30
+ /** The interval in milliseconds between cleanup runs. */
31
+ readonly #interval: number;
32
+
33
+ /** The cleanup function to call on each interval. */
34
+ readonly #cleanupFn: CleanupFn;
35
+
36
+ /** The onEmpty callback, called when cleanup finds the store empty. */
37
+ readonly #onEmpty: (() => void) | undefined;
38
+
39
+ /** Whether the scheduler is currently running. */
40
+ #running = false;
41
+
42
+ /**
43
+ * Creates a new Scheduler.
44
+ *
45
+ * @param cleanupFn - Function to call for cleanup. Should return count of removed entries.
46
+ * @param interval - Interval in milliseconds between cleanup runs.
47
+ * @param onEmpty - Optional callback when cleanup finds the store empty.
48
+ */
49
+ constructor(
50
+ cleanupFn: CleanupFn,
51
+ interval: number,
52
+ onEmpty?: () => void,
53
+ ) {
54
+ this.#cleanupFn = cleanupFn;
55
+ this.#interval = interval;
56
+ this.#onEmpty = onEmpty;
57
+ }
58
+
59
+ /**
60
+ * Starts the scheduler if it's not already running.
61
+ * Does nothing if already started.
62
+ */
63
+ start(): void {
64
+ if (this.#running || this.#timer !== null) return;
65
+
66
+ this.#running = true;
67
+ this.#timer = createInterval(() => {
68
+ this.#tick();
69
+ }, this.#interval);
70
+ }
71
+
72
+ /**
73
+ * Stops the scheduler if running.
74
+ * Safe to call multiple times.
75
+ */
76
+ stop(): void {
77
+ this.#running = false;
78
+ if (this.#timer !== null) {
79
+ this.#timer.cancel();
80
+ this.#timer = null;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Restarts the scheduler (stops then starts).
86
+ */
87
+ restart(): void {
88
+ this.stop();
89
+ this.start();
90
+ }
91
+
92
+ /**
93
+ * Whether the scheduler is currently running.
94
+ */
95
+ get running(): boolean {
96
+ return this.#running;
97
+ }
98
+
99
+ /**
100
+ * Performs a single cleanup tick.
101
+ * Stops the scheduler if the store is empty.
102
+ */
103
+ #tick(): void {
104
+ const removed = this.#cleanupFn();
105
+
106
+ // If nothing was removed and nothing remains, stop the scheduler
107
+ // The store will restart the scheduler when new entries are added
108
+ if (removed === 0) {
109
+ this.stop();
110
+ this.#onEmpty?.();
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Disposes the scheduler, stopping it and releasing all references.
116
+ */
117
+ dispose(): void {
118
+ this.stop();
119
+ }
120
+ }