@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.
@@ -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
- * Automatically removes entries after TTL expires.
66
- * This function uses a background interval to check for expired entries.
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
- const checkExpired = (): void => {
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 >= entry.expiresAt) {
85
- expired.push(key);
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
- // Start background expiration checker
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
- if (typeof value === "function") {
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
- return value;
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
  // ============================================================================