architex-js 1.10.0 → 1.11.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,6 +1,6 @@
1
1
  {
2
2
  "name": "architex-js",
3
- "version": "1.10.0",
3
+ "version": "1.11.0",
4
4
  "main": "src/index.js",
5
5
  "exports": {
6
6
  ".": "./src/index.js",
@@ -14,7 +14,8 @@
14
14
  "./repository": "./src/repository/index.js",
15
15
  "./events": "./src/events/index.js",
16
16
  "./cqrs": "./src/cqrs/index.js",
17
- "./scheduler": "./src/scheduler/index.js"
17
+ "./scheduler": "./src/scheduler/index.js",
18
+ "./cache": "./src/cache/index.js"
18
19
  },
19
20
  "description": "Architectural Toolbox for JavaScript - Providing high-level building blocks for robust systems.",
20
21
  "author": {
@@ -0,0 +1,115 @@
1
+ /**
2
+ * In-memory cache with TTL (time-to-live), lazy expiry, and `remember()` helper.
3
+ *
4
+ * @example
5
+ * const cache = new Cache({ ttl: 60000 }); // 60 seconds default TTL
6
+ * const user = await cache.remember('user:1', () => fetchUser(1));
7
+ */
8
+ class Cache {
9
+ /**
10
+ * @param {{ ttl?: number }} [options]
11
+ * @param {number} [options.ttl=0] - Default TTL in milliseconds. 0 = no expiry.
12
+ */
13
+ constructor({ ttl = 0 } = {}) {
14
+ /** @type {number} Default TTL in ms */
15
+ this._defaultTtl = ttl;
16
+ /** @type {Map<string, { value: any, expiresAt: number | null }>} */
17
+ this._store = new Map();
18
+ }
19
+
20
+ /**
21
+ * Stores a value in the cache.
22
+ * @param {string} key
23
+ * @param {any} value
24
+ * @param {number} [ttl] - TTL in milliseconds. Falls back to the default. 0 = no expiry.
25
+ */
26
+ set(key, value, ttl = this._defaultTtl) {
27
+ const expiresAt = ttl > 0 ? Date.now() + ttl : null;
28
+ this._store.set(key, { value, expiresAt });
29
+ }
30
+
31
+ /**
32
+ * Retrieves a value. Returns `undefined` if the key doesn't exist or has expired.
33
+ * @param {string} key
34
+ * @returns {any | undefined}
35
+ */
36
+ get(key) {
37
+ const entry = this._store.get(key);
38
+ if (!entry) return undefined;
39
+
40
+ if (entry.expiresAt !== null && Date.now() >= entry.expiresAt) {
41
+ this._store.delete(key);
42
+ return undefined;
43
+ }
44
+
45
+ return entry.value;
46
+ }
47
+
48
+ /**
49
+ * Returns true if the key exists and has not expired.
50
+ * @param {string} key
51
+ * @returns {boolean}
52
+ */
53
+ has(key) {
54
+ return this.get(key) !== undefined;
55
+ }
56
+
57
+ /**
58
+ * Removes a key from the cache.
59
+ * @param {string} key
60
+ */
61
+ delete(key) {
62
+ this._store.delete(key);
63
+ }
64
+
65
+ /**
66
+ * Clears all entries in the cache.
67
+ */
68
+ clear() {
69
+ this._store.clear();
70
+ }
71
+
72
+ /**
73
+ * Returns the cached value if it exists; otherwise calls the factory,
74
+ * caches the result, and returns it. (Cache-aside pattern)
75
+ * @template T
76
+ * @param {string} key
77
+ * @param {() => T | Promise<T>} factory - Function that generates the value if cache misses.
78
+ * @param {number} [ttl] - Optional TTL override.
79
+ * @returns {Promise<T>}
80
+ */
81
+ async remember(key, factory, ttl = this._defaultTtl) {
82
+ const cached = this.get(key);
83
+ if (cached !== undefined) return cached;
84
+
85
+ const value = await factory();
86
+ this.set(key, value, ttl);
87
+ return value;
88
+ }
89
+
90
+ /**
91
+ * Returns the number of entries in the cache (including potentially expired ones).
92
+ * @returns {number}
93
+ */
94
+ get size() {
95
+ return this._store.size;
96
+ }
97
+
98
+ /**
99
+ * Purges all expired entries.
100
+ * @returns {number} Number of entries removed.
101
+ */
102
+ purgeExpired() {
103
+ const now = Date.now();
104
+ let removed = 0;
105
+ for (const [key, entry] of this._store) {
106
+ if (entry.expiresAt !== null && now >= entry.expiresAt) {
107
+ this._store.delete(key);
108
+ removed++;
109
+ }
110
+ }
111
+ return removed;
112
+ }
113
+ }
114
+
115
+ export { Cache };
@@ -0,0 +1 @@
1
+ export * from "./Cache.js";
package/src/index.js CHANGED
@@ -8,4 +8,5 @@ export * from "./guards/index.js";
8
8
  export * from "./repository/index.js";
9
9
  export * from "./events/index.js";
10
10
  export * from "./cqrs/index.js";
11
- export * from "./scheduler/index.js";
11
+ export * from "./scheduler/index.js";
12
+ export * from "./cache/index.js";
@@ -0,0 +1,88 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { Cache } from '../src/cache/index.js';
3
+
4
+ describe('Cache', () => {
5
+ let cache;
6
+
7
+ beforeEach(() => {
8
+ vi.useFakeTimers();
9
+ cache = new Cache({ ttl: 1000 });
10
+ });
11
+
12
+ afterEach(() => {
13
+ vi.useRealTimers();
14
+ });
15
+
16
+ it('should set and get a value', () => {
17
+ cache.set('key', 'value');
18
+ expect(cache.get('key')).toBe('value');
19
+ });
20
+
21
+ it('should return undefined for a missing key', () => {
22
+ expect(cache.get('missing')).toBeUndefined();
23
+ });
24
+
25
+ it('should expire entries after TTL', () => {
26
+ cache.set('key', 42, 500);
27
+ vi.advanceTimersByTime(499);
28
+ expect(cache.get('key')).toBe(42);
29
+
30
+ vi.advanceTimersByTime(1);
31
+ expect(cache.get('key')).toBeUndefined();
32
+ });
33
+
34
+ it('should not expire entries with ttl=0', () => {
35
+ const persistent = new Cache({ ttl: 0 });
36
+ persistent.set('forever', 'yes');
37
+ vi.advanceTimersByTime(9999999);
38
+ expect(persistent.get('forever')).toBe('yes');
39
+ });
40
+
41
+ it('has() should return true for existing non-expired keys', () => {
42
+ cache.set('x', 1);
43
+ expect(cache.has('x')).toBe(true);
44
+ });
45
+
46
+ it('has() should return false for expired keys', () => {
47
+ cache.set('x', 1, 100);
48
+ vi.advanceTimersByTime(101);
49
+ expect(cache.has('x')).toBe(false);
50
+ });
51
+
52
+ it('delete() should remove a key', () => {
53
+ cache.set('dKey', 'val');
54
+ cache.delete('dKey');
55
+ expect(cache.get('dKey')).toBeUndefined();
56
+ });
57
+
58
+ it('clear() should remove all entries', () => {
59
+ cache.set('a', 1);
60
+ cache.set('b', 2);
61
+ cache.clear();
62
+ expect(cache.size).toBe(0);
63
+ });
64
+
65
+ it('remember() should call the factory on cache miss', async () => {
66
+ const factory = vi.fn().mockResolvedValue('fetched');
67
+ const result = await cache.remember('r', factory);
68
+ expect(result).toBe('fetched');
69
+ expect(factory).toHaveBeenCalledTimes(1);
70
+ });
71
+
72
+ it('remember() should not call factory on cache hit', async () => {
73
+ const factory = vi.fn().mockResolvedValue('fetched');
74
+ await cache.remember('r', factory);
75
+ await cache.remember('r', factory);
76
+ expect(factory).toHaveBeenCalledTimes(1);
77
+ });
78
+
79
+ it('purgeExpired() should remove expired entries and return count', () => {
80
+ cache.set('a', 1, 100);
81
+ cache.set('b', 2, 100);
82
+ cache.set('c', 3, 99999);
83
+ vi.advanceTimersByTime(101);
84
+ const removed = cache.purgeExpired();
85
+ expect(removed).toBe(2);
86
+ expect(cache.has('c')).toBe(true);
87
+ });
88
+ });