@mark1russell7/collections 0.7.2 → 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 +12 -0
- package/build/index.d.ts +58 -8
- package/build/index.d.ts.map +1 -1
- package/build/index.js +232 -75
- package/build/index.js.map +1 -1
- package/package.json +1 -1
- package/src/behaviors/bounded.ts +52 -7
- package/src/behaviors/evented.ts +75 -25
- package/src/behaviors/lru.ts +22 -24
- package/src/behaviors/ttl.ts +92 -84
- package/src/core/removals.ts +85 -0
- package/src/index.ts +1 -0
package/src/behaviors/ttl.ts
CHANGED
|
@@ -14,8 +14,9 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import type { Middleware } from "../core/middleware.js";
|
|
17
|
-
import type { MapLike } from "../interfaces/map.js";
|
|
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
|
/**
|
|
@@ -62,8 +63,13 @@ interface TTLEntry<V> {
|
|
|
62
63
|
/**
|
|
63
64
|
* This function makes a TTL Map middleware.
|
|
64
65
|
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
66
|
+
* An entry is live while the time is less than the time of its last `set` plus `ttl`.
|
|
67
|
+
* Each access to the map first removes the expired entries, so each read shows only live entries.
|
|
68
|
+
* The layer gives each expired entry to `onExpire` one time, in the order of expiry.
|
|
69
|
+
* A background interval also removes expired entries, so `onExpire` occurs with no access.
|
|
70
|
+
*
|
|
71
|
+
* The layer keeps the times in the order of expiry, so the removal before an access is amortized O(1).
|
|
72
|
+
* This order needs a clock that does not go back. If `Date.now()` goes back, an entry can stay until the entries before it expire.
|
|
67
73
|
*/
|
|
68
74
|
export function ttlMap<K, V>(
|
|
69
75
|
options: TTLOptions
|
|
@@ -73,39 +79,25 @@ export function ttlMap<K, V>(
|
|
|
73
79
|
return (
|
|
74
80
|
next: MapLike<K, V>
|
|
75
81
|
): MapLike<K, V> & { readonly dispose: () => void } => {
|
|
82
|
+
// The times of the live keys, in the order of expiry: a set deletes its key before it adds it again
|
|
76
83
|
const metadata = new Map<K, TTLEntry<V>>();
|
|
77
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));
|
|
78
87
|
|
|
79
|
-
|
|
88
|
+
// This function removes the expired entries from the front of the order, and stops at the first live entry
|
|
89
|
+
const sweep = (): void => {
|
|
80
90
|
const now = Date.now();
|
|
81
|
-
const expired: K[] = [];
|
|
82
|
-
|
|
83
91
|
for (const [key, entry] of metadata) {
|
|
84
|
-
if (now
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
for (const key of expired) {
|
|
90
|
-
const entry = metadata.get(key);
|
|
91
|
-
if (entry) {
|
|
92
|
-
next.delete(key);
|
|
93
|
-
metadata.delete(key);
|
|
94
|
-
|
|
95
|
-
if (onExpire) {
|
|
96
|
-
onExpire({
|
|
97
|
-
key,
|
|
98
|
-
value: entry.value,
|
|
99
|
-
createdAt: entry.createdAt,
|
|
100
|
-
expiredAt: now,
|
|
101
|
-
});
|
|
102
|
-
}
|
|
103
|
-
}
|
|
92
|
+
if (now < entry.expiresAt) return;
|
|
93
|
+
metadata.delete(key);
|
|
94
|
+
next.delete(key);
|
|
95
|
+
onExpire?.({ key, value: entry.value, createdAt: entry.createdAt, expiredAt: now });
|
|
96
|
+
source.emit({ key, value: entry.value, reason: "expire" });
|
|
104
97
|
}
|
|
105
98
|
};
|
|
106
99
|
|
|
107
|
-
|
|
108
|
-
intervalId = startExpirationTimer(checkExpired, checkInterval);
|
|
100
|
+
intervalId = startExpirationTimer(sweep, checkInterval);
|
|
109
101
|
|
|
110
102
|
const dispose = (): void => {
|
|
111
103
|
if (intervalId) {
|
|
@@ -116,10 +108,12 @@ export function ttlMap<K, V>(
|
|
|
116
108
|
|
|
117
109
|
return new Proxy(next, {
|
|
118
110
|
get(target, prop, receiver) {
|
|
119
|
-
// Add dispose method
|
|
120
111
|
if (prop === "dispose") {
|
|
121
112
|
return dispose;
|
|
122
113
|
}
|
|
114
|
+
if (prop === removals) {
|
|
115
|
+
return source.channel;
|
|
116
|
+
}
|
|
123
117
|
|
|
124
118
|
// Composite methods (setIfAbsent, compute, merge, putAll and others) go through this
|
|
125
119
|
// proxy's own primitives, so they keep the expiry rules (BUGS-2026-07 L13)
|
|
@@ -128,64 +122,73 @@ export function ttlMap<K, V>(
|
|
|
128
122
|
return composite;
|
|
129
123
|
}
|
|
130
124
|
|
|
125
|
+
// A getter (size, isEmpty) reads only live entries
|
|
126
|
+
sweep();
|
|
131
127
|
const value = Reflect.get(target, prop, receiver);
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
switch (prop) {
|
|
135
|
-
case "get":
|
|
136
|
-
return function (key: K): V {
|
|
137
|
-
// Check if expired before returning
|
|
138
|
-
const entry = metadata.get(key);
|
|
139
|
-
if (entry && Date.now() >= entry.expiresAt) {
|
|
140
|
-
target.delete(key);
|
|
141
|
-
metadata.delete(key);
|
|
142
|
-
throw new Error(`Key expired: ${key}`);
|
|
143
|
-
}
|
|
144
|
-
return (value as Function).call(target, key);
|
|
145
|
-
};
|
|
146
|
-
|
|
147
|
-
case "has":
|
|
148
|
-
return function (key: K): boolean {
|
|
149
|
-
// Check if expired
|
|
150
|
-
const entry = metadata.get(key);
|
|
151
|
-
if (entry && Date.now() >= entry.expiresAt) {
|
|
152
|
-
target.delete(key);
|
|
153
|
-
metadata.delete(key);
|
|
154
|
-
return false;
|
|
155
|
-
}
|
|
156
|
-
return (value as Function).call(target, key);
|
|
157
|
-
};
|
|
158
|
-
|
|
159
|
-
case "set":
|
|
160
|
-
return function (key: K, val: V): V | undefined {
|
|
161
|
-
const now = Date.now();
|
|
162
|
-
const result = (value as Function).call(target, key, val);
|
|
163
|
-
|
|
164
|
-
metadata.set(key, {
|
|
165
|
-
value: val,
|
|
166
|
-
expiresAt: now + ttl,
|
|
167
|
-
createdAt: now,
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
return result;
|
|
171
|
-
};
|
|
172
|
-
|
|
173
|
-
case "delete":
|
|
174
|
-
return function (key: K): V | undefined {
|
|
175
|
-
const result = (value as Function).call(target, key);
|
|
176
|
-
metadata.delete(key);
|
|
177
|
-
return result;
|
|
178
|
-
};
|
|
179
|
-
|
|
180
|
-
case "clear":
|
|
181
|
-
return function (): void {
|
|
182
|
-
(value as Function).call(target);
|
|
183
|
-
metadata.clear();
|
|
184
|
-
};
|
|
185
|
-
}
|
|
128
|
+
if (typeof value !== "function") {
|
|
129
|
+
return value;
|
|
186
130
|
}
|
|
187
131
|
|
|
188
|
-
|
|
132
|
+
switch (prop) {
|
|
133
|
+
case "set":
|
|
134
|
+
return function (key: K, val: V): V | undefined {
|
|
135
|
+
sweep();
|
|
136
|
+
const result = (value as Function).call(target, key, val);
|
|
137
|
+
const now = Date.now();
|
|
138
|
+
metadata.delete(key);
|
|
139
|
+
metadata.set(key, { value: val, expiresAt: now + ttl, createdAt: now });
|
|
140
|
+
return result;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
case "delete":
|
|
144
|
+
return function (key: K): V | undefined {
|
|
145
|
+
sweep();
|
|
146
|
+
metadata.delete(key);
|
|
147
|
+
return (value as Function).call(target, key);
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
case "clear":
|
|
151
|
+
return function (): void {
|
|
152
|
+
metadata.clear();
|
|
153
|
+
(value as Function).call(target);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
// The iteration gives the entries that are live when it starts. A removal while it runs (by a read in
|
|
157
|
+
// the loop, or by the interval between the steps of an async loop) does not change it, so the base
|
|
158
|
+
// iterator never sees a change and never throws.
|
|
159
|
+
case Symbol.iterator:
|
|
160
|
+
case "entries":
|
|
161
|
+
return function (): IterableIterator<Entry<K, V>> {
|
|
162
|
+
sweep();
|
|
163
|
+
return Array.from(target as Iterable<Entry<K, V>>).values();
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
case "keys":
|
|
167
|
+
return function (): IterableIterator<K> {
|
|
168
|
+
sweep();
|
|
169
|
+
return Array.from(target as Iterable<Entry<K, V>>, (e) => e.key).values();
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
case "values":
|
|
173
|
+
return function (): IterableIterator<V> {
|
|
174
|
+
sweep();
|
|
175
|
+
return Array.from(target as Iterable<Entry<K, V>>, (e) => e.value).values();
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
case "forEach":
|
|
179
|
+
return function (callback: (value: V, key: K, map: MapLike<K, V>) => void): void {
|
|
180
|
+
sweep();
|
|
181
|
+
for (const e of Array.from(target as Iterable<Entry<K, V>>)) callback(e.value, e.key, receiver as MapLike<K, V>);
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
default:
|
|
185
|
+
// Each other method (get, has, getOrUndefined, the iteration) reads only live entries,
|
|
186
|
+
// also when a caller keeps the method and calls it later
|
|
187
|
+
return function (this: unknown, ...args: unknown[]): unknown {
|
|
188
|
+
sweep();
|
|
189
|
+
return (value as Function).apply(target, args);
|
|
190
|
+
};
|
|
191
|
+
}
|
|
189
192
|
},
|
|
190
193
|
}) as MapLike<K, V> & { readonly dispose: () => void };
|
|
191
194
|
};
|
|
@@ -209,6 +212,7 @@ export function ttlCollection<T>(
|
|
|
209
212
|
): Collection<T> & { readonly dispose: () => void } => {
|
|
210
213
|
const metadata = new Map<T, { expiresAt: number; createdAt: number }>();
|
|
211
214
|
let intervalId: ReturnType<typeof setInterval> | null = null;
|
|
215
|
+
const source = removalSource<ElementRemoval<T>>(next, ({ value }) => void metadata.delete(value));
|
|
212
216
|
|
|
213
217
|
const checkExpired = (): void => {
|
|
214
218
|
const now = Date.now();
|
|
@@ -233,6 +237,7 @@ export function ttlCollection<T>(
|
|
|
233
237
|
expiredAt: now,
|
|
234
238
|
});
|
|
235
239
|
}
|
|
240
|
+
source.emit({ value: element, reason: "expire" });
|
|
236
241
|
}
|
|
237
242
|
}
|
|
238
243
|
};
|
|
@@ -251,6 +256,9 @@ export function ttlCollection<T>(
|
|
|
251
256
|
if (prop === "dispose") {
|
|
252
257
|
return dispose;
|
|
253
258
|
}
|
|
259
|
+
if (prop === removals) {
|
|
260
|
+
return source.channel;
|
|
261
|
+
}
|
|
254
262
|
|
|
255
263
|
const value = Reflect.get(target, prop, receiver);
|
|
256
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
|
// ============================================================================
|