@mark1russell7/collections 0.7.2 → 0.8.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/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.7.2",
4
+ "version": "0.8.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",
@@ -231,6 +231,19 @@ export function boundedList<T>(
231
231
  };
232
232
  }
233
233
 
234
+ /**
235
+ * This function adds each element with `add`, one after the other, until `add` refuses one.
236
+ * It gives true when one element goes in. A throw of `add` stops it, and the earlier elements stay.
237
+ */
238
+ function addEach<T>(elements: Iterable<T>, add: (element: T) => boolean): boolean {
239
+ let added = false;
240
+ for (const element of Array.from(elements)) {
241
+ if (!add(element)) break;
242
+ added = true;
243
+ }
244
+ return added;
245
+ }
246
+
234
247
  /**
235
248
  * This function makes a bounded Queue middleware.
236
249
  */
@@ -294,6 +307,12 @@ export function boundedQueue<T>(
294
307
  }
295
308
  return (value as Function).call(target, element);
296
309
  };
310
+
311
+ // The base addAll goes past the bound, so each element goes through add of this proxy (C10)
312
+ case "addAll":
313
+ return function (elements: Iterable<T>): boolean {
314
+ return addEach(elements, (element) => (receiver as Queue<T>).add(element));
315
+ };
297
316
  }
298
317
  }
299
318
 
@@ -313,7 +332,8 @@ export function boundedDeque<T>(
313
332
  const capacity = checkCapacity(options.capacity, "boundedDeque");
314
333
 
315
334
  return (next: Deque<T>): Deque<T> & BoundedCollection => {
316
- const handleOverflow = (element: T): void => {
335
+ // An insert at the front drops the last element, as unshift of boundedList does (C10)
336
+ const handleOverflow = (element: T, dropFrom: "first" | "last"): void => {
317
337
  if (onOverflow) {
318
338
  const context: OverflowContext<T> = {
319
339
  newElement: element,
@@ -329,7 +349,8 @@ export function boundedDeque<T>(
329
349
  throw new Error(`Deque is full (capacity: ${capacity})`);
330
350
  case "drop-oldest":
331
351
  if (!next.isEmpty) {
332
- next.removeFirst();
352
+ if (dropFrom === "last") next.removeLast();
353
+ else next.removeFirst();
333
354
  }
334
355
  break;
335
356
  case "drop-newest":
@@ -363,13 +384,19 @@ export function boundedDeque<T>(
363
384
  case "add":
364
385
  return function (element: T): any {
365
386
  if (target.size >= capacity && policy !== "grow") {
366
- handleOverflow(element);
387
+ handleOverflow(element, prop === "addFirst" || prop === "offerFirst" ? "last" : "first");
367
388
  if (policy === "reject" || policy === "drop-newest") {
368
389
  return prop.startsWith("offer") || prop === "add" ? false : undefined;
369
390
  }
370
391
  }
371
392
  return (value as Function).call(target, element);
372
393
  };
394
+
395
+ // The base addAll goes past the bound, so each element goes through add of this proxy (C10)
396
+ case "addAll":
397
+ return function (elements: Iterable<T>): boolean {
398
+ return addEach(elements, (element) => (receiver as Deque<T>).add(element));
399
+ };
373
400
  }
374
401
  }
375
402
 
@@ -240,8 +240,10 @@ export function lruMap<K, V>(
240
240
  if (stale) {
241
241
  removeNode(stale);
242
242
  }
243
- // Keep the node list bounded: when it reaches capacity, drop stale nodes first
244
- if (nodeMap.size >= capacity) {
243
+ // Keep the node list bounded. At most `capacity` nodes are live, so at twice the capacity a scan
244
+ // removes at least `capacity` stale nodes: the cost of a set is amortized O(1). A scan at the
245
+ // capacity read each node at each set into a full map (C10). evictLRU skips the other stale nodes.
246
+ if (nodeMap.size >= 2 * capacity) {
245
247
  dropStaleNodes();
246
248
  }
247
249
  if (target.size >= capacity) {
@@ -14,7 +14,7 @@
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
19
  import type { Collection } from "../interfaces/collection.js";
20
20
 
@@ -62,8 +62,13 @@ interface TTLEntry<V> {
62
62
  /**
63
63
  * This function makes a TTL Map middleware.
64
64
  *
65
- * Automatically removes entries after TTL expires.
66
- * This function uses a background interval to check for expired entries.
65
+ * An entry is live while the time is less than the time of its last `set` plus `ttl`.
66
+ * Each access to the map first removes the expired entries, so each read shows only live entries.
67
+ * The layer gives each expired entry to `onExpire` one time, in the order of expiry.
68
+ * A background interval also removes expired entries, so `onExpire` occurs with no access.
69
+ *
70
+ * The layer keeps the times in the order of expiry, so the removal before an access is amortized O(1).
71
+ * 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
72
  */
68
73
  export function ttlMap<K, V>(
69
74
  options: TTLOptions
@@ -73,39 +78,22 @@ export function ttlMap<K, V>(
73
78
  return (
74
79
  next: MapLike<K, V>
75
80
  ): MapLike<K, V> & { readonly dispose: () => void } => {
81
+ // The times of the live keys, in the order of expiry: a set deletes its key before it adds it again
76
82
  const metadata = new Map<K, TTLEntry<V>>();
77
83
  let intervalId: ReturnType<typeof setInterval> | null = null;
78
84
 
79
- const checkExpired = (): void => {
85
+ // This function removes the expired entries from the front of the order, and stops at the first live entry
86
+ const sweep = (): void => {
80
87
  const now = Date.now();
81
- const expired: K[] = [];
82
-
83
88
  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
- }
89
+ if (now < entry.expiresAt) return;
90
+ metadata.delete(key);
91
+ next.delete(key);
92
+ onExpire?.({ key, value: entry.value, createdAt: entry.createdAt, expiredAt: now });
104
93
  }
105
94
  };
106
95
 
107
- // Start background expiration checker
108
- intervalId = startExpirationTimer(checkExpired, checkInterval);
96
+ intervalId = startExpirationTimer(sweep, checkInterval);
109
97
 
110
98
  const dispose = (): void => {
111
99
  if (intervalId) {
@@ -116,7 +104,6 @@ export function ttlMap<K, V>(
116
104
 
117
105
  return new Proxy(next, {
118
106
  get(target, prop, receiver) {
119
- // Add dispose method
120
107
  if (prop === "dispose") {
121
108
  return dispose;
122
109
  }
@@ -128,64 +115,73 @@ export function ttlMap<K, V>(
128
115
  return composite;
129
116
  }
130
117
 
118
+ // A getter (size, isEmpty) reads only live entries
119
+ sweep();
131
120
  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
- }
121
+ if (typeof value !== "function") {
122
+ return value;
186
123
  }
187
124
 
188
- return value;
125
+ switch (prop) {
126
+ case "set":
127
+ return function (key: K, val: V): V | undefined {
128
+ sweep();
129
+ const result = (value as Function).call(target, key, val);
130
+ const now = Date.now();
131
+ metadata.delete(key);
132
+ metadata.set(key, { value: val, expiresAt: now + ttl, createdAt: now });
133
+ return result;
134
+ };
135
+
136
+ case "delete":
137
+ return function (key: K): V | undefined {
138
+ sweep();
139
+ metadata.delete(key);
140
+ return (value as Function).call(target, key);
141
+ };
142
+
143
+ case "clear":
144
+ return function (): void {
145
+ metadata.clear();
146
+ (value as Function).call(target);
147
+ };
148
+
149
+ // The iteration gives the entries that are live when it starts. A removal while it runs (by a read in
150
+ // the loop, or by the interval between the steps of an async loop) does not change it, so the base
151
+ // iterator never sees a change and never throws.
152
+ case Symbol.iterator:
153
+ case "entries":
154
+ return function (): IterableIterator<Entry<K, V>> {
155
+ sweep();
156
+ return Array.from(target as Iterable<Entry<K, V>>).values();
157
+ };
158
+
159
+ case "keys":
160
+ return function (): IterableIterator<K> {
161
+ sweep();
162
+ return Array.from(target as Iterable<Entry<K, V>>, (e) => e.key).values();
163
+ };
164
+
165
+ case "values":
166
+ return function (): IterableIterator<V> {
167
+ sweep();
168
+ return Array.from(target as Iterable<Entry<K, V>>, (e) => e.value).values();
169
+ };
170
+
171
+ case "forEach":
172
+ return function (callback: (value: V, key: K, map: MapLike<K, V>) => void): void {
173
+ sweep();
174
+ for (const e of Array.from(target as Iterable<Entry<K, V>>)) callback(e.value, e.key, receiver as MapLike<K, V>);
175
+ };
176
+
177
+ default:
178
+ // Each other method (get, has, getOrUndefined, the iteration) reads only live entries,
179
+ // also when a caller keeps the method and calls it later
180
+ return function (this: unknown, ...args: unknown[]): unknown {
181
+ sweep();
182
+ return (value as Function).apply(target, args);
183
+ };
184
+ }
189
185
  },
190
186
  }) as MapLike<K, V> & { readonly dispose: () => void };
191
187
  };