@mark1russell7/collections 0.8.0 → 0.9.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/README.md +8 -1
- package/build/index.d.ts +51 -6
- package/build/index.d.ts.map +1 -1
- package/build/index.js +160 -31
- package/build/index.js.map +1 -1
- package/package.json +1 -1
- package/src/behaviors/bounded.ts +24 -6
- package/src/behaviors/evented.ts +75 -25
- package/src/behaviors/lru.ts +18 -22
- package/src/behaviors/ttl.ts +12 -0
- package/src/core/removals.ts +85 -0
- package/src/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package",
|
|
3
3
|
"name": "@mark1russell7/collections",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.9.0",
|
|
5
5
|
"description": "Collections with no dependencies: lists, maps, queues, a summary tree, channels, lazy operations and dependency graphs.",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"collections",
|
package/src/behaviors/bounded.ts
CHANGED
|
@@ -20,6 +20,7 @@ import type { Middleware } from "../core/middleware.js";
|
|
|
20
20
|
import type { List } from "../interfaces/list.js";
|
|
21
21
|
import type { Queue, Deque } from "../interfaces/queue.js";
|
|
22
22
|
import type { MapLike } from "../interfaces/map.js";
|
|
23
|
+
import { removals, removalSource, type ElementRemoval, type MapRemoval } from "../core/removals.js";
|
|
23
24
|
import type { Collection } from "../interfaces/collection.js";
|
|
24
25
|
import type {
|
|
25
26
|
OverflowPolicy,
|
|
@@ -71,6 +72,7 @@ export function boundedList<T>(
|
|
|
71
72
|
const capacity = checkCapacity(options.capacity, "boundedList");
|
|
72
73
|
|
|
73
74
|
return (next: List<T>): List<T> & BoundedCollection => {
|
|
75
|
+
const source = removalSource<ElementRemoval<T>>(next);
|
|
74
76
|
// An unshift puts the element at the front, so drop-oldest drops the last element for it
|
|
75
77
|
const handleOverflow = (element: T, dropFrom: "first" | "last" = "first"): void => {
|
|
76
78
|
// Call overflow handler if provided
|
|
@@ -92,8 +94,8 @@ export function boundedList<T>(
|
|
|
92
94
|
case "drop-oldest":
|
|
93
95
|
// Remove one element: the first, or the last for an unshift
|
|
94
96
|
if (!next.isEmpty) {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
+
const value = dropFrom === "last" ? next.pop() : next.shift();
|
|
98
|
+
source.emit({ value, end: dropFrom, reason: "drop" });
|
|
97
99
|
}
|
|
98
100
|
break;
|
|
99
101
|
|
|
@@ -123,6 +125,7 @@ export function boundedList<T>(
|
|
|
123
125
|
return new Proxy(next, {
|
|
124
126
|
get(target, prop, receiver) {
|
|
125
127
|
// Capacity property
|
|
128
|
+
if (prop === removals) return source.channel;
|
|
126
129
|
if (prop === "capacity") {
|
|
127
130
|
return capacity;
|
|
128
131
|
}
|
|
@@ -254,6 +257,7 @@ export function boundedQueue<T>(
|
|
|
254
257
|
const capacity = checkCapacity(options.capacity, "boundedQueue");
|
|
255
258
|
|
|
256
259
|
return (next: Queue<T>): Queue<T> & BoundedCollection => {
|
|
260
|
+
const source = removalSource<ElementRemoval<T>>(next);
|
|
257
261
|
const handleOverflow = (element: T): void => {
|
|
258
262
|
if (onOverflow) {
|
|
259
263
|
const context: OverflowContext<T> = {
|
|
@@ -270,7 +274,7 @@ export function boundedQueue<T>(
|
|
|
270
274
|
throw new Error(`Queue is full (capacity: ${capacity})`);
|
|
271
275
|
case "drop-oldest":
|
|
272
276
|
if (!next.isEmpty) {
|
|
273
|
-
next.poll();
|
|
277
|
+
source.emit({ value: next.poll() as T, end: "first", reason: "drop" });
|
|
274
278
|
}
|
|
275
279
|
break;
|
|
276
280
|
case "drop-newest":
|
|
@@ -287,6 +291,7 @@ export function boundedQueue<T>(
|
|
|
287
291
|
|
|
288
292
|
return new Proxy(next, {
|
|
289
293
|
get(target, prop, receiver) {
|
|
294
|
+
if (prop === removals) return source.channel;
|
|
290
295
|
if (prop === "capacity") return capacity;
|
|
291
296
|
if (prop === "isFull") return target.size >= capacity;
|
|
292
297
|
if (prop === "remainingCapacity") return Math.max(0, capacity - target.size);
|
|
@@ -332,6 +337,7 @@ export function boundedDeque<T>(
|
|
|
332
337
|
const capacity = checkCapacity(options.capacity, "boundedDeque");
|
|
333
338
|
|
|
334
339
|
return (next: Deque<T>): Deque<T> & BoundedCollection => {
|
|
340
|
+
const source = removalSource<ElementRemoval<T>>(next);
|
|
335
341
|
// An insert at the front drops the last element, as unshift of boundedList does (C10)
|
|
336
342
|
const handleOverflow = (element: T, dropFrom: "first" | "last"): void => {
|
|
337
343
|
if (onOverflow) {
|
|
@@ -349,8 +355,8 @@ export function boundedDeque<T>(
|
|
|
349
355
|
throw new Error(`Deque is full (capacity: ${capacity})`);
|
|
350
356
|
case "drop-oldest":
|
|
351
357
|
if (!next.isEmpty) {
|
|
352
|
-
|
|
353
|
-
|
|
358
|
+
const value = dropFrom === "last" ? next.removeLast() : next.removeFirst();
|
|
359
|
+
source.emit({ value, end: dropFrom, reason: "drop" });
|
|
354
360
|
}
|
|
355
361
|
break;
|
|
356
362
|
case "drop-newest":
|
|
@@ -367,6 +373,7 @@ export function boundedDeque<T>(
|
|
|
367
373
|
|
|
368
374
|
return new Proxy(next, {
|
|
369
375
|
get(target, prop, receiver) {
|
|
376
|
+
if (prop === removals) return source.channel;
|
|
370
377
|
if (prop === "capacity") return capacity;
|
|
371
378
|
if (prop === "isFull") return target.size >= capacity;
|
|
372
379
|
if (prop === "remainingCapacity") return Math.max(0, capacity - target.size);
|
|
@@ -419,6 +426,10 @@ export function boundedMap<K, V>(
|
|
|
419
426
|
|
|
420
427
|
return (next: MapLike<K, V>): MapLike<K, V> & BoundedCollection => {
|
|
421
428
|
let insertionOrder: K[] = []; // Track insertion order for drop-oldest
|
|
429
|
+
// A removal below (for example an eviction of lruMap) takes its key out of the order
|
|
430
|
+
const source = removalSource<MapRemoval<K, V>>(next, ({ key }) => {
|
|
431
|
+
insertionOrder = insertionOrder.filter((k) => !next.keyEq(k, key));
|
|
432
|
+
});
|
|
422
433
|
|
|
423
434
|
const handleOverflow = (key: K, value: V): void => {
|
|
424
435
|
if (onOverflow) {
|
|
@@ -436,9 +447,15 @@ export function boundedMap<K, V>(
|
|
|
436
447
|
throw new Error(`Map is full (capacity: ${capacity})`);
|
|
437
448
|
case "drop-oldest":
|
|
438
449
|
// Remove oldest key
|
|
439
|
-
|
|
450
|
+
// The removal channel below keeps the order exact. A layer below with no channel can remove a key
|
|
451
|
+
// without this layer seeing it, so a drop skips each key that is gone.
|
|
452
|
+
while (insertionOrder.length > 0) {
|
|
440
453
|
const oldestKey = insertionOrder.shift()!;
|
|
454
|
+
if (!next.has(oldestKey)) continue;
|
|
455
|
+
const value = next.get(oldestKey);
|
|
441
456
|
next.delete(oldestKey);
|
|
457
|
+
source.emit({ key: oldestKey, value, reason: "drop" });
|
|
458
|
+
break;
|
|
442
459
|
}
|
|
443
460
|
break;
|
|
444
461
|
case "drop-newest":
|
|
@@ -455,6 +472,7 @@ export function boundedMap<K, V>(
|
|
|
455
472
|
|
|
456
473
|
return new Proxy(next, {
|
|
457
474
|
get(target, prop, receiver) {
|
|
475
|
+
if (prop === removals) return source.channel;
|
|
458
476
|
if (prop === "capacity") return capacity;
|
|
459
477
|
if (prop === "isFull") return target.size >= capacity;
|
|
460
478
|
if (prop === "remainingCapacity") return Math.max(0, capacity - target.size);
|
package/src/behaviors/evented.ts
CHANGED
|
@@ -22,6 +22,31 @@ import type { Queue, Deque } from "../interfaces/queue.js";
|
|
|
22
22
|
import type { MapLike } from "../interfaces/map.js";
|
|
23
23
|
import type { Collection } from "../interfaces/collection.js";
|
|
24
24
|
import { Emitter, type Unsubscribe } from "../core/events.js";
|
|
25
|
+
import { removalsOf, type ElementRemoval, type MapRemoval, type RemovalReason } from "../core/removals.js";
|
|
26
|
+
|
|
27
|
+
/** The number of removals that a layer heard from the layers below it, and the number from the first end. */
|
|
28
|
+
interface Heard {
|
|
29
|
+
all: number;
|
|
30
|
+
first: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* This function gives each removal below `next` to `listener` and counts the removals.
|
|
35
|
+
* With the counts, a layer tells a refused add (the size did not grow) from an add that caused a drop.
|
|
36
|
+
*/
|
|
37
|
+
function hear<R extends ElementRemoval<unknown> | MapRemoval<unknown, unknown>>(next: object, listener: (removal: R) => void): Heard {
|
|
38
|
+
const heard: Heard = { all: 0, first: 0 };
|
|
39
|
+
removalsOf<R>(next)?.on((removal) => {
|
|
40
|
+
heard.all++;
|
|
41
|
+
if ("end" in removal && removal.end === "first") heard.first++;
|
|
42
|
+
listener(removal);
|
|
43
|
+
});
|
|
44
|
+
return heard;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** This function tells if a call added an element: the size grew, after the removals of the layers below. */
|
|
48
|
+
const added = (size: number, sizeBefore: number, heard: Heard, heardBefore: number): boolean =>
|
|
49
|
+
size - sizeBefore + heard.all - heardBefore > 0;
|
|
25
50
|
|
|
26
51
|
// ============================================================================
|
|
27
52
|
// Event type definitions
|
|
@@ -33,8 +58,8 @@ import { Emitter, type Unsubscribe } from "../core/events.js";
|
|
|
33
58
|
export interface ListEvents<T> {
|
|
34
59
|
/** Element was added */
|
|
35
60
|
add: { value: T; index?: number };
|
|
36
|
-
/** Element was removed */
|
|
37
|
-
remove: { value: T; index?: number };
|
|
61
|
+
/** Element was removed. A removal by a layer below (a drop) has a `reason`. */
|
|
62
|
+
remove: { value: T; index?: number; reason?: RemovalReason };
|
|
38
63
|
/** Element was replaced */
|
|
39
64
|
set: { oldValue: T; newValue: T; index: number };
|
|
40
65
|
/** Collection was cleared */
|
|
@@ -51,8 +76,8 @@ export interface ListEvents<T> {
|
|
|
51
76
|
export interface QueueEvents<T> {
|
|
52
77
|
/** Element was added to queue */
|
|
53
78
|
enqueue: { value: T };
|
|
54
|
-
/** Element was removed from queue */
|
|
55
|
-
dequeue: { value: T };
|
|
79
|
+
/** Element was removed from queue. A removal by a layer below (a drop) has a `reason`. */
|
|
80
|
+
dequeue: { value: T; reason?: RemovalReason };
|
|
56
81
|
/** Collection was cleared */
|
|
57
82
|
clear: {};
|
|
58
83
|
}
|
|
@@ -65,10 +90,10 @@ export interface DequeEvents<T> {
|
|
|
65
90
|
addFirst: { value: T };
|
|
66
91
|
/** Element was added to back */
|
|
67
92
|
addLast: { value: T };
|
|
68
|
-
/** Element was removed from front */
|
|
69
|
-
removeFirst: { value: T };
|
|
70
|
-
/** Element was removed from back */
|
|
71
|
-
removeLast: { value: T };
|
|
93
|
+
/** Element was removed from front. A removal by a layer below (a drop) has a `reason`. */
|
|
94
|
+
removeFirst: { value: T; reason?: RemovalReason };
|
|
95
|
+
/** Element was removed from back. A removal by a layer below (a drop) has a `reason`. */
|
|
96
|
+
removeLast: { value: T; reason?: RemovalReason };
|
|
72
97
|
/** Collection was cleared */
|
|
73
98
|
clear: {};
|
|
74
99
|
}
|
|
@@ -79,8 +104,8 @@ export interface DequeEvents<T> {
|
|
|
79
104
|
export interface MapEvents<K, V> {
|
|
80
105
|
/** Key-value pair was set */
|
|
81
106
|
set: { key: K; value: V; oldValue?: V };
|
|
82
|
-
/** Key was deleted */
|
|
83
|
-
delete: { key: K; value: V };
|
|
107
|
+
/** Key was deleted. A removal by a layer below (a drop, an eviction or an expiry) has a `reason`. */
|
|
108
|
+
delete: { key: K; value: V; reason?: RemovalReason };
|
|
84
109
|
/** Collection was cleared */
|
|
85
110
|
clear: {};
|
|
86
111
|
}
|
|
@@ -91,8 +116,8 @@ export interface MapEvents<K, V> {
|
|
|
91
116
|
export interface CollectionEvents<T> {
|
|
92
117
|
/** Element was added */
|
|
93
118
|
add: { value: T };
|
|
94
|
-
/** Element was removed */
|
|
95
|
-
remove: { value: T };
|
|
119
|
+
/** Element was removed. A removal by a layer below has a `reason`. */
|
|
120
|
+
remove: { value: T; reason?: RemovalReason };
|
|
96
121
|
/** Collection was cleared */
|
|
97
122
|
clear: {};
|
|
98
123
|
}
|
|
@@ -117,6 +142,10 @@ export function eventedList<T>(): Middleware<
|
|
|
117
142
|
> {
|
|
118
143
|
return (next: List<T>) => {
|
|
119
144
|
const emitter = new Emitter<ListEvents<T>>();
|
|
145
|
+
// A removal from the last end left the index that is now the size
|
|
146
|
+
const heard = hear<ElementRemoval<T>>(next, ({ value, end, reason }) =>
|
|
147
|
+
emitter.emit("remove", end === undefined ? { value, reason } : { value, index: end === "first" ? 0 : next.size, reason }),
|
|
148
|
+
);
|
|
120
149
|
|
|
121
150
|
return new Proxy(next, {
|
|
122
151
|
get(target, prop, receiver) {
|
|
@@ -129,24 +158,34 @@ export function eventedList<T>(): Middleware<
|
|
|
129
158
|
|
|
130
159
|
if (typeof value === "function") {
|
|
131
160
|
switch (prop) {
|
|
161
|
+
// A layer below can refuse the element (reject, drop-newest): then no event
|
|
132
162
|
case "add":
|
|
133
163
|
case "push":
|
|
134
164
|
return function (element: T): any {
|
|
165
|
+
const size = target.size;
|
|
166
|
+
const all = heard.all;
|
|
135
167
|
const result = (value as Function).call(target, element);
|
|
136
|
-
emitter.emit("add", { value: element });
|
|
168
|
+
if (added(target.size, size, heard, all)) emitter.emit("add", { value: element });
|
|
137
169
|
return result;
|
|
138
170
|
};
|
|
139
171
|
|
|
140
172
|
case "unshift":
|
|
141
173
|
return function (element: T): void {
|
|
174
|
+
const size = target.size;
|
|
175
|
+
const all = heard.all;
|
|
142
176
|
(value as Function).call(target, element);
|
|
143
|
-
emitter.emit("add", { value: element, index: 0 });
|
|
177
|
+
if (added(target.size, size, heard, all)) emitter.emit("add", { value: element, index: 0 });
|
|
144
178
|
};
|
|
145
179
|
|
|
146
180
|
case "insert":
|
|
147
181
|
return function (index: number, element: T): void {
|
|
182
|
+
const size = target.size;
|
|
183
|
+
const all = heard.all;
|
|
184
|
+
const first = heard.first;
|
|
148
185
|
(value as Function).call(target, index, element);
|
|
149
|
-
|
|
186
|
+
if (!added(target.size, size, heard, all)) return;
|
|
187
|
+
// A drop of the first element below moved each later index one place lower
|
|
188
|
+
emitter.emit("add", { value: element, index: heard.first > first ? Math.max(0, index - 1) : index });
|
|
150
189
|
};
|
|
151
190
|
|
|
152
191
|
case "set":
|
|
@@ -175,7 +214,8 @@ export function eventedList<T>(): Middleware<
|
|
|
175
214
|
case "pop":
|
|
176
215
|
return function (): T {
|
|
177
216
|
const removed = (value as Function).call(target);
|
|
178
|
-
|
|
217
|
+
// The last element had the index that is now the size
|
|
218
|
+
emitter.emit("remove", { value: removed, index: target.size });
|
|
179
219
|
return removed;
|
|
180
220
|
};
|
|
181
221
|
|
|
@@ -226,6 +266,7 @@ export function eventedQueue<T>(): Middleware<
|
|
|
226
266
|
> {
|
|
227
267
|
return (next: Queue<T>) => {
|
|
228
268
|
const emitter = new Emitter<QueueEvents<T>>();
|
|
269
|
+
hear<ElementRemoval<T>>(next, ({ value, reason }) => emitter.emit("dequeue", { value, reason }));
|
|
229
270
|
|
|
230
271
|
return new Proxy(next, {
|
|
231
272
|
get(target, prop, receiver) {
|
|
@@ -284,6 +325,9 @@ export function eventedDeque<T>(): Middleware<
|
|
|
284
325
|
> {
|
|
285
326
|
return (next: Deque<T>) => {
|
|
286
327
|
const emitter = new Emitter<DequeEvents<T>>();
|
|
328
|
+
const heard = hear<ElementRemoval<T>>(next, ({ value, end, reason }) =>
|
|
329
|
+
emitter.emit(end === "last" ? "removeLast" : "removeFirst", { value, reason }),
|
|
330
|
+
);
|
|
287
331
|
|
|
288
332
|
return new Proxy(next, {
|
|
289
333
|
get(target, prop, receiver) {
|
|
@@ -295,22 +339,21 @@ export function eventedDeque<T>(): Middleware<
|
|
|
295
339
|
|
|
296
340
|
if (typeof value === "function") {
|
|
297
341
|
switch (prop) {
|
|
342
|
+
// push of a deque adds at the end (see Deque.push). A layer below can refuse the element.
|
|
298
343
|
case "addFirst":
|
|
299
344
|
case "offerFirst":
|
|
300
|
-
case "push":
|
|
301
|
-
return function (element: T): any {
|
|
302
|
-
const result = (value as Function).call(target, element);
|
|
303
|
-
emitter.emit("addFirst", { value: element });
|
|
304
|
-
return result;
|
|
305
|
-
};
|
|
306
|
-
|
|
307
345
|
case "addLast":
|
|
308
346
|
case "offerLast":
|
|
309
347
|
case "offer":
|
|
310
348
|
case "add":
|
|
349
|
+
case "push":
|
|
311
350
|
return function (element: T): any {
|
|
351
|
+
const size = target.size;
|
|
352
|
+
const all = heard.all;
|
|
312
353
|
const result = (value as Function).call(target, element);
|
|
313
|
-
|
|
354
|
+
if (added(target.size, size, heard, all)) {
|
|
355
|
+
emitter.emit(prop === "addFirst" || prop === "offerFirst" ? "addFirst" : "addLast", { value: element });
|
|
356
|
+
}
|
|
314
357
|
return result;
|
|
315
358
|
};
|
|
316
359
|
|
|
@@ -365,6 +408,7 @@ export function eventedMap<K, V>(): Middleware<
|
|
|
365
408
|
> {
|
|
366
409
|
return (next: MapLike<K, V>) => {
|
|
367
410
|
const emitter = new Emitter<MapEvents<K, V>>();
|
|
411
|
+
const heard = hear<MapRemoval<K, V>>(next, ({ key, value, reason }) => emitter.emit("delete", { key, value, reason }));
|
|
368
412
|
|
|
369
413
|
return new Proxy(next, {
|
|
370
414
|
get(target, prop, receiver) {
|
|
@@ -378,8 +422,13 @@ export function eventedMap<K, V>(): Middleware<
|
|
|
378
422
|
switch (prop) {
|
|
379
423
|
case "set":
|
|
380
424
|
return function (key: K, val: V): V | undefined {
|
|
425
|
+
const size = target.size;
|
|
426
|
+
const all = heard.all;
|
|
381
427
|
const oldValue = (value as Function).call(target, key, val);
|
|
382
|
-
|
|
428
|
+
// A new key that a layer below refused (reject, drop-newest) gives no event
|
|
429
|
+
if (oldValue !== undefined || added(target.size, size, heard, all)) {
|
|
430
|
+
emitter.emit("set", { key, value: val, oldValue });
|
|
431
|
+
}
|
|
383
432
|
return oldValue;
|
|
384
433
|
};
|
|
385
434
|
|
|
@@ -420,6 +469,7 @@ export function evented<T>(): Middleware<
|
|
|
420
469
|
> {
|
|
421
470
|
return (next: Collection<T>) => {
|
|
422
471
|
const emitter = new Emitter<CollectionEvents<T>>();
|
|
472
|
+
hear<ElementRemoval<T>>(next, ({ value, reason }) => emitter.emit("remove", { value, reason }));
|
|
423
473
|
|
|
424
474
|
return new Proxy(next, {
|
|
425
475
|
get(target, prop, receiver) {
|
package/src/behaviors/lru.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { checkCapacity } from "../core/capacity.js";
|
|
|
18
18
|
import type { Middleware } from "../core/middleware.js";
|
|
19
19
|
import type { MapLike } from "../interfaces/map.js";
|
|
20
20
|
import { compositeMapMethod } from "./composite.js";
|
|
21
|
+
import { removals, removalSource, type MapRemoval } from "../core/removals.js";
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* Options for LRU behavior.
|
|
@@ -133,9 +134,15 @@ export function lruMap<K, V>(
|
|
|
133
134
|
nodeMap.delete(node.key);
|
|
134
135
|
};
|
|
135
136
|
|
|
136
|
-
// A
|
|
137
|
-
|
|
138
|
-
|
|
137
|
+
// A removal below (for example an expiry of ttlMap, in compose(lruMap, ttlMap)) removes the node of its key
|
|
138
|
+
const source = removalSource<MapRemoval<K, V>>(next, ({ key }) => {
|
|
139
|
+
const node = nodeMap.get(key);
|
|
140
|
+
if (node) removeNode(node);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// A layer below with no removal channel can remove an entry without this layer seeing it. Its node is
|
|
144
|
+
// then stale: the list must not keep it (it holds the value, and it would grow without bound) and evicting
|
|
145
|
+
// it is not an eviction. With the channel, no node is stale and these checks find nothing.
|
|
139
146
|
const dropStaleNodes = (): void => {
|
|
140
147
|
for (const node of [...nodeMap.values()]) {
|
|
141
148
|
if (!next.has(node.key)) {
|
|
@@ -161,12 +168,16 @@ export function lruMap<K, V>(
|
|
|
161
168
|
timestamp: candidate.lastAccessed,
|
|
162
169
|
});
|
|
163
170
|
}
|
|
171
|
+
source.emit({ key: candidate.key, value: candidate.value, reason: "evict" });
|
|
164
172
|
return;
|
|
165
173
|
}
|
|
166
174
|
};
|
|
167
175
|
|
|
168
176
|
return new Proxy(next, {
|
|
169
177
|
get(target, prop, receiver) {
|
|
178
|
+
if (prop === removals) {
|
|
179
|
+
return source.channel;
|
|
180
|
+
}
|
|
170
181
|
// Add isFull property
|
|
171
182
|
if (prop === "isFull") {
|
|
172
183
|
return target.size >= capacity;
|
|
@@ -185,17 +196,7 @@ export function lruMap<K, V>(
|
|
|
185
196
|
switch (prop) {
|
|
186
197
|
case "get":
|
|
187
198
|
return function (key: K): V {
|
|
188
|
-
|
|
189
|
-
try {
|
|
190
|
-
result = (value as Function).call(target, key);
|
|
191
|
-
} catch (error) {
|
|
192
|
-
// The entry is gone below (for example expired): forget its node
|
|
193
|
-
const stale = nodeMap.get(key);
|
|
194
|
-
if (stale) {
|
|
195
|
-
removeNode(stale);
|
|
196
|
-
}
|
|
197
|
-
throw error;
|
|
198
|
-
}
|
|
199
|
+
const result: V = (value as Function).call(target, key);
|
|
199
200
|
// Update access time
|
|
200
201
|
const node = nodeMap.get(key);
|
|
201
202
|
if (node) {
|
|
@@ -208,14 +209,9 @@ export function lruMap<K, V>(
|
|
|
208
209
|
return function (key: K): boolean {
|
|
209
210
|
const result = (value as Function).call(target, key);
|
|
210
211
|
const node = nodeMap.get(key);
|
|
211
|
-
if (node) {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
moveToHead(node);
|
|
215
|
-
} else {
|
|
216
|
-
// The entry is gone below (for example expired): forget its node
|
|
217
|
-
removeNode(node);
|
|
218
|
-
}
|
|
212
|
+
if (node && result) {
|
|
213
|
+
// Update access time
|
|
214
|
+
moveToHead(node);
|
|
219
215
|
}
|
|
220
216
|
return result;
|
|
221
217
|
};
|
package/src/behaviors/ttl.ts
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import type { Middleware } from "../core/middleware.js";
|
|
17
17
|
import type { Entry, MapLike } from "../interfaces/map.js";
|
|
18
18
|
import { compositeMapMethod } from "./composite.js";
|
|
19
|
+
import { removals, removalSource, type ElementRemoval, type MapRemoval } from "../core/removals.js";
|
|
19
20
|
import type { Collection } from "../interfaces/collection.js";
|
|
20
21
|
|
|
21
22
|
/**
|
|
@@ -81,6 +82,8 @@ export function ttlMap<K, V>(
|
|
|
81
82
|
// The times of the live keys, in the order of expiry: a set deletes its key before it adds it again
|
|
82
83
|
const metadata = new Map<K, TTLEntry<V>>();
|
|
83
84
|
let intervalId: ReturnType<typeof setInterval> | null = null;
|
|
85
|
+
// A removal below (for example an eviction of lruMap) ends the time of its key, so it never expires
|
|
86
|
+
const source = removalSource<MapRemoval<K, V>>(next, ({ key }) => void metadata.delete(key));
|
|
84
87
|
|
|
85
88
|
// This function removes the expired entries from the front of the order, and stops at the first live entry
|
|
86
89
|
const sweep = (): void => {
|
|
@@ -90,6 +93,7 @@ export function ttlMap<K, V>(
|
|
|
90
93
|
metadata.delete(key);
|
|
91
94
|
next.delete(key);
|
|
92
95
|
onExpire?.({ key, value: entry.value, createdAt: entry.createdAt, expiredAt: now });
|
|
96
|
+
source.emit({ key, value: entry.value, reason: "expire" });
|
|
93
97
|
}
|
|
94
98
|
};
|
|
95
99
|
|
|
@@ -107,6 +111,9 @@ export function ttlMap<K, V>(
|
|
|
107
111
|
if (prop === "dispose") {
|
|
108
112
|
return dispose;
|
|
109
113
|
}
|
|
114
|
+
if (prop === removals) {
|
|
115
|
+
return source.channel;
|
|
116
|
+
}
|
|
110
117
|
|
|
111
118
|
// Composite methods (setIfAbsent, compute, merge, putAll and others) go through this
|
|
112
119
|
// proxy's own primitives, so they keep the expiry rules (BUGS-2026-07 L13)
|
|
@@ -205,6 +212,7 @@ export function ttlCollection<T>(
|
|
|
205
212
|
): Collection<T> & { readonly dispose: () => void } => {
|
|
206
213
|
const metadata = new Map<T, { expiresAt: number; createdAt: number }>();
|
|
207
214
|
let intervalId: ReturnType<typeof setInterval> | null = null;
|
|
215
|
+
const source = removalSource<ElementRemoval<T>>(next, ({ value }) => void metadata.delete(value));
|
|
208
216
|
|
|
209
217
|
const checkExpired = (): void => {
|
|
210
218
|
const now = Date.now();
|
|
@@ -229,6 +237,7 @@ export function ttlCollection<T>(
|
|
|
229
237
|
expiredAt: now,
|
|
230
238
|
});
|
|
231
239
|
}
|
|
240
|
+
source.emit({ value: element, reason: "expire" });
|
|
232
241
|
}
|
|
233
242
|
}
|
|
234
243
|
};
|
|
@@ -247,6 +256,9 @@ export function ttlCollection<T>(
|
|
|
247
256
|
if (prop === "dispose") {
|
|
248
257
|
return dispose;
|
|
249
258
|
}
|
|
259
|
+
if (prop === removals) {
|
|
260
|
+
return source.channel;
|
|
261
|
+
}
|
|
250
262
|
|
|
251
263
|
const value = Reflect.get(target, prop, receiver);
|
|
252
264
|
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The removal channel of a stack of layers (C23).
|
|
3
|
+
*
|
|
4
|
+
* Some layers remove entries or elements by themselves. The bounded layers drop one when they are full.
|
|
5
|
+
* `lruMap` evicts one, and `ttlMap` removes an expired one. A layer
|
|
6
|
+
* above them does not call these removals, so it cannot see them. The removal channel tells it.
|
|
7
|
+
*
|
|
8
|
+
* - Each removing layer gives its channel under the key `removals`. Its channel gives its own removals and the
|
|
9
|
+
* removals of the channel below it.
|
|
10
|
+
* - A layer that does not remove forwards the channel below it: a proxy gives each unknown key of its target.
|
|
11
|
+
* - `eventedMap` and the other evented layers emit an event for each removal of the channel below them.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* const cache = compose(eventedMap<string, number>(), lruMap({ capacity: 2 }))(hashMap());
|
|
15
|
+
* cache.on("delete", ({ key, reason }) => console.log(key, reason)); // an eviction gives the reason "evict"
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { Listener, Unsubscribe } from "./events.js";
|
|
19
|
+
|
|
20
|
+
/** The key of the removal channel on a layer. */
|
|
21
|
+
export const removals: unique symbol = Symbol.for("@mark1russell7/collections/removals");
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The cause of a removal: a bounded layer drops, `lruMap` evicts and `ttlMap` removes an expired entry.
|
|
25
|
+
*/
|
|
26
|
+
export type RemovalReason = "drop" | "evict" | "expire";
|
|
27
|
+
|
|
28
|
+
/** A removal from a map: the key and the value that the layer removed. */
|
|
29
|
+
export interface MapRemoval<K, V> {
|
|
30
|
+
readonly key: K;
|
|
31
|
+
readonly value: V;
|
|
32
|
+
readonly reason: RemovalReason;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A removal of an element: the element and the end that it left. A removal from a collection with no ends,
|
|
37
|
+
* for example by `ttlCollection`, has no `end`.
|
|
38
|
+
*/
|
|
39
|
+
export interface ElementRemoval<T> {
|
|
40
|
+
readonly value: T;
|
|
41
|
+
readonly end?: "first" | "last";
|
|
42
|
+
readonly reason: RemovalReason;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A channel of removals. The listener gets each removal after the collection changed. */
|
|
46
|
+
export interface RemovalChannel<R> {
|
|
47
|
+
on(listener: Listener<R>): Unsubscribe;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The channel of a removing layer, and the function that sends its own removals. */
|
|
51
|
+
export interface RemovalSource<R> {
|
|
52
|
+
readonly channel: RemovalChannel<R>;
|
|
53
|
+
emit(removal: R): void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* This function gives the removal channel of a collection, or `undefined` when no layer of it removes.
|
|
58
|
+
*/
|
|
59
|
+
export function removalsOf<R>(collection: object): RemovalChannel<R> | undefined {
|
|
60
|
+
const channel: unknown = (collection as { readonly [removals]?: unknown })[removals];
|
|
61
|
+
if (typeof channel !== "object" || channel === null) return undefined;
|
|
62
|
+
return typeof (channel as { on?: unknown }).on === "function" ? (channel as RemovalChannel<R>) : undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* This function makes the channel of a removing layer over `next`.
|
|
67
|
+
* The channel forwards each removal below. It calls `below` first, so the layer corrects its own state first.
|
|
68
|
+
*/
|
|
69
|
+
export function removalSource<R>(next: object, below?: (removal: R) => void): RemovalSource<R> {
|
|
70
|
+
const listeners = new Set<Listener<R>>();
|
|
71
|
+
const emit = (removal: R): void => {
|
|
72
|
+
for (const listener of [...listeners]) listener(removal);
|
|
73
|
+
};
|
|
74
|
+
removalsOf<R>(next)?.on((removal) => {
|
|
75
|
+
below?.(removal);
|
|
76
|
+
emit(removal);
|
|
77
|
+
});
|
|
78
|
+
const channel: RemovalChannel<R> = {
|
|
79
|
+
on(listener) {
|
|
80
|
+
listeners.add(listener);
|
|
81
|
+
return () => void listeners.delete(listener);
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
return { channel, emit };
|
|
85
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -13,6 +13,7 @@ export * from "./core/traits.js";
|
|
|
13
13
|
export * from "./core/policies.js";
|
|
14
14
|
export * from "./core/middleware.js";
|
|
15
15
|
export * from "./core/events.js";
|
|
16
|
+
export * from "./core/removals.js";
|
|
16
17
|
// ============================================================================
|
|
17
18
|
// Interfaces
|
|
18
19
|
// ============================================================================
|