@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.
Files changed (99) hide show
  1. package/LICENSE +180 -4
  2. package/dist/generic/InMemoryLocalStorageProvider.d.ts +6 -0
  3. package/dist/generic/InMemoryLocalStorageProvider.d.ts.map +1 -1
  4. package/dist/generic/InMemoryLocalStorageProvider.js +6 -0
  5. package/dist/generic/InMemoryLocalStorageProvider.js.map +1 -1
  6. package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts +120 -0
  7. package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts.map +1 -0
  8. package/dist/generic/JSONType-interfaces/IEntityConfiguration.js +6 -0
  9. package/dist/generic/JSONType-interfaces/IEntityConfiguration.js.map +1 -0
  10. package/dist/generic/JSONType-interfaces/IEntityFieldConfiguration.d.ts +31 -0
  11. package/dist/generic/JSONType-interfaces/IEntityFieldConfiguration.d.ts.map +1 -0
  12. package/dist/generic/JSONType-interfaces/IEntityFieldConfiguration.js +6 -0
  13. package/dist/generic/JSONType-interfaces/IEntityFieldConfiguration.js.map +1 -0
  14. package/dist/generic/JSONType-interfaces/IEntityRelationshipConfiguration.d.ts +75 -0
  15. package/dist/generic/JSONType-interfaces/IEntityRelationshipConfiguration.d.ts.map +1 -0
  16. package/dist/generic/JSONType-interfaces/IEntityRelationshipConfiguration.js +6 -0
  17. package/dist/generic/JSONType-interfaces/IEntityRelationshipConfiguration.js.map +1 -0
  18. package/dist/generic/JSONType-interfaces/IFileStorageProviderConfiguration.d.ts +19 -0
  19. package/dist/generic/JSONType-interfaces/IFileStorageProviderConfiguration.d.ts.map +1 -0
  20. package/dist/generic/JSONType-interfaces/IFileStorageProviderConfiguration.js +6 -0
  21. package/dist/generic/JSONType-interfaces/IFileStorageProviderConfiguration.js.map +1 -0
  22. package/dist/generic/authTypes.d.ts +69 -0
  23. package/dist/generic/authTypes.d.ts.map +1 -1
  24. package/dist/generic/baseEngine.d.ts +14 -0
  25. package/dist/generic/baseEngine.d.ts.map +1 -1
  26. package/dist/generic/baseEngine.js +53 -13
  27. package/dist/generic/baseEngine.js.map +1 -1
  28. package/dist/generic/baseEntity.d.ts +150 -23
  29. package/dist/generic/baseEntity.d.ts.map +1 -1
  30. package/dist/generic/baseEntity.js +435 -66
  31. package/dist/generic/baseEntity.js.map +1 -1
  32. package/dist/generic/baseInfo.d.ts.map +1 -1
  33. package/dist/generic/baseInfo.js +16 -4
  34. package/dist/generic/baseInfo.js.map +1 -1
  35. package/dist/generic/compositeKey.d.ts +30 -0
  36. package/dist/generic/compositeKey.d.ts.map +1 -1
  37. package/dist/generic/compositeKey.js +97 -0
  38. package/dist/generic/compositeKey.js.map +1 -1
  39. package/dist/generic/dataHooks.d.ts +5 -0
  40. package/dist/generic/dataHooks.d.ts.map +1 -1
  41. package/dist/generic/dataHooks.js +27 -3
  42. package/dist/generic/dataHooks.js.map +1 -1
  43. package/dist/generic/databaseProviderBase.d.ts.map +1 -1
  44. package/dist/generic/databaseProviderBase.js +9 -5
  45. package/dist/generic/databaseProviderBase.js.map +1 -1
  46. package/dist/generic/embeddedRecord.d.ts +123 -0
  47. package/dist/generic/embeddedRecord.d.ts.map +1 -0
  48. package/dist/generic/embeddedRecord.js +356 -0
  49. package/dist/generic/embeddedRecord.js.map +1 -0
  50. package/dist/generic/entityCompanion.d.ts +20 -2
  51. package/dist/generic/entityCompanion.d.ts.map +1 -1
  52. package/dist/generic/entityCompanion.js +17 -1
  53. package/dist/generic/entityCompanion.js.map +1 -1
  54. package/dist/generic/entityConfiguration.d.ts +131 -0
  55. package/dist/generic/entityConfiguration.d.ts.map +1 -0
  56. package/dist/generic/entityConfiguration.js +346 -0
  57. package/dist/generic/entityConfiguration.js.map +1 -0
  58. package/dist/generic/entityInfo.d.ts +274 -4
  59. package/dist/generic/entityInfo.d.ts.map +1 -1
  60. package/dist/generic/entityInfo.js +502 -14
  61. package/dist/generic/entityInfo.js.map +1 -1
  62. package/dist/generic/entitySavePlan.d.ts +40 -11
  63. package/dist/generic/entitySavePlan.d.ts.map +1 -1
  64. package/dist/generic/entitySavePlan.js +70 -2
  65. package/dist/generic/entitySavePlan.js.map +1 -1
  66. package/dist/generic/interfaces.d.ts +35 -24
  67. package/dist/generic/interfaces.d.ts.map +1 -1
  68. package/dist/generic/interfaces.js +6 -24
  69. package/dist/generic/interfaces.js.map +1 -1
  70. package/dist/generic/localCacheManager.d.ts +123 -5
  71. package/dist/generic/localCacheManager.d.ts.map +1 -1
  72. package/dist/generic/localCacheManager.js +264 -9
  73. package/dist/generic/localCacheManager.js.map +1 -1
  74. package/dist/generic/providerBase.d.ts +118 -0
  75. package/dist/generic/providerBase.d.ts.map +1 -1
  76. package/dist/generic/providerBase.js +311 -11
  77. package/dist/generic/providerBase.js.map +1 -1
  78. package/dist/generic/relatedRecordCollection.d.ts +36 -4
  79. package/dist/generic/relatedRecordCollection.d.ts.map +1 -1
  80. package/dist/generic/relatedRecordCollection.js +70 -8
  81. package/dist/generic/relatedRecordCollection.js.map +1 -1
  82. package/dist/generic/runQuery.d.ts +14 -0
  83. package/dist/generic/runQuery.d.ts.map +1 -1
  84. package/dist/generic/runQuery.js.map +1 -1
  85. package/dist/generic/saveEntityGraphOperation.js +1 -1
  86. package/dist/generic/saveEntityGraphOperation.js.map +1 -1
  87. package/dist/generic/securityInfo.d.ts.map +1 -1
  88. package/dist/generic/securityInfo.js +15 -5
  89. package/dist/generic/securityInfo.js.map +1 -1
  90. package/dist/index.d.ts +3 -0
  91. package/dist/index.d.ts.map +1 -1
  92. package/dist/index.js +3 -0
  93. package/dist/index.js.map +1 -1
  94. package/dist/views/runView.d.ts +58 -0
  95. package/dist/views/runView.d.ts.map +1 -1
  96. package/dist/views/runView.js +20 -0
  97. package/dist/views/runView.js.map +1 -1
  98. package/package.json +5 -4
  99. 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
- // Per-entity memory limit: evict oldest entries for this entity if over budget
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
- // Check if we need to evict entries
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;