@memberjunction/core 6.1.0-edge.1 → 6.1.0-edge.3
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/LICENSE +180 -4
- package/dist/generic/InMemoryLocalStorageProvider.d.ts +6 -0
- package/dist/generic/InMemoryLocalStorageProvider.d.ts.map +1 -1
- package/dist/generic/InMemoryLocalStorageProvider.js +6 -0
- package/dist/generic/InMemoryLocalStorageProvider.js.map +1 -1
- package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts +120 -0
- package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts.map +1 -0
- package/dist/generic/JSONType-interfaces/IEntityConfiguration.js +6 -0
- package/dist/generic/JSONType-interfaces/IEntityConfiguration.js.map +1 -0
- package/dist/generic/JSONType-interfaces/IEntityFieldConfiguration.d.ts +31 -0
- package/dist/generic/JSONType-interfaces/IEntityFieldConfiguration.d.ts.map +1 -0
- package/dist/generic/JSONType-interfaces/IEntityFieldConfiguration.js +6 -0
- package/dist/generic/JSONType-interfaces/IEntityFieldConfiguration.js.map +1 -0
- package/dist/generic/JSONType-interfaces/IEntityRelationshipConfiguration.d.ts +75 -0
- package/dist/generic/JSONType-interfaces/IEntityRelationshipConfiguration.d.ts.map +1 -0
- package/dist/generic/JSONType-interfaces/IEntityRelationshipConfiguration.js +6 -0
- package/dist/generic/JSONType-interfaces/IEntityRelationshipConfiguration.js.map +1 -0
- package/dist/generic/JSONType-interfaces/IFileStorageProviderConfiguration.d.ts +19 -0
- package/dist/generic/JSONType-interfaces/IFileStorageProviderConfiguration.d.ts.map +1 -0
- package/dist/generic/JSONType-interfaces/IFileStorageProviderConfiguration.js +6 -0
- package/dist/generic/JSONType-interfaces/IFileStorageProviderConfiguration.js.map +1 -0
- package/dist/generic/authTypes.d.ts +69 -0
- package/dist/generic/authTypes.d.ts.map +1 -1
- package/dist/generic/baseEngine.d.ts +14 -0
- package/dist/generic/baseEngine.d.ts.map +1 -1
- package/dist/generic/baseEngine.js +53 -13
- package/dist/generic/baseEngine.js.map +1 -1
- package/dist/generic/baseEntity.d.ts +150 -23
- package/dist/generic/baseEntity.d.ts.map +1 -1
- package/dist/generic/baseEntity.js +435 -66
- package/dist/generic/baseEntity.js.map +1 -1
- package/dist/generic/baseInfo.d.ts.map +1 -1
- package/dist/generic/baseInfo.js +16 -4
- package/dist/generic/baseInfo.js.map +1 -1
- package/dist/generic/compositeKey.d.ts +30 -0
- package/dist/generic/compositeKey.d.ts.map +1 -1
- package/dist/generic/compositeKey.js +97 -0
- package/dist/generic/compositeKey.js.map +1 -1
- package/dist/generic/dataHooks.d.ts +5 -0
- package/dist/generic/dataHooks.d.ts.map +1 -1
- package/dist/generic/dataHooks.js +27 -3
- package/dist/generic/dataHooks.js.map +1 -1
- package/dist/generic/databaseProviderBase.d.ts.map +1 -1
- package/dist/generic/databaseProviderBase.js +9 -5
- package/dist/generic/databaseProviderBase.js.map +1 -1
- package/dist/generic/embeddedRecord.d.ts +123 -0
- package/dist/generic/embeddedRecord.d.ts.map +1 -0
- package/dist/generic/embeddedRecord.js +356 -0
- package/dist/generic/embeddedRecord.js.map +1 -0
- package/dist/generic/entityCompanion.d.ts +20 -2
- package/dist/generic/entityCompanion.d.ts.map +1 -1
- package/dist/generic/entityCompanion.js +17 -1
- package/dist/generic/entityCompanion.js.map +1 -1
- package/dist/generic/entityConfiguration.d.ts +131 -0
- package/dist/generic/entityConfiguration.d.ts.map +1 -0
- package/dist/generic/entityConfiguration.js +346 -0
- package/dist/generic/entityConfiguration.js.map +1 -0
- package/dist/generic/entityInfo.d.ts +274 -4
- package/dist/generic/entityInfo.d.ts.map +1 -1
- package/dist/generic/entityInfo.js +502 -14
- package/dist/generic/entityInfo.js.map +1 -1
- package/dist/generic/entitySavePlan.d.ts +40 -11
- package/dist/generic/entitySavePlan.d.ts.map +1 -1
- package/dist/generic/entitySavePlan.js +70 -2
- package/dist/generic/entitySavePlan.js.map +1 -1
- package/dist/generic/interfaces.d.ts +35 -24
- package/dist/generic/interfaces.d.ts.map +1 -1
- package/dist/generic/interfaces.js +6 -24
- package/dist/generic/interfaces.js.map +1 -1
- package/dist/generic/localCacheManager.d.ts +123 -5
- package/dist/generic/localCacheManager.d.ts.map +1 -1
- package/dist/generic/localCacheManager.js +264 -9
- package/dist/generic/localCacheManager.js.map +1 -1
- package/dist/generic/providerBase.d.ts +118 -0
- package/dist/generic/providerBase.d.ts.map +1 -1
- package/dist/generic/providerBase.js +311 -11
- package/dist/generic/providerBase.js.map +1 -1
- package/dist/generic/relatedRecordCollection.d.ts +36 -4
- package/dist/generic/relatedRecordCollection.d.ts.map +1 -1
- package/dist/generic/relatedRecordCollection.js +70 -8
- package/dist/generic/relatedRecordCollection.js.map +1 -1
- package/dist/generic/runQuery.d.ts +14 -0
- package/dist/generic/runQuery.d.ts.map +1 -1
- package/dist/generic/runQuery.js.map +1 -1
- package/dist/generic/saveEntityGraphOperation.js +1 -1
- package/dist/generic/saveEntityGraphOperation.js.map +1 -1
- package/dist/generic/securityInfo.d.ts.map +1 -1
- package/dist/generic/securityInfo.js +15 -5
- package/dist/generic/securityInfo.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/views/runView.d.ts +58 -0
- package/dist/views/runView.d.ts.map +1 -1
- package/dist/views/runView.js +20 -0
- package/dist/views/runView.js.map +1 -1
- package/package.json +5 -4
- package/readme.md +64 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { BaseSingleton, MJGlobal, MJEventType } from "@memberjunction/global";
|
|
2
|
+
import { IsMaterializedDataSource } from "../views/runView.js";
|
|
2
3
|
import { LogError, LogStatusEx } from "./logging.js";
|
|
3
4
|
import { BaseEntity } from "./baseEntity.js";
|
|
4
5
|
import { Metadata } from "./metadata.js";
|
|
@@ -7,6 +8,50 @@ import { CompositeKey, KeyValuePair } from "./compositeKey.js";
|
|
|
7
8
|
function LogStatusVerbose(message) {
|
|
8
9
|
LogStatusEx({ message, verboseOnly: true });
|
|
9
10
|
}
|
|
11
|
+
/**
|
|
12
|
+
* Recursively freezes a value that is about to enter the cache, so consumers holding the
|
|
13
|
+
* same reference cannot mutate shared state.
|
|
14
|
+
*
|
|
15
|
+
* Only invoked when the storage provider reports
|
|
16
|
+
* {@link ILocalStorageProvider.SharesReferences} — a serializing backend (IndexedDB,
|
|
17
|
+
* localStorage, Redis, MMKV) already isolates stored data, and freezing there would
|
|
18
|
+
* needlessly immobilize the caller's own rows.
|
|
19
|
+
*
|
|
20
|
+
* Freezing the ARRAY matters as much as the rows: `results.sort()` / `.push()` on a live
|
|
21
|
+
* cache array silently reorders or grows the cached slot for every later reader.
|
|
22
|
+
*
|
|
23
|
+
* Freezes BEFORE recursing, so the `isFrozen` short-circuit both skips already-frozen
|
|
24
|
+
* subtrees (cheap re-entry for in-place slot maintenance, which carries existing rows
|
|
25
|
+
* forward by reference) and terminates cycles.
|
|
26
|
+
*
|
|
27
|
+
* Two value kinds are skipped, not frozen — accepted residuals:
|
|
28
|
+
* - Binary payloads (`Buffer`/TypedArray/`ArrayBuffer`, e.g. `varbinary` columns): the spec
|
|
29
|
+
* makes `Object.freeze` THROW on a non-empty view, so attempting it would turn a cache
|
|
30
|
+
* write into a crash.
|
|
31
|
+
* - `Date` internal slots: `Object.freeze` cannot protect them — `setHours` and friends
|
|
32
|
+
* still work.
|
|
33
|
+
*/
|
|
34
|
+
function deepFreezeCacheValue(value, visited = new WeakSet()) {
|
|
35
|
+
if (value === null || typeof value !== 'object'
|
|
36
|
+
|| ArrayBuffer.isView(value) || value instanceof ArrayBuffer) {
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
// Cycle termination and re-entry short-circuit key off an explicit visited set rather than
|
|
40
|
+
// `Object.isFrozen`. Those are not the same test: an object frozen SHALLOWLY by someone else
|
|
41
|
+
// (a caller that ran `Object.freeze(row)` before handing it over) reports frozen while its
|
|
42
|
+
// nested values are still writable, so keying off isFrozen would skip the whole subtree and
|
|
43
|
+
// leave shared state mutable. Already-frozen objects are cheap to re-freeze; unvisited
|
|
44
|
+
// children are the thing that must not be skipped.
|
|
45
|
+
if (visited.has(value)) {
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
visited.add(value);
|
|
49
|
+
Object.freeze(value);
|
|
50
|
+
for (const nested of Object.values(value)) {
|
|
51
|
+
deepFreezeCacheValue(nested, visited);
|
|
52
|
+
}
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
10
55
|
// ============================================================================
|
|
11
56
|
// DEFAULT CONFIGURATION
|
|
12
57
|
// ============================================================================
|
|
@@ -76,6 +121,12 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
76
121
|
constructor() {
|
|
77
122
|
super();
|
|
78
123
|
this._storageProvider = null;
|
|
124
|
+
/**
|
|
125
|
+
* Whether the active storage provider hands back live object references, resolved once at
|
|
126
|
+
* initialization — from the provider's declared {@link ILocalStorageProvider.SharesReferences}
|
|
127
|
+
* when it states one, otherwise measured empirically. Gates the defensive deep-freeze.
|
|
128
|
+
*/
|
|
129
|
+
this._sharesReferences = false;
|
|
79
130
|
this._registry = new Map();
|
|
80
131
|
this._initialized = false;
|
|
81
132
|
this._initializePromise = null;
|
|
@@ -138,14 +189,64 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
138
189
|
this._initializePromise = this.doInitialize(storageProvider, config);
|
|
139
190
|
return this._initializePromise;
|
|
140
191
|
}
|
|
192
|
+
/**
|
|
193
|
+
* Decides whether the storage provider hands back live object references — the condition
|
|
194
|
+
* that makes the defensive deep-freeze necessary.
|
|
195
|
+
*
|
|
196
|
+
* Prefers the provider's declared {@link ILocalStorageProvider.SharesReferences}. When a
|
|
197
|
+
* provider does not state one (any implementation written before that property existed),
|
|
198
|
+
* MEASURE it rather than guessing: store a sentinel object, read it back, and compare
|
|
199
|
+
* identity. A reference-sharing store returns the very same object; anything with a
|
|
200
|
+
* serialization or structured-clone boundary returns a copy. This is what keeps the
|
|
201
|
+
* property optional — an external provider that never declares it still gets the correct
|
|
202
|
+
* protection instead of silently losing it to a falsy default.
|
|
203
|
+
*
|
|
204
|
+
* Fails closed to `false` if the probe cannot complete (a provider whose backing store is
|
|
205
|
+
* not ready at init): that matches the pre-freeze behavior rather than immobilizing rows
|
|
206
|
+
* for a provider we could not classify.
|
|
207
|
+
*/
|
|
208
|
+
async resolveSharesReferences(storageProvider) {
|
|
209
|
+
if (typeof storageProvider.SharesReferences === 'boolean') {
|
|
210
|
+
return storageProvider.SharesReferences;
|
|
211
|
+
}
|
|
212
|
+
const probeKey = '__mj_sharesreferences_probe__';
|
|
213
|
+
const sentinel = { probe: true };
|
|
214
|
+
try {
|
|
215
|
+
await storageProvider.SetItem(probeKey, sentinel, CacheCategory.Default);
|
|
216
|
+
const readBack = await storageProvider.GetItem(probeKey, CacheCategory.Default);
|
|
217
|
+
const shares = readBack === sentinel;
|
|
218
|
+
LogStatusVerbose(`[CACHE-INIT] Storage provider "${storageProvider.constructor?.name ?? 'unknown'}" did not declare ` +
|
|
219
|
+
`SharesReferences; probed it as ${shares} (freeze-on-write ${shares ? 'ENABLED' : 'disabled'}).`);
|
|
220
|
+
return shares;
|
|
221
|
+
}
|
|
222
|
+
catch (e) {
|
|
223
|
+
LogError(`LocalCacheManager: could not probe SharesReferences on the storage provider; ` +
|
|
224
|
+
`assuming it isolates (freeze-on-write disabled). Declare SharesReferences to be explicit. ${e}`);
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
finally {
|
|
228
|
+
try {
|
|
229
|
+
await storageProvider.Remove(probeKey, CacheCategory.Default);
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
/* best-effort cleanup — a leftover probe key is harmless */
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
141
236
|
/**
|
|
142
237
|
* Internal initialization logic - only called once by the first caller
|
|
143
238
|
*/
|
|
144
239
|
async doInitialize(storageProvider, config) {
|
|
145
|
-
this._storageProvider = storageProvider;
|
|
146
240
|
if (config) {
|
|
147
241
|
this._config = { ...this._config, ...config };
|
|
148
242
|
}
|
|
243
|
+
// Resolve the freeze decision BEFORE publishing the provider. The probe awaits I/O, and
|
|
244
|
+
// `_storageProvider` is what every write path reads to decide whether to freeze — so
|
|
245
|
+
// assigning it first opens a window where writes see the new provider paired with the
|
|
246
|
+
// PREVIOUS provider's (or the default `false`) freeze decision. Same ordering bug that
|
|
247
|
+
// `SetStorageProvider` had; fixed here too rather than left as the one asymmetric path.
|
|
248
|
+
this._sharesReferences = await this.resolveSharesReferences(storageProvider);
|
|
249
|
+
this._storageProvider = storageProvider;
|
|
149
250
|
await this.loadRegistry();
|
|
150
251
|
this._initialized = true;
|
|
151
252
|
// Start periodic eviction sweep for TTL-expired entries
|
|
@@ -193,12 +294,24 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
193
294
|
* @param newProvider - The new storage provider to use
|
|
194
295
|
*/
|
|
195
296
|
async SetStorageProvider(newProvider) {
|
|
297
|
+
// The freeze decision belongs to the ACTIVE provider, not to whichever one happened to be
|
|
298
|
+
// installed at Initialize. MJAPI initializes on the in-memory provider during engine
|
|
299
|
+
// loading and swaps to Redis afterward, so these two have OPPOSITE reference semantics on
|
|
300
|
+
// every Redis deployment — carrying the old answer forward means freezing rows Redis has
|
|
301
|
+
// already isolated (all of the hazard, none of the protection), or, on the reverse swap,
|
|
302
|
+
// silently dropping the protection.
|
|
303
|
+
//
|
|
304
|
+
// Resolved BEFORE `_storageProvider` is published in both branches: the probe awaits I/O,
|
|
305
|
+
// and write paths read `_storageProvider` to decide whether to freeze, so publishing first
|
|
306
|
+
// would pair the new provider with the old provider's decision for the duration of the probe.
|
|
196
307
|
if (!this._initialized) {
|
|
197
308
|
// Not yet initialized — just set the provider and return
|
|
309
|
+
this._sharesReferences = await this.resolveSharesReferences(newProvider);
|
|
198
310
|
this._storageProvider = newProvider;
|
|
199
311
|
return;
|
|
200
312
|
}
|
|
201
313
|
const oldProvider = this._storageProvider;
|
|
314
|
+
this._sharesReferences = await this.resolveSharesReferences(newProvider);
|
|
202
315
|
this._storageProvider = newProvider;
|
|
203
316
|
// Migrate existing cached data from old provider to new provider
|
|
204
317
|
const entries = this.GetAllEntries();
|
|
@@ -932,7 +1045,8 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
932
1045
|
*
|
|
933
1046
|
* @param name - The dataset name
|
|
934
1047
|
* @param itemFilters - Optional filters applied to the dataset
|
|
935
|
-
* @param dataset - The dataset result to cache
|
|
1048
|
+
* @param dataset - The dataset result to cache. Deep-frozen on reference-sharing storage,
|
|
1049
|
+
* like every other cache write funnel — see below.
|
|
936
1050
|
* @param keyPrefix - Prefix for the cache key (typically includes connection info)
|
|
937
1051
|
*/
|
|
938
1052
|
async SetDataset(name, itemFilters, dataset, keyPrefix) {
|
|
@@ -942,6 +1056,17 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
942
1056
|
// Estimate size from a string representation (used only for cache eviction
|
|
943
1057
|
// accounting; the actual stored value is the native object).
|
|
944
1058
|
const sizeBytes = this.estimateSize(JSON.stringify(dataset));
|
|
1059
|
+
// Fourth write funnel, held to the same contract as SetRunViewResult /
|
|
1060
|
+
// SetRunQueryResult / storeCachedResults. `GetDataset` hands this object straight back
|
|
1061
|
+
// out, so on a reference-sharing provider every reader shares it — the same exposure the
|
|
1062
|
+
// other three close. It has no in-repo caller today (GetDatasetByName caches per ITEM via
|
|
1063
|
+
// SetRunViewResult), but it is exported public API, so an external caller would otherwise
|
|
1064
|
+
// get an unprotected slot with no indication that it differs from the documented rule.
|
|
1065
|
+
//
|
|
1066
|
+
// Frozen BEFORE the awaited eviction below, for the same reason as the other funnels: a
|
|
1067
|
+
// yield point between the decision to cache and the freeze is a window in which the
|
|
1068
|
+
// caller can still mutate what is about to become shared state.
|
|
1069
|
+
this.freezeRowDataIfProviderSharesReferences(dataset);
|
|
945
1070
|
// Check if we need to evict entries
|
|
946
1071
|
await this.evictIfNeeded(sizeBytes);
|
|
947
1072
|
try {
|
|
@@ -1078,7 +1203,7 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
1078
1203
|
* pre-RLS format so normal cache sharing is preserved and no existing entries are invalidated.
|
|
1079
1204
|
* @returns A unique, human-readable fingerprint string
|
|
1080
1205
|
*/
|
|
1081
|
-
GenerateRunViewFingerprint(params, connectionPrefix, rlsWhereClause) {
|
|
1206
|
+
GenerateRunViewFingerprint(params, connectionPrefix, rlsWhereClause, datasetSegment) {
|
|
1082
1207
|
const entity = params.EntityName?.trim() || 'Unknown';
|
|
1083
1208
|
const rawFilter = params.ExtraFilter;
|
|
1084
1209
|
const filter = (typeof rawFilter === 'string' ? rawFilter : rawFilter ? JSON.stringify(rawFilter) : '').trim();
|
|
@@ -1128,6 +1253,15 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
1128
1253
|
if (params.IgnoreMaxRows === true) {
|
|
1129
1254
|
parts.push('imr:1');
|
|
1130
1255
|
}
|
|
1256
|
+
// DataSource segment. RunViewParams.DataSource:'Materialized' routes the read to the entity's
|
|
1257
|
+
// materialized snapshot view (GetEffectiveBaseView), a DIFFERENT physical source than the default
|
|
1258
|
+
// live base view — so a Live read and a Materialized read of the same entity/filter/orderBy MUST
|
|
1259
|
+
// NOT share a cache slot (else one is silently served the other's source). Appended ONLY for the
|
|
1260
|
+
// non-default 'Materialized' so every existing (Live/default) fingerprint stays byte-for-byte
|
|
1261
|
+
// identical and no existing cache entries are invalidated.
|
|
1262
|
+
if (IsMaterializedDataSource(params.DataSource)) {
|
|
1263
|
+
parts.push('ds:materialized');
|
|
1264
|
+
}
|
|
1131
1265
|
// Keyset (AfterKey) seek cursor MUST be part of the fingerprint. Each keyset page
|
|
1132
1266
|
// sends a different AfterKey but otherwise-identical params; without this, sequential
|
|
1133
1267
|
// pages collide on the same fingerprint and the dedup/linger layer hands page N+1 the
|
|
@@ -1158,6 +1292,25 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
1158
1292
|
if (viewKey.length > 0) {
|
|
1159
1293
|
parts.push(`vw:${viewKey}`);
|
|
1160
1294
|
}
|
|
1295
|
+
// Dataset namespace. `GetDatasetByName` caches each dataset ITEM's rows through this same
|
|
1296
|
+
// builder, supplying only entity + the item's WhereClause — and every shipped item has a
|
|
1297
|
+
// NULL WhereClause, so without this segment a dataset item emits the identical fingerprint
|
|
1298
|
+
// to a plain unfiltered read of the same entity and the two silently share one slot. That
|
|
1299
|
+
// is not merely a stale-data risk: the MJ_Metadata dataset writes its rows with
|
|
1300
|
+
// `ProviderInternalScaffolding` (deliberately UNFROZEN, because bootstrap rearranges them
|
|
1301
|
+
// in place), so the shared slot hands ordinary callers unprotected rows for the hottest
|
|
1302
|
+
// entities in the process — and, in the other direction, an ordinary read repopulating an
|
|
1303
|
+
// evicted slot stores it FROZEN and the next metadata refresh throws.
|
|
1304
|
+
//
|
|
1305
|
+
// Dataset items may also project columns (`DatasetItem.Columns`), where a RunView slot is
|
|
1306
|
+
// always the full field set — so the two are not interchangeable in shape either.
|
|
1307
|
+
//
|
|
1308
|
+
// Appended ONLY when supplied, so ordinary reads keep their exact pre-existing key and no
|
|
1309
|
+
// existing cache entry is invalidated by this change.
|
|
1310
|
+
const dataset = (datasetSegment ?? '').trim();
|
|
1311
|
+
if (dataset.length > 0) {
|
|
1312
|
+
parts.push(`ds:${dataset}`);
|
|
1313
|
+
}
|
|
1161
1314
|
// Only include connection if provided
|
|
1162
1315
|
if (connection) {
|
|
1163
1316
|
parts.push(connection);
|
|
@@ -1260,6 +1413,64 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
1260
1413
|
return undefined;
|
|
1261
1414
|
}
|
|
1262
1415
|
}
|
|
1416
|
+
/**
|
|
1417
|
+
* Deep-freezes an about-to-be-stored cache payload when — and only when — the active
|
|
1418
|
+
* storage provider hands out live object references
|
|
1419
|
+
* ({@link ILocalStorageProvider.SharesReferences}).
|
|
1420
|
+
*
|
|
1421
|
+
* This is the cache's structural defense against consumer corruption. Under a
|
|
1422
|
+
* reference-sharing provider the rows a caller receives ARE the cached rows, in both
|
|
1423
|
+
* directions: on a hit the reader gets the stored array, and on a miss the cache stored
|
|
1424
|
+
* the caller's own array. Any in-place mutation therefore edits process-wide state. One
|
|
1425
|
+
* such mutation shipped as a P1 (a resolver renamed `__mj_CreatedAt` to its GraphQL
|
|
1426
|
+
* transport alias in place, so every later read served rows `BaseEntity.SetMany`
|
|
1427
|
+
* rejects). Freezing makes that failure immediate and attributable — a `TypeError` at
|
|
1428
|
+
* the offending line — instead of silent corruption, and costs nothing per cache hit.
|
|
1429
|
+
*
|
|
1430
|
+
* On serializing providers this is a no-op: their stored data is already isolated, and
|
|
1431
|
+
* freezing would only immobilize the caller's own rows for no safety gain.
|
|
1432
|
+
*
|
|
1433
|
+
* @param payload - The envelope being handed to the storage provider. Frozen in place;
|
|
1434
|
+
* the same reference is returned for call-site convenience.
|
|
1435
|
+
*/
|
|
1436
|
+
/**
|
|
1437
|
+
* Whether the CURRENTLY-INSTALLED provider hands back live references.
|
|
1438
|
+
*
|
|
1439
|
+
* Prefers the active provider's own declaration, read live on each call — a property read,
|
|
1440
|
+
* so it costs nothing on the write path, and it stays correct no matter how many times the
|
|
1441
|
+
* provider is swapped or which code path does the swapping. `_sharesReferences` (resolved at
|
|
1442
|
+
* `Initialize`/`SetStorageProvider`) is the fallback for providers that declare nothing, where
|
|
1443
|
+
* the answer can only come from the async probe and therefore cannot be recomputed here.
|
|
1444
|
+
*
|
|
1445
|
+
* Belt-and-braces with the re-resolution in {@link SetStorageProvider}: that keeps the probed
|
|
1446
|
+
* value correct across swaps, and this keeps DECLARED providers correct even if some future
|
|
1447
|
+
* swap site forgets to re-resolve.
|
|
1448
|
+
*/
|
|
1449
|
+
activeProviderSharesReferences() {
|
|
1450
|
+
const declared = this._storageProvider?.SharesReferences;
|
|
1451
|
+
return typeof declared === 'boolean' ? declared : this._sharesReferences;
|
|
1452
|
+
}
|
|
1453
|
+
freezeRowDataIfProviderSharesReferences(payload) {
|
|
1454
|
+
if (!this.activeProviderSharesReferences()) {
|
|
1455
|
+
return payload;
|
|
1456
|
+
}
|
|
1457
|
+
try {
|
|
1458
|
+
return deepFreezeCacheValue(payload);
|
|
1459
|
+
}
|
|
1460
|
+
catch (e) {
|
|
1461
|
+
// The freeze is protective, never load-bearing — an exotic value Object.freeze
|
|
1462
|
+
// rejects (beyond the guarded binary kinds) must degrade to a stored write rather
|
|
1463
|
+
// than taking down the read path it defends.
|
|
1464
|
+
//
|
|
1465
|
+
// The payload is PARTIALLY frozen at this point and cannot be un-frozen: the walk
|
|
1466
|
+
// freezes parent-first, so everything visited before the throw is already immutable.
|
|
1467
|
+
// Say that plainly instead of claiming "unfrozen" — an operator debugging a
|
|
1468
|
+
// downstream TypeError needs to know the entry is a mix, not a clean opt-out.
|
|
1469
|
+
LogError(`LocalCacheManager: freeze-on-write failed partway; storing the entry with ` +
|
|
1470
|
+
`whatever was frozen before the failure (partial protection, not none). ${e}`);
|
|
1471
|
+
return payload;
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1263
1474
|
/**
|
|
1264
1475
|
* Stores a RunView result in the cache.
|
|
1265
1476
|
*
|
|
@@ -1276,8 +1487,11 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
1276
1487
|
* AllowCaching gating in multi-provider scenarios (parallel client connections to multiple
|
|
1277
1488
|
* servers). Falls back to `Metadata.Provider` (global default) when omitted, which is fine
|
|
1278
1489
|
* for single-provider apps but wrong when AllowCaching differs across servers.
|
|
1490
|
+
* @param ttlMs - Optional time-based expiry (required for external-data-source entities).
|
|
1491
|
+
* @param options - See {@link CacheWriteOptions}. Pass `{ ProviderInternalScaffolding: true }`
|
|
1492
|
+
* only for slots the writing provider is the sole consumer of.
|
|
1279
1493
|
*/
|
|
1280
|
-
async SetRunViewResult(fingerprint, params, results, maxUpdatedAt, aggregateResults, totalRowCount, provider, ttlMs) {
|
|
1494
|
+
async SetRunViewResult(fingerprint, params, results, maxUpdatedAt, aggregateResults, totalRowCount, provider, ttlMs, options) {
|
|
1281
1495
|
if (!this._storageProvider || !this._config.enabled)
|
|
1282
1496
|
return;
|
|
1283
1497
|
// Keyset (AfterKey) queries are inherently single-use — each call uses a different
|
|
@@ -1336,6 +1550,10 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
1336
1550
|
}
|
|
1337
1551
|
// Persist results, maxUpdatedAt, aggregateResults, totalRowCount, and schemaHash
|
|
1338
1552
|
const data = { results, maxUpdatedAt };
|
|
1553
|
+
if (options?.ProviderInternalScaffolding) {
|
|
1554
|
+
// Persisted so event-driven in-place maintenance carries the exemption forward.
|
|
1555
|
+
data.providerInternalScaffolding = true;
|
|
1556
|
+
}
|
|
1339
1557
|
if (aggregateResults && aggregateResults.length > 0) {
|
|
1340
1558
|
data.aggregateResults = aggregateResults;
|
|
1341
1559
|
}
|
|
@@ -1361,10 +1579,23 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
1361
1579
|
LogStatusEx({ message: `[CACHE-WRITE-GATE] Skipping cache write for "${params.EntityName || fingerprint.substring(0, 60)}" — estimated entry size ${sizeBytes} bytes exceeds per-entry cap (${this._config.maxEntryPercentOfCache}% of ${this._config.maxSizeBytes} byte budget)` });
|
|
1362
1580
|
return;
|
|
1363
1581
|
}
|
|
1364
|
-
//
|
|
1582
|
+
// The oversized gate above is the ONLY step that can decline this write, and it is
|
|
1583
|
+
// synchronous — so by here the row set is definitely becoming shared state, and it is
|
|
1584
|
+
// frozen with no intervening yield point. That ordering is load-bearing: the eviction
|
|
1585
|
+
// steps below are awaited, and callers hold this very array while they run (the
|
|
1586
|
+
// smart-cache stale leg does not await this method at all). Freezing after them leaves
|
|
1587
|
+
// a window in which shared rows are handed out still mutable — and `BaseEntity`
|
|
1588
|
+
// samples `Object.isFrozen` once at load, so a freeze arriving mid-construction makes
|
|
1589
|
+
// a later field READ throw. Declining writes still leave the caller's rows mutable,
|
|
1590
|
+
// because the decline happens before this point.
|
|
1591
|
+
if (!options?.ProviderInternalScaffolding) {
|
|
1592
|
+
this.freezeRowDataIfProviderSharesReferences(data);
|
|
1593
|
+
}
|
|
1594
|
+
// Per-entity memory limit: evict oldest entries for this entity if over budget.
|
|
1595
|
+
// Evicts OTHER entries only — it cannot cancel this write.
|
|
1365
1596
|
const entityName = params.EntityName || 'Unknown';
|
|
1366
1597
|
await this.enforcePerEntityMemoryLimit(entityName, sizeBytes);
|
|
1367
|
-
// Check if we need to evict entries (global budget)
|
|
1598
|
+
// Check if we need to evict entries (global budget). Also eviction-only.
|
|
1368
1599
|
await this.evictIfNeeded(sizeBytes);
|
|
1369
1600
|
try {
|
|
1370
1601
|
// Native object storage — IDB structured-clones, localStorage / Redis serialize internally.
|
|
@@ -1504,6 +1735,8 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
1504
1735
|
totalRowCount: parsed.totalRowCount,
|
|
1505
1736
|
// Surfaced so in-place maintenance can carry it forward on rewrite (B38).
|
|
1506
1737
|
schemaHash: parsed.schemaHash,
|
|
1738
|
+
// Same carry-forward rationale: a maintained scaffolding slot must stay exempt.
|
|
1739
|
+
providerInternalScaffolding: parsed.providerInternalScaffolding,
|
|
1507
1740
|
};
|
|
1508
1741
|
if (parsed.aggregateResults) {
|
|
1509
1742
|
result.aggregateResults = parsed.aggregateResults;
|
|
@@ -1717,7 +1950,8 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
1717
1950
|
// Upsert the entity (add or replace)
|
|
1718
1951
|
resultMap.set(keyStr, entityData);
|
|
1719
1952
|
const updatedResults = Array.from(resultMap.values());
|
|
1720
|
-
return await this.storeCachedResults(fingerprint, updatedResults, newMaxUpdatedAt, { totalRowCount: cached.totalRowCount, rowCount: cached.results.length, schemaHash: cached.schemaHash
|
|
1953
|
+
return await this.storeCachedResults(fingerprint, updatedResults, newMaxUpdatedAt, { totalRowCount: cached.totalRowCount, rowCount: cached.results.length, schemaHash: cached.schemaHash,
|
|
1954
|
+
providerInternalScaffolding: cached.providerInternalScaffolding });
|
|
1721
1955
|
}
|
|
1722
1956
|
catch (e) {
|
|
1723
1957
|
LogError(`LocalCacheManager.UpsertSingleEntity failed: ${e}`);
|
|
@@ -1759,7 +1993,8 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
1759
1993
|
}
|
|
1760
1994
|
resultMap.delete(keyStr);
|
|
1761
1995
|
const updatedResults = Array.from(resultMap.values());
|
|
1762
|
-
return await this.storeCachedResults(fingerprint, updatedResults, newMaxUpdatedAt, { totalRowCount: cached.totalRowCount, rowCount: cached.results.length, schemaHash: cached.schemaHash
|
|
1996
|
+
return await this.storeCachedResults(fingerprint, updatedResults, newMaxUpdatedAt, { totalRowCount: cached.totalRowCount, rowCount: cached.results.length, schemaHash: cached.schemaHash,
|
|
1997
|
+
providerInternalScaffolding: cached.providerInternalScaffolding });
|
|
1763
1998
|
}
|
|
1764
1999
|
catch (e) {
|
|
1765
2000
|
LogError(`LocalCacheManager.RemoveSingleEntity failed: ${e}`);
|
|
@@ -1800,6 +2035,12 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
1800
2035
|
if (prior?.schemaHash) {
|
|
1801
2036
|
data.schemaHash = prior.schemaHash;
|
|
1802
2037
|
}
|
|
2038
|
+
// Carry the freeze exemption forward too — a scaffolding slot that gets maintained in
|
|
2039
|
+
// place must not silently become frozen, or the owner that mutates those rows breaks on
|
|
2040
|
+
// the next read (for the metadata dataset that means booting with no metadata).
|
|
2041
|
+
if (prior?.providerInternalScaffolding) {
|
|
2042
|
+
data.providerInternalScaffolding = true;
|
|
2043
|
+
}
|
|
1803
2044
|
// Aggregates are deliberately NOT carried here — see hasAggregates(): an aggregate-bearing
|
|
1804
2045
|
// slot is invalidated on mutation rather than maintained, so this path never runs for one.
|
|
1805
2046
|
if (prior?.totalRowCount != null) {
|
|
@@ -1810,6 +2051,13 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
1810
2051
|
// value is the native object. This runs on every save/delete event per matching
|
|
1811
2052
|
// unfiltered fingerprint, so avoiding a full serialization here matters most.
|
|
1812
2053
|
const sizeBytes = this.estimateResultsSize(updatedResults);
|
|
2054
|
+
// Second write funnel — this path bypasses SetRunViewResult entirely, so it must
|
|
2055
|
+
// freeze too. Rows carried forward from the prior slot are already frozen (the
|
|
2056
|
+
// isFrozen short-circuit makes re-entry cheap); what this catches is the NEW array
|
|
2057
|
+
// and the freshly upserted row. Scaffolding slots stay exempt (see above).
|
|
2058
|
+
if (!prior?.providerInternalScaffolding) {
|
|
2059
|
+
this.freezeRowDataIfProviderSharesReferences(data);
|
|
2060
|
+
}
|
|
1813
2061
|
await this._storageProvider.SetItem(fingerprint, data, CacheCategory.RunViewCache);
|
|
1814
2062
|
const existingEntry = this._registry.get(fingerprint);
|
|
1815
2063
|
if (existingEntry) {
|
|
@@ -1911,7 +2159,14 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
1911
2159
|
LogStatusEx({ message: `[CACHE-WRITE-GATE] Skipping cache write for query "${queryName}" — estimated entry size ${sizeBytes} bytes exceeds per-entry cap (${this._config.maxEntryPercentOfCache}% of ${this._config.maxSizeBytes} byte budget)` });
|
|
1912
2160
|
return;
|
|
1913
2161
|
}
|
|
1914
|
-
//
|
|
2162
|
+
// Same reference-sharing exposure as the RunView path (GetRunQueryResult hands
|
|
2163
|
+
// `results` straight back out) — closed here rather than waiting for a mutator to
|
|
2164
|
+
// find it. Placed immediately after the only declining gate and BEFORE the awaited
|
|
2165
|
+
// eviction below, for the same reason as SetRunViewResult: callers of this method do
|
|
2166
|
+
// not always await it, so any yield point before the freeze is a window in which the
|
|
2167
|
+
// caller can still mutate the rows the cache is about to store.
|
|
2168
|
+
this.freezeRowDataIfProviderSharesReferences(data);
|
|
2169
|
+
// Check if we need to evict entries. Evicts OTHER entries only — cannot cancel this write.
|
|
1915
2170
|
await this.evictIfNeeded(sizeBytes);
|
|
1916
2171
|
const now = Date.now();
|
|
1917
2172
|
const expiresAt = ttlMs ? now + ttlMs : undefined;
|