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.
- package/LICENSE +21 -0
- package/README.md +501 -0
- package/dist/index.cjs +953 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +650 -0
- package/dist/index.d.ts +650 -0
- package/dist/index.js +938 -0
- package/dist/index.js.map +1 -0
- package/package.json +57 -0
- package/src/Entry.ts +83 -0
- package/src/Events.ts +138 -0
- package/src/MemoryStore.ts +554 -0
- package/src/Scheduler.ts +120 -0
- package/src/Stats.ts +138 -0
- package/src/WeakMemoryStore.ts +229 -0
- package/src/index.ts +57 -0
- package/src/types.ts +106 -0
- package/src/utils/featureDetection.ts +52 -0
- package/src/utils/timer.ts +93 -0
|
@@ -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
|
+
}
|
package/src/Scheduler.ts
ADDED
|
@@ -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
|
+
}
|