@metreeca/keep 0.9.0 → 0.10.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 +221 -24
- package/dist/clients/batching.core.d.ts +29 -0
- package/dist/clients/batching.core.d.ts.map +1 -0
- package/dist/clients/batching.core.js +114 -0
- package/dist/clients/batching.core.js.map +1 -0
- package/dist/clients/batching.d.ts +223 -0
- package/dist/clients/batching.d.ts.map +1 -0
- package/dist/clients/batching.js +105 -0
- package/dist/clients/batching.js.map +1 -0
- package/dist/clients/validating.d.ts +38 -0
- package/dist/clients/validating.d.ts.map +1 -0
- package/dist/clients/validating.js +128 -0
- package/dist/clients/validating.js.map +1 -0
- package/dist/index.d.ts +358 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/{states/index.js → index.js} +1 -1
- package/dist/index.js.map +1 -0
- package/dist/stores/caching.d.ts +96 -0
- package/dist/stores/caching.d.ts.map +1 -0
- package/dist/stores/caching.js +311 -0
- package/dist/stores/caching.js.map +1 -0
- package/dist/stores/managing.d.ts +55 -0
- package/dist/stores/managing.d.ts.map +1 -0
- package/dist/stores/managing.js +159 -0
- package/dist/stores/managing.js.map +1 -0
- package/package.json +33 -17
- package/dist/index/index.d.ts +0 -2
- package/dist/index/index.d.ts.map +0 -1
- package/dist/index/index.js +0 -17
- package/dist/index/index.js.map +0 -1
- package/dist/states/index.d.ts +0 -2
- package/dist/states/index.d.ts.map +0 -1
- package/dist/states/index.js.map +0 -1
- package/dist/stores/index.d.ts +0 -2
- package/dist/stores/index.d.ts.map +0 -1
- package/dist/stores/index.js +0 -17
- package/dist/stores/index.js.map +0 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG"}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { Reference } from "@metreeca/qest/resource";
|
|
2
|
+
import type { Store } from "../index.js";
|
|
3
|
+
/**
|
|
4
|
+
* Creates a caching store backed by a delegate.
|
|
5
|
+
*
|
|
6
|
+
* Serves repeated retrievals from an in-memory cache, sparing the delegate a round-trip whenever a prior retrieval
|
|
7
|
+
* can be reused; this cuts latency and backend load for read-heavy workloads, while writes and eviction keep cached
|
|
8
|
+
* results coherent and bounded.
|
|
9
|
+
*
|
|
10
|
+
* Retrievals are memoised under an `(entry, model, locale, limit)` key, where the model is canonicalised bottom-up so
|
|
11
|
+
* templates differing only in property or element order (including arrays of objects) share a cache entry, widening
|
|
12
|
+
* the set of requests a single cached result can serve.
|
|
13
|
+
*
|
|
14
|
+
* > [!IMPORTANT]
|
|
15
|
+
* > `shape` is deliberately excluded from the cache key: the retrieval result must be a pure function of
|
|
16
|
+
* > `(entry, model, locale, limit)`, with `shape` supplying only structural metadata that does not affect the
|
|
17
|
+
* > returned payload.
|
|
18
|
+
*
|
|
19
|
+
* > [!IMPORTANT]
|
|
20
|
+
* > `opts.locale` is part of the cache key because it selects which localised content a retrieval returns. Unlike
|
|
21
|
+
* > the model, the locale priority list is order-significant — `["en", "it"]` and `["it", "en"]` key separately —
|
|
22
|
+
* > since order encodes language-negotiation preference. An omitted or empty locale list collapses to a single key,
|
|
23
|
+
* > distinct from any explicit list.
|
|
24
|
+
*
|
|
25
|
+
* > [!IMPORTANT]
|
|
26
|
+
* > `opts.limit` is part of the cache key because it caps each selection's `#` and so changes the returned payload,
|
|
27
|
+
* > with an omitted or `0` limit collapsing to a single unbounded key. `opts.plain`/`opts.depth` are excluded: they
|
|
28
|
+
* > only accept or reject a model, never altering a successful payload.
|
|
29
|
+
*
|
|
30
|
+
* The cache offers three observable guarantees:
|
|
31
|
+
*
|
|
32
|
+
* - **coalescing** — concurrent retrievals for the same key resolve from a single delegate fetch, and a failed
|
|
33
|
+
* retrieval is never cached, so a later retrieval re-fetches;
|
|
34
|
+
* - **read-after-write coherence** — a retrieval never returns content staler than the latest write made through this
|
|
35
|
+
* store: {@link Store.create create}, {@link Store.update update}, {@link Store.delete delete},
|
|
36
|
+
* {@link Store.insert insert} and {@link Store.remove remove} invalidate matching entries, and a
|
|
37
|
+
* retrieval overlapping an in-progress write to the same entry is served from the delegate without being cached;
|
|
38
|
+
* - **commit coherence** — writes committed through {@link Store.execute execute}, by other clients sharing the
|
|
39
|
+
* delegate, or signalled by the backend invalidate matching entries on commit, for as long as the store stays open
|
|
40
|
+
* ({@link Store.close close} ends further invalidation).
|
|
41
|
+
*
|
|
42
|
+
* The configurable `match` predicate selects which cached entries a write invalidates.
|
|
43
|
+
*
|
|
44
|
+
* > [!IMPORTANT]
|
|
45
|
+
* > **Transaction Isolation** — Inherited from the wrapped {@link Store}.
|
|
46
|
+
*
|
|
47
|
+
* > [!IMPORTANT]
|
|
48
|
+
* > **Mutation Events** — Inherited from the wrapped {@link Store}.
|
|
49
|
+
*
|
|
50
|
+
* @param store - The delegate store to cache
|
|
51
|
+
* @param options - Cache configuration options
|
|
52
|
+
*
|
|
53
|
+
* @returns An immutable store with caching behaviour
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* ```typescript
|
|
57
|
+
* const cache = createCachingStore(restStore, { size: 1_000, ttl: 60_000 });
|
|
58
|
+
* const product = await cache.lookup({ entry, shape, model });
|
|
59
|
+
* ```
|
|
60
|
+
*/
|
|
61
|
+
export declare function createCachingStore(store: Store, { size, ttl, match }?: {
|
|
62
|
+
/**
|
|
63
|
+
* Maximum number of records to cache.
|
|
64
|
+
*
|
|
65
|
+
* When the cache exceeds this bound, the least-recently-used record is evicted after each miss.
|
|
66
|
+
*
|
|
67
|
+
* @defaultValue 0 (unlimited)
|
|
68
|
+
*/
|
|
69
|
+
readonly size?: number;
|
|
70
|
+
/**
|
|
71
|
+
* Maximum record age in milliseconds.
|
|
72
|
+
*
|
|
73
|
+
* Records older than this value are treated as misses on the next retrieval and evicted when a sibling miss
|
|
74
|
+
* triggers the TTL sweep. Expiration is checked on every hit, so a stale record is never returned even if the
|
|
75
|
+
* sweep hasn't run yet.
|
|
76
|
+
*
|
|
77
|
+
* @defaultValue 0 (no expiration)
|
|
78
|
+
*/
|
|
79
|
+
readonly ttl?: number;
|
|
80
|
+
/**
|
|
81
|
+
* Predicate deciding which cached records an invalidation event discards.
|
|
82
|
+
*
|
|
83
|
+
* Invoked by both invalidation paths — pre-commit writes on the wrapper and reactive mutation events from the
|
|
84
|
+
* delegate — with the mutated resource's identifier and the identifier carried on each cached record. Return
|
|
85
|
+
* true to evict the record.
|
|
86
|
+
*
|
|
87
|
+
* @param entry - Identifier of the mutated resource
|
|
88
|
+
* @param cache - Identifier associated with a cached record
|
|
89
|
+
*
|
|
90
|
+
* @returns true if the record should be evicted; false otherwise
|
|
91
|
+
*
|
|
92
|
+
* @defaultValue `() => true` (every mutation clears the cache)
|
|
93
|
+
*/
|
|
94
|
+
readonly match?: (entry: Reference, cache: Reference) => boolean;
|
|
95
|
+
}): Store;
|
|
96
|
+
//# sourceMappingURL=caching.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"caching.d.ts","sourceRoot":"","sources":["../../src/stores/caching.ts"],"names":[],"mappings":"AA8BA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yBAAyB,CAAC;AAEzD,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,aAAa,CAAC;AAGzC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyDG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,KAAK,EAAE,EAEhD,IAAQ,EACR,GAAO,EACP,KAAkB,EAElB,GAAE;IAEF;;;;;;OAMG;IACH,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IAEvB;;;;;;;;OAQG;IACH,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IAEtB;;;;;;;;;;;;;OAaG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,SAAS,EAAE,KAAK,EAAE,SAAS,KAAK,OAAO,CAAC;CAE5D,GAAG,KAAK,CAyUb"}
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright © 2025-2026 Metreeca srl
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Caching store wrapper.
|
|
18
|
+
*
|
|
19
|
+
* Provides {@link createCachingStore}, which wraps a {@link Store} with an in-memory cache
|
|
20
|
+
* supporting LRU and TTL-based eviction, in-flight request sharing, an in-flight write bypass that prevents
|
|
21
|
+
* pre-commit values from being cached, and coherent invalidation driven by both the wrapper's own write
|
|
22
|
+
* operations and the delegate's mutation events.
|
|
23
|
+
*
|
|
24
|
+
* @module
|
|
25
|
+
*/
|
|
26
|
+
import { isArray, isObject } from "@metreeca/core";
|
|
27
|
+
import { immutable } from "@metreeca/core/structures";
|
|
28
|
+
/**
|
|
29
|
+
* Creates a caching store backed by a delegate.
|
|
30
|
+
*
|
|
31
|
+
* Serves repeated retrievals from an in-memory cache, sparing the delegate a round-trip whenever a prior retrieval
|
|
32
|
+
* can be reused; this cuts latency and backend load for read-heavy workloads, while writes and eviction keep cached
|
|
33
|
+
* results coherent and bounded.
|
|
34
|
+
*
|
|
35
|
+
* Retrievals are memoised under an `(entry, model, locale, limit)` key, where the model is canonicalised bottom-up so
|
|
36
|
+
* templates differing only in property or element order (including arrays of objects) share a cache entry, widening
|
|
37
|
+
* the set of requests a single cached result can serve.
|
|
38
|
+
*
|
|
39
|
+
* > [!IMPORTANT]
|
|
40
|
+
* > `shape` is deliberately excluded from the cache key: the retrieval result must be a pure function of
|
|
41
|
+
* > `(entry, model, locale, limit)`, with `shape` supplying only structural metadata that does not affect the
|
|
42
|
+
* > returned payload.
|
|
43
|
+
*
|
|
44
|
+
* > [!IMPORTANT]
|
|
45
|
+
* > `opts.locale` is part of the cache key because it selects which localised content a retrieval returns. Unlike
|
|
46
|
+
* > the model, the locale priority list is order-significant — `["en", "it"]` and `["it", "en"]` key separately —
|
|
47
|
+
* > since order encodes language-negotiation preference. An omitted or empty locale list collapses to a single key,
|
|
48
|
+
* > distinct from any explicit list.
|
|
49
|
+
*
|
|
50
|
+
* > [!IMPORTANT]
|
|
51
|
+
* > `opts.limit` is part of the cache key because it caps each selection's `#` and so changes the returned payload,
|
|
52
|
+
* > with an omitted or `0` limit collapsing to a single unbounded key. `opts.plain`/`opts.depth` are excluded: they
|
|
53
|
+
* > only accept or reject a model, never altering a successful payload.
|
|
54
|
+
*
|
|
55
|
+
* The cache offers three observable guarantees:
|
|
56
|
+
*
|
|
57
|
+
* - **coalescing** — concurrent retrievals for the same key resolve from a single delegate fetch, and a failed
|
|
58
|
+
* retrieval is never cached, so a later retrieval re-fetches;
|
|
59
|
+
* - **read-after-write coherence** — a retrieval never returns content staler than the latest write made through this
|
|
60
|
+
* store: {@link Store.create create}, {@link Store.update update}, {@link Store.delete delete},
|
|
61
|
+
* {@link Store.insert insert} and {@link Store.remove remove} invalidate matching entries, and a
|
|
62
|
+
* retrieval overlapping an in-progress write to the same entry is served from the delegate without being cached;
|
|
63
|
+
* - **commit coherence** — writes committed through {@link Store.execute execute}, by other clients sharing the
|
|
64
|
+
* delegate, or signalled by the backend invalidate matching entries on commit, for as long as the store stays open
|
|
65
|
+
* ({@link Store.close close} ends further invalidation).
|
|
66
|
+
*
|
|
67
|
+
* The configurable `match` predicate selects which cached entries a write invalidates.
|
|
68
|
+
*
|
|
69
|
+
* > [!IMPORTANT]
|
|
70
|
+
* > **Transaction Isolation** — Inherited from the wrapped {@link Store}.
|
|
71
|
+
*
|
|
72
|
+
* > [!IMPORTANT]
|
|
73
|
+
* > **Mutation Events** — Inherited from the wrapped {@link Store}.
|
|
74
|
+
*
|
|
75
|
+
* @param store - The delegate store to cache
|
|
76
|
+
* @param options - Cache configuration options
|
|
77
|
+
*
|
|
78
|
+
* @returns An immutable store with caching behaviour
|
|
79
|
+
*
|
|
80
|
+
* @example
|
|
81
|
+
* ```typescript
|
|
82
|
+
* const cache = createCachingStore(restStore, { size: 1_000, ttl: 60_000 });
|
|
83
|
+
* const product = await cache.lookup({ entry, shape, model });
|
|
84
|
+
* ```
|
|
85
|
+
*/
|
|
86
|
+
export function createCachingStore(store, { size = 0, ttl = 0, match = () => true } = {}) {
|
|
87
|
+
// LRU ordering is supplied for free by `Map`'s insertion-order iteration: every hit re-sets the entry,
|
|
88
|
+
// moving it to the tail, so `cache.keys().next().value` is always the least-recently-used key.
|
|
89
|
+
const cache = new Map();
|
|
90
|
+
// Tracks entries with at least one in-flight wrapper-initiated write. Reads of these entries bypass the
|
|
91
|
+
// cache entirely — they still query the delegate, but their result is not stored. This closes the window
|
|
92
|
+
// between a lookup resolving with a pre-commit value and the reactive observer firing on commit, during
|
|
93
|
+
// which the cache would otherwise hold a stale-but-fresh-looking record.
|
|
94
|
+
const writing = new Map();
|
|
95
|
+
// Reactive invalidation: subscribe to the delegate's mutation events and drop matching records on commit.
|
|
96
|
+
// Covers writes routed through `execute`, writes from other clients sharing the delegate, and backends that
|
|
97
|
+
// emit storage-level events. The subscription is released by `close()` so the delegate does not retain a
|
|
98
|
+
// reference to a discarded wrapper.
|
|
99
|
+
const unsubscribe = store.observe(mutations => {
|
|
100
|
+
Object.keys(mutations).forEach(invalidate);
|
|
101
|
+
});
|
|
102
|
+
return immutable({
|
|
103
|
+
lookup({ entry, shape, model }, opts) {
|
|
104
|
+
return memoise(entry, model, opts, () => store.lookup({ entry, shape, model }, opts));
|
|
105
|
+
},
|
|
106
|
+
create({ entry, shape, state }) {
|
|
107
|
+
return write(entry, () => store.create({ entry, shape, state }));
|
|
108
|
+
},
|
|
109
|
+
update({ entry, shape, state }) {
|
|
110
|
+
return write(entry, () => store.update({ entry, shape, state }));
|
|
111
|
+
},
|
|
112
|
+
delete({ entry, shape }) {
|
|
113
|
+
return write(entry, () => store.delete({ entry, shape }));
|
|
114
|
+
},
|
|
115
|
+
insert({ entry, shape, state }, opts) {
|
|
116
|
+
return write(entry, () => store.insert({ entry, shape, state }, opts));
|
|
117
|
+
},
|
|
118
|
+
remove({ entry, shape }) {
|
|
119
|
+
return write(entry, () => store.remove({ entry, shape }));
|
|
120
|
+
},
|
|
121
|
+
observe(observer, resources) {
|
|
122
|
+
return store.observe(observer, resources);
|
|
123
|
+
},
|
|
124
|
+
execute(task) {
|
|
125
|
+
return store.execute(task); // task mutations invalidate the cache reactively through events
|
|
126
|
+
},
|
|
127
|
+
close() {
|
|
128
|
+
unsubscribe();
|
|
129
|
+
return store.close();
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
/**
|
|
133
|
+
* Retrieve-with-cache core.
|
|
134
|
+
*
|
|
135
|
+
* Three branches:
|
|
136
|
+
*
|
|
137
|
+
* - **bypass** — when `entry` has any wrapper-initiated write in flight, runs `miss()` and returns its
|
|
138
|
+
* promise without storing it, so a pre-commit snapshot cannot be installed as fresh;
|
|
139
|
+
* - **fresh hit** — returns the memoised promise and re-inserts the record to bump its LRU position;
|
|
140
|
+
* - **miss / TTL-expired** — runs `miss()`, stores the pending promise under the canonicalised key so
|
|
141
|
+
* concurrent callers share the in-flight request, and enforces TTL/size bounds via {@link purge}; a
|
|
142
|
+
* rejected fetch evicts its record only if an intervening invalidation has not already replaced it.
|
|
143
|
+
*
|
|
144
|
+
* @param entry - Resource identifier; paired with the canonicalised model and varying opts to form the cache key
|
|
145
|
+
* @param model - Retrieval template, canonicalised so that equivalent templates in different orderings share a key
|
|
146
|
+
* @param vary - Retrieval opts whose values vary the returned payload, folded into the key: `locale`
|
|
147
|
+
* order-significantly (an omitted or empty list collapsing to a single key) and `limit` because it caps each
|
|
148
|
+
* selection's `#` pagination bound (an omitted or `0` unbounded value collapsing to a single key)
|
|
149
|
+
* @param miss - Fetcher invoked on cache miss, on TTL expiration, or while the entry has an in-flight write
|
|
150
|
+
*
|
|
151
|
+
* @returns The memoised, freshly-fetched, or bypass-fetched promise
|
|
152
|
+
*/
|
|
153
|
+
function memoise(entry, model, vary = {}, miss) {
|
|
154
|
+
// Bypass the cache while a write to this entry is in flight: still query the delegate so the caller
|
|
155
|
+
// gets a value, but do not store the result — it is liable to be the pre-commit snapshot that the
|
|
156
|
+
// reactive observer would invalidate moments later
|
|
157
|
+
if (writing.has(entry)) {
|
|
158
|
+
return miss();
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
// Cache key layout: `<entry>\x00<canonical-model>\x00<locale>\x00<limit>`. `canonical` walks the model
|
|
162
|
+
// bottom-up, sorting object keys and (already-canonical) array elements, so templates differing only in
|
|
163
|
+
// ordering share a key. The locale segment is serialised verbatim (order preserved, since the priority
|
|
164
|
+
// list is order-significant) with omitted and empty lists collapsing to `[]`. The limit segment is
|
|
165
|
+
// keyed because it caps each selection's `#` and so changes the payload, with omitted and `0` (both
|
|
166
|
+
// unbounded) collapsing to `0`. `\x00` is safe as a separator — IRIs cannot contain it and JSON always
|
|
167
|
+
// escapes it inside string payloads.
|
|
168
|
+
const key = `${entry}\x00${canonical(model)}\x00${JSON.stringify(vary.locale ?? [])}\x00${vary.limit ?? 0}`;
|
|
169
|
+
const cached = cache.get(key);
|
|
170
|
+
if (cached && !expired(cached.created)) {
|
|
171
|
+
// hit: re-insert to move the entry to the tail of the Map's iteration order (the LRU position),
|
|
172
|
+
// and return the memoised promise; the cast reflects the type-erased `Promise<unknown>` storage
|
|
173
|
+
// that lets records with different template types share one Map
|
|
174
|
+
cache.delete(key);
|
|
175
|
+
cache.set(key, cached);
|
|
176
|
+
return cached.value;
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
// miss (or TTL-expired): drop any stale record, run the fetcher, and memoise the pending promise
|
|
180
|
+
// under the same key so concurrent callers share the in-flight request; a failure evicts its own
|
|
181
|
+
// record through the catch handler so transient errors are not retained
|
|
182
|
+
if (cached) {
|
|
183
|
+
cache.delete(key);
|
|
184
|
+
}
|
|
185
|
+
const value = miss().catch(error => {
|
|
186
|
+
// identity-check: only evict if this promise is still the cached one —
|
|
187
|
+
// avoids dropping a newer record installed after an intervening invalidate
|
|
188
|
+
if (cache.get(key)?.value === value) {
|
|
189
|
+
cache.delete(key);
|
|
190
|
+
}
|
|
191
|
+
throw error;
|
|
192
|
+
});
|
|
193
|
+
cache.set(key, {
|
|
194
|
+
entry,
|
|
195
|
+
value,
|
|
196
|
+
created: Date.now()
|
|
197
|
+
});
|
|
198
|
+
purge();
|
|
199
|
+
return value;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Serialises a value to a canonical JSON-like string.
|
|
205
|
+
*
|
|
206
|
+
* Children are canonicalised first, then sorted by their already-canonical string form — so arrays of
|
|
207
|
+
* objects are ordered by structural content rather than by the `"[object Object]"` placeholder that
|
|
208
|
+
* `Array.prototype.sort` would otherwise impose. The output collapses values differing only in property
|
|
209
|
+
* or element order to the same string.
|
|
210
|
+
*
|
|
211
|
+
* @param value - Value to serialise
|
|
212
|
+
*
|
|
213
|
+
* @returns A canonical string representation of `value`
|
|
214
|
+
*/
|
|
215
|
+
function canonical(value) {
|
|
216
|
+
if (isArray(value)) {
|
|
217
|
+
return `[${value.map(canonical).sort().join(",")}]`;
|
|
218
|
+
}
|
|
219
|
+
else if (isObject(value)) {
|
|
220
|
+
return `{${Object.keys(value).sort().map(k => `${JSON.stringify(k)}:${canonical(value[k])}`).join(",")}}`;
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
// JSON.stringify(undefined) returns undefined, not a string; substitute a distinct sentinel so the
|
|
224
|
+
// signature holds and `undefined` does not collide with the string literal `"undefined"` (which JSON
|
|
225
|
+
// would render with surrounding quotes)
|
|
226
|
+
return value === undefined ? "undefined" : JSON.stringify(value);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Tests whether a record is older than the configured TTL.
|
|
231
|
+
*
|
|
232
|
+
* Returns false when `ttl <= 0`, so the predicate can be reused at both the hit guard and the purge sweep
|
|
233
|
+
* without duplicating the boundary check. The reference timestamp defaults to `Date.now` but may be
|
|
234
|
+
* passed in to share a single snapshot across a batch of comparisons.
|
|
235
|
+
*
|
|
236
|
+
* @param created - Insertion timestamp (ms) of the record under test
|
|
237
|
+
* @param now - Reference timestamp (ms) to compare against; defaults to `Date.now`
|
|
238
|
+
*
|
|
239
|
+
* @returns true if `ttl > 0` and `created` is older than `ttl` milliseconds relative to `now`; false otherwise
|
|
240
|
+
*/
|
|
241
|
+
function expired(created, now = Date.now()) {
|
|
242
|
+
return ttl > 0 && now - created > ttl;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Run a wrapper-initiated write under in-flight tracking.
|
|
246
|
+
*
|
|
247
|
+
* Marks `entry` as having an in-flight write, runs the configured pre-commit invalidation, delegates to
|
|
248
|
+
* `op`, and clears the marker once the delegate settles. While the marker is set, retrievals of `entry`
|
|
249
|
+
* bypass the cache so a pre-commit value can never be stored as fresh.
|
|
250
|
+
*
|
|
251
|
+
* @param entry - Resource identifier targeted by the write
|
|
252
|
+
* @param op - Thunk that delegates the write to the wrapped store and returns its promise
|
|
253
|
+
*
|
|
254
|
+
* @returns The promise returned by `op`, settled after the in-flight marker has been cleared
|
|
255
|
+
*/
|
|
256
|
+
function write(entry, op) {
|
|
257
|
+
writing.set(entry, (writing.get(entry) ?? 0) + 1);
|
|
258
|
+
invalidate(entry);
|
|
259
|
+
return op().finally(() => {
|
|
260
|
+
const count = (writing.get(entry) ?? 1) - 1;
|
|
261
|
+
if (count <= 0) {
|
|
262
|
+
writing.delete(entry);
|
|
263
|
+
}
|
|
264
|
+
else {
|
|
265
|
+
writing.set(entry, count);
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Drop cached records matching a mutated resource.
|
|
271
|
+
*
|
|
272
|
+
* Iterates the cache and removes every record whose stored identifier satisfies the configured {@link match}
|
|
273
|
+
* predicate against the mutated `entry`. Called from both the pre-commit write path on the wrapper and the
|
|
274
|
+
* reactive observer attached to the delegate.
|
|
275
|
+
*
|
|
276
|
+
* @param entry - Identifier of the mutated resource
|
|
277
|
+
*/
|
|
278
|
+
function invalidate(entry) {
|
|
279
|
+
for (const [key, record] of cache) {
|
|
280
|
+
if (match(entry, record.entry)) {
|
|
281
|
+
cache.delete(key);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Enforce TTL and size bounds.
|
|
287
|
+
*
|
|
288
|
+
* Runs two sweeps: first, when `ttl > 0`, drops every record older than `ttl` milliseconds; second, when
|
|
289
|
+
* `size > 0` and the cache is overfull, drops the head slice of the Map (least-recently-used first) in
|
|
290
|
+
* a single batch sized to the exact overflow. Invoked after every miss so expired siblings and overfill
|
|
291
|
+
* are cleaned up incrementally — the cache has no background sweeper.
|
|
292
|
+
*/
|
|
293
|
+
function purge() {
|
|
294
|
+
if (ttl > 0) {
|
|
295
|
+
const now = Date.now();
|
|
296
|
+
for (const [key, record] of cache) {
|
|
297
|
+
if (expired(record.created, now)) {
|
|
298
|
+
cache.delete(key);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
// LRU sweep: when over the size bound, take the head slice of Map keys (insertion order = LRU first)
|
|
303
|
+
// and drop them — `cache.size - size` is the exact overflow, so no loop condition needs re-checking
|
|
304
|
+
if (size > 0 && cache.size > size) {
|
|
305
|
+
Array.from(cache.keys())
|
|
306
|
+
.slice(0, cache.size - size)
|
|
307
|
+
.forEach(key => cache.delete(key));
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
//# sourceMappingURL=caching.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"caching.js","sourceRoot":"","sources":["../../src/stores/caching.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { Store, StoreClient } from "../index.js";
|
|
2
|
+
/**
|
|
3
|
+
* Manages mutation events, transactional execution, and lifecycle for a bare {@link StoreClient},
|
|
4
|
+
* exposing it as a full {@link Store}.
|
|
5
|
+
*
|
|
6
|
+
* This lets a connector implement only the {@link StoreClient} data surface and obtain
|
|
7
|
+
* mutation events, transactional execution, and lifecycle for free: supply nothing and the wrapper provides working
|
|
8
|
+
* defaults, or hand it backend primitives through `management` to back any of `execute`, `observe`, or `close`.
|
|
9
|
+
*
|
|
10
|
+
* Each standalone mutation call and each {@link Store.execute execute} call accumulates its own
|
|
11
|
+
* batch of mutation signals and delivers a single filtered event to each matching registered
|
|
12
|
+
* {@link StoreObserver observer} when the call resolves; if it rejects, pending signals are discarded
|
|
13
|
+
* and no observers are notified. Both synchronous throws and asynchronous rejections from observers
|
|
14
|
+
* are caught and silently ignored so that one faulty observer cannot break delivery to others.
|
|
15
|
+
*
|
|
16
|
+
* Any of the {@link Store} management methods may be supplied through `management` to delegate to an inner
|
|
17
|
+
* implementation; the wrapper fills in whatever is missing.
|
|
18
|
+
*
|
|
19
|
+
* All errors — `RangeError`, {@link @metreeca/core!TraceError | TraceError},
|
|
20
|
+
* {@link @metreeca/http!Problem | Problem} —
|
|
21
|
+
* propagate as promise rejections per the unified {@link Store} error channel.
|
|
22
|
+
*
|
|
23
|
+
* > [!IMPORTANT]
|
|
24
|
+
* > **Transaction Isolation** — `None` by default: the built-in `execute` is a deferred identity that applies each
|
|
25
|
+
* > call directly, with no write buffering and no rollback. When an `execute` opt is supplied through `management`,
|
|
26
|
+
* > the level is determined by that wrapper (typically a backend transaction primitive).
|
|
27
|
+
*
|
|
28
|
+
* > [!IMPORTANT]
|
|
29
|
+
* > **Mutation Events** — emits in-process observer events: each standalone mutation and each
|
|
30
|
+
* > {@link Store.execute execute} call delivers a single filtered batch to matching {@link StoreObserver observer}s
|
|
31
|
+
* > on resolve, and none on rejection. Cross-client signals reach local observers only when a backend-bridging
|
|
32
|
+
* > `observe` opt is supplied through `management`.
|
|
33
|
+
*
|
|
34
|
+
* @param store - Inner StoreClient to delegate data calls to
|
|
35
|
+
* @param management - Subset of {@link Store} management methods to delegate to; the wrapper fills in whatever is
|
|
36
|
+
* missing
|
|
37
|
+
* @param management.execute - Wraps every standalone StoreClient call and the body of {@link Store.execute execute},
|
|
38
|
+
* typically a backend transaction primitive (for example, `graph.execute` for a SPARQL connector) responsible for
|
|
39
|
+
* atomic commit and rollback. The wrapper StoreClient passed to the user task does NOT re-enter the opt; it relies
|
|
40
|
+
* on the outer wrap so cross-call isolation works as expected. The task receives a per-call `scope` StoreClient,
|
|
41
|
+
* and every delegated call within the wrap routes through `scope` (mirroring the {@link Store.execute} contract),
|
|
42
|
+
* letting the wrapper install per-call state such as a freshly-bound graph buffer without leaking it across
|
|
43
|
+
* concurrent invocations. Defaults to a deferred identity (`task => Promise.resolve().then(() => task(store))`);
|
|
44
|
+
* user-supplied wrappers MUST pass a per-call `scope` StoreClient to `task` and MUST convert synchronous throws
|
|
45
|
+
* from `task` into promise rejections to preserve the unified Store error channel; the `lookup` path delegates
|
|
46
|
+
* directly to the wrapper and provides no additional guard
|
|
47
|
+
* @param management.observe - Registers each observer with the delegate as well as locally, combining the two
|
|
48
|
+
* unsubscribe handles so a single detach call releases both; this is how storage-level events (mutations from
|
|
49
|
+
* other clients sharing the backend) reach the wrapper's local observers. Absent by default
|
|
50
|
+
* @param management.close - Exposed verbatim on the returned store. Defaults to a resolved no-op
|
|
51
|
+
*
|
|
52
|
+
* @returns An immutable {@link Store} composing `store` with the supplied `management` opts
|
|
53
|
+
*/
|
|
54
|
+
export declare function createManagingStore(store: StoreClient, { observe, execute, close }?: Partial<Store>): Store;
|
|
55
|
+
//# sourceMappingURL=managing.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"managing.d.ts","sourceRoot":"","sources":["../../src/stores/managing.ts"],"names":[],"mappings":"AA+BA,OAAO,KAAK,EAAE,KAAK,EAAE,WAAW,EAAiB,MAAM,aAAa,CAAC;AAGrE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmDG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,WAAW,EAAE,EAEvD,OAAO,EACP,OAAwG,EACxG,KAA+B,EAE/B,GAAE,OAAO,CAAC,KAAK,CAAM,GAAG,KAAK,CA2H7B"}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright © 2025-2026 Metreeca srl
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Managing store wrapper.
|
|
18
|
+
*
|
|
19
|
+
* Manages mutation events, transactional execution, and lifecycle for a plain {@link StoreClient},
|
|
20
|
+
* exposing it as a full {@link Store}.
|
|
21
|
+
*
|
|
22
|
+
* @module
|
|
23
|
+
*/
|
|
24
|
+
import { isString } from "@metreeca/core";
|
|
25
|
+
import { isNestedIRI } from "@metreeca/core/resource";
|
|
26
|
+
import { immutable } from "@metreeca/core/structures";
|
|
27
|
+
/**
|
|
28
|
+
* Manages mutation events, transactional execution, and lifecycle for a bare {@link StoreClient},
|
|
29
|
+
* exposing it as a full {@link Store}.
|
|
30
|
+
*
|
|
31
|
+
* This lets a connector implement only the {@link StoreClient} data surface and obtain
|
|
32
|
+
* mutation events, transactional execution, and lifecycle for free: supply nothing and the wrapper provides working
|
|
33
|
+
* defaults, or hand it backend primitives through `management` to back any of `execute`, `observe`, or `close`.
|
|
34
|
+
*
|
|
35
|
+
* Each standalone mutation call and each {@link Store.execute execute} call accumulates its own
|
|
36
|
+
* batch of mutation signals and delivers a single filtered event to each matching registered
|
|
37
|
+
* {@link StoreObserver observer} when the call resolves; if it rejects, pending signals are discarded
|
|
38
|
+
* and no observers are notified. Both synchronous throws and asynchronous rejections from observers
|
|
39
|
+
* are caught and silently ignored so that one faulty observer cannot break delivery to others.
|
|
40
|
+
*
|
|
41
|
+
* Any of the {@link Store} management methods may be supplied through `management` to delegate to an inner
|
|
42
|
+
* implementation; the wrapper fills in whatever is missing.
|
|
43
|
+
*
|
|
44
|
+
* All errors — `RangeError`, {@link @metreeca/core!TraceError | TraceError},
|
|
45
|
+
* {@link @metreeca/http!Problem | Problem} —
|
|
46
|
+
* propagate as promise rejections per the unified {@link Store} error channel.
|
|
47
|
+
*
|
|
48
|
+
* > [!IMPORTANT]
|
|
49
|
+
* > **Transaction Isolation** — `None` by default: the built-in `execute` is a deferred identity that applies each
|
|
50
|
+
* > call directly, with no write buffering and no rollback. When an `execute` opt is supplied through `management`,
|
|
51
|
+
* > the level is determined by that wrapper (typically a backend transaction primitive).
|
|
52
|
+
*
|
|
53
|
+
* > [!IMPORTANT]
|
|
54
|
+
* > **Mutation Events** — emits in-process observer events: each standalone mutation and each
|
|
55
|
+
* > {@link Store.execute execute} call delivers a single filtered batch to matching {@link StoreObserver observer}s
|
|
56
|
+
* > on resolve, and none on rejection. Cross-client signals reach local observers only when a backend-bridging
|
|
57
|
+
* > `observe` opt is supplied through `management`.
|
|
58
|
+
*
|
|
59
|
+
* @param store - Inner StoreClient to delegate data calls to
|
|
60
|
+
* @param management - Subset of {@link Store} management methods to delegate to; the wrapper fills in whatever is
|
|
61
|
+
* missing
|
|
62
|
+
* @param management.execute - Wraps every standalone StoreClient call and the body of {@link Store.execute execute},
|
|
63
|
+
* typically a backend transaction primitive (for example, `graph.execute` for a SPARQL connector) responsible for
|
|
64
|
+
* atomic commit and rollback. The wrapper StoreClient passed to the user task does NOT re-enter the opt; it relies
|
|
65
|
+
* on the outer wrap so cross-call isolation works as expected. The task receives a per-call `scope` StoreClient,
|
|
66
|
+
* and every delegated call within the wrap routes through `scope` (mirroring the {@link Store.execute} contract),
|
|
67
|
+
* letting the wrapper install per-call state such as a freshly-bound graph buffer without leaking it across
|
|
68
|
+
* concurrent invocations. Defaults to a deferred identity (`task => Promise.resolve().then(() => task(store))`);
|
|
69
|
+
* user-supplied wrappers MUST pass a per-call `scope` StoreClient to `task` and MUST convert synchronous throws
|
|
70
|
+
* from `task` into promise rejections to preserve the unified Store error channel; the `lookup` path delegates
|
|
71
|
+
* directly to the wrapper and provides no additional guard
|
|
72
|
+
* @param management.observe - Registers each observer with the delegate as well as locally, combining the two
|
|
73
|
+
* unsubscribe handles so a single detach call releases both; this is how storage-level events (mutations from
|
|
74
|
+
* other clients sharing the backend) reach the wrapper's local observers. Absent by default
|
|
75
|
+
* @param management.close - Exposed verbatim on the returned store. Defaults to a resolved no-op
|
|
76
|
+
*
|
|
77
|
+
* @returns An immutable {@link Store} composing `store` with the supplied `management` opts
|
|
78
|
+
*/
|
|
79
|
+
export function createManagingStore(store, { observe, execute = (task) => Promise.resolve().then(() => task(store)), close = () => Promise.resolve() } = {}) {
|
|
80
|
+
const observers = new Map();
|
|
81
|
+
return immutable({
|
|
82
|
+
lookup: (specs, opts) => execute(store => store.lookup(specs, opts)),
|
|
83
|
+
create: specs => notify(({ mutated }) => execute(store => store.create(specs).then(mutated))),
|
|
84
|
+
update: specs => notify(({ mutated }) => execute(store => store.update(specs).then(mutated))),
|
|
85
|
+
delete: specs => notify(({ deleted }) => execute(store => store.delete(specs).then(deleted))),
|
|
86
|
+
insert: (specs, opts) => notify(({ mutated }) => execute(store => store.insert(specs, opts).then(mutated))),
|
|
87
|
+
remove: specs => notify(({ deleted }) => execute(store => store.remove(specs).then(deleted))),
|
|
88
|
+
observe(observer, resources) {
|
|
89
|
+
if (Array.isArray(resources) && resources.length === 0) {
|
|
90
|
+
return observe?.(observer, []) ?? (() => { });
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
const token = Symbol();
|
|
94
|
+
const detach = observe?.(observer, resources);
|
|
95
|
+
observers.set(token, {
|
|
96
|
+
observer,
|
|
97
|
+
resources: resources === undefined ? undefined
|
|
98
|
+
: isString(resources) ? [resources]
|
|
99
|
+
: [...resources]
|
|
100
|
+
});
|
|
101
|
+
return () => {
|
|
102
|
+
try {
|
|
103
|
+
detach?.();
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
observers.delete(token);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
execute(task) {
|
|
112
|
+
return notify(({ mutated, deleted }) => execute(store => task(immutable({
|
|
113
|
+
lookup: (specs, opts) => store.lookup(specs, opts),
|
|
114
|
+
create: specs => store.create(specs).then(mutated),
|
|
115
|
+
update: specs => store.update(specs).then(mutated),
|
|
116
|
+
delete: specs => store.delete(specs).then(deleted),
|
|
117
|
+
insert: (specs, opts) => store.insert(specs, opts).then(mutated),
|
|
118
|
+
remove: specs => store.remove(specs).then(deleted)
|
|
119
|
+
}))));
|
|
120
|
+
},
|
|
121
|
+
close
|
|
122
|
+
});
|
|
123
|
+
async function notify(task) {
|
|
124
|
+
const mutations = new Map();
|
|
125
|
+
const value = await task({
|
|
126
|
+
mutated: entry => {
|
|
127
|
+
if (entry !== undefined) {
|
|
128
|
+
mutations.set(entry, true);
|
|
129
|
+
}
|
|
130
|
+
return entry;
|
|
131
|
+
},
|
|
132
|
+
deleted: entry => {
|
|
133
|
+
if (entry !== undefined) {
|
|
134
|
+
mutations.set(entry, false);
|
|
135
|
+
}
|
|
136
|
+
return entry;
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
if (mutations.size > 0) {
|
|
140
|
+
observers.forEach(({ observer, resources }) => {
|
|
141
|
+
const event = {};
|
|
142
|
+
mutations.forEach((exists, id) => {
|
|
143
|
+
// `resources` is `undefined` for an unfiltered registration (fires for every mutation)
|
|
144
|
+
// and an array — possibly empty — for a filtered one
|
|
145
|
+
if (resources === undefined || resources.some(r => isNestedIRI(r, id))) {
|
|
146
|
+
event[id] = exists;
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
if (Object.keys(event).length > 0) {
|
|
150
|
+
Promise.resolve(event)
|
|
151
|
+
.then(observer)
|
|
152
|
+
.catch(() => { });
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
return value;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
//# sourceMappingURL=managing.js.map
|