@memberjunction/core 5.48.0 → 5.50.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.
@@ -16,6 +16,7 @@ const DEFAULT_CONFIG = {
16
16
  defaultTTLMs: 0, // No TTL — event-based invalidation is the primary mechanism
17
17
  evictionPolicy: 'lru',
18
18
  maxPercentOfCachePerEntity: 50,
19
+ maxEntryPercentOfCache: 25,
19
20
  evictionSweepIntervalMs: 300000, // 5 minutes
20
21
  verboseLogging: false,
21
22
  };
@@ -224,7 +225,11 @@ export class LocalCacheManager extends BaseSingleton {
224
225
  // ========================================================================
225
226
  /**
226
227
  * Extracts the entity name from a RunView fingerprint.
227
- * Fingerprint format: `EntityName|Filter|OrderBy|ResultType|MaxRows|StartRow|AggHash[|Connection]`
228
+ * Fingerprint format: `Entity|Filter|OrderBy|MaxRows|StartRow|AggHash|UserSearch[|…]`
229
+ * (built in GenerateRunViewFingerprint below — that array is the ground truth). NOTE:
230
+ * `ResultType` is deliberately NOT a segment; the cache stores plain JSON regardless and
231
+ * transformation happens post-cache. An earlier version of this comment listed it, which
232
+ * would put any new segment-indexing predicate one position off — MaxRows is [3], not [4].
228
233
  * @param fingerprint - The RunView cache fingerprint
229
234
  * @returns The entity name, or null if the fingerprint is malformed
230
235
  */
@@ -240,7 +245,186 @@ export class LocalCacheManager extends BaseSingleton {
240
245
  */
241
246
  isFilteredFingerprint(fingerprint) {
242
247
  const parts = fingerprint.split('|');
243
- return parts.length >= 2 && parts[1] !== '_' && parts[1] !== '';
248
+ return (parts.length >= 2 && parts[1] !== '_' && parts[1] !== '')
249
+ || this.hasOrderBy(parts)
250
+ || this.hasUserSearch(parts)
251
+ || this.hasNarrowingSegment(parts)
252
+ || this.hasAggregates(parts);
253
+ }
254
+ /**
255
+ * Returns true if the slot was cached under an ORDER BY (fingerprint segment [2]).
256
+ *
257
+ * An ordered slot's row SET can be maintained in place, but its ORDER cannot: an upsert
258
+ * appends the new row at map-insertion end and leaves re-sorted rows at their old positions,
259
+ * so the slot silently stops honoring the order the caller asked for — wrong for any
260
+ * "first row of the ordered set" consumer. Re-sorting in JS would require reimplementing SQL
261
+ * ORDER BY semantics (collations, NULL ordering, expression sorts), which is exactly the kind
262
+ * of "derive it in JS" shortcut this file keeps having to walk back.
263
+ *
264
+ * DELETE remains maintainable: removing a row preserves the relative order of the rest. This
265
+ * mirrors the filtered-slot asymmetry, and the branch order in
266
+ * processEntityEventForFingerprint (delete is checked before the filtered classification)
267
+ * delivers it without extra wiring. `BaseEngine` already refuses ordered configs for
268
+ * in-place mutation (`canUseImmediateMutation`); this closes the same gap in the raw
269
+ * provider cache. (B42)
270
+ *
271
+ * @param parts - the fingerprint already split on '|'
272
+ */
273
+ hasOrderBy(parts) {
274
+ const ORDER_BY_INDEX = 2;
275
+ const orderBy = parts[ORDER_BY_INDEX];
276
+ return !!orderBy && orderBy !== '_';
277
+ }
278
+ /**
279
+ * Returns true if the slot was produced by a user search (fingerprint segment [6]).
280
+ *
281
+ * `UserSearchString` generates LIKE / full-text WHERE clauses, so it narrows rows exactly as
282
+ * `ExtraFilter` does — but it lives at index [6], INSIDE the 7-segment base, where neither the
283
+ * `parts[1]` filter check nor `hasNarrowingSegment` (which starts at index 7) was looking.
284
+ *
285
+ * Same bug class as H1/H3, different hiding place: a row-narrowing predicate invisible to the
286
+ * maintainability check. Demonstrated by upserting a non-matching row into a search slot —
287
+ * a search for "annual gala" subsequently served "Totally Unrelated Row". Explorer grid
288
+ * searches are the reachable surface. (N1)
289
+ *
290
+ * @param parts - the fingerprint already split on '|'
291
+ */
292
+ hasUserSearch(parts) {
293
+ const USER_SEARCH_INDEX = 6;
294
+ const search = parts[USER_SEARCH_INDEX];
295
+ return !!search && search !== '_';
296
+ }
297
+ /**
298
+ * Returns true if the slot carries aggregate results (fingerprint segment [5], `aggHash`).
299
+ *
300
+ * ## Why an aggregate slot must be INVALIDATED, not maintained (H2)
301
+ * The aggregate was computed by the DATABASE over the pre-mutation row set. After an in-place
302
+ * upsert/remove there is no way to recompute it in JS for the general case: `COUNT(*)` shifts
303
+ * by one, `SUM`/`AVG` need the mutated row's contribution to the specific expression, and
304
+ * `MAX`/`MIN` may or may not move depending on the value.
305
+ *
306
+ * The first attempt at this fix CARRIED the cached aggregate forward — which was worse than
307
+ * the bug it replaced. Verified live: after a save the slot reported `rows=7` alongside
308
+ * `COUNT(*) = 6`. A caller can detect a MISSING aggregate; it cannot detect a stale one, and
309
+ * the read path reports `Success: true` / `cacheStatus: 'hit'` either way. Silently wrong
310
+ * beats loudly absent only if you never look.
311
+ *
312
+ * So: same treatment as subset slots. The value is not derivable in JS, therefore the slot is
313
+ * dropped and the next read recomputes it against the database.
314
+ *
315
+ * @param parts - the fingerprint already split on '|'
316
+ */
317
+ hasAggregates(parts) {
318
+ const AGG_HASH_INDEX = 5;
319
+ const agg = parts[AGG_HASH_INDEX];
320
+ return !!agg && agg !== '_';
321
+ }
322
+ /**
323
+ * Returns true if the fingerprint carries any segment BEYOND the 7-part base that narrows the
324
+ * result set — i.e. the slot holds fewer rows than an unfiltered read of the same entity would.
325
+ *
326
+ * ## Why this exists (H1/H3)
327
+ * The base fingerprint is `Entity|Filter|OrderBy|MaxRows|StartRow|AggHash|UserSearch`, and the
328
+ * original filtered-check inspected ONLY `parts[1]`. But two later segments narrow the rows
329
+ * WITHOUT touching that segment:
330
+ *
331
+ * - `vw:<id>` — a saved view's `WhereClause` lives ON THE VIEW, not in `params.ExtraFilter`,
332
+ * so the filter segment stays `_`. The slot was therefore classified
333
+ * unfiltered and UPSERTED IN PLACE on save — serving rows the view's own
334
+ * WhereClause excludes. Views are how users are shown a restricted row set,
335
+ * so this reads as a data/permission leak, not merely stale data.
336
+ * - `rls:<h>` — the per-user Row-Level-Security predicate is appended AFTER the filter
337
+ * segment is built. Same misclassification, worse consequence: a save by
338
+ * user A was upserted into user B's RLS-scoped slot, injecting a row B's
339
+ * predicate excludes. That is an RLS bypass.
340
+ *
341
+ * ## Why it is written as a DENY-by-default allowlist
342
+ * Enumerating the narrowing segments would repeat the original mistake: the next segment
343
+ * someone appends is silently treated as maintainable until it causes a leak. So this
344
+ * enumerates only what is provably SAFE and treats everything else as narrowing:
345
+ *
346
+ * - `imr:1` — IgnoreMaxRows WIDENS the set (it removes a cap), so in-place maintenance
347
+ * remains valid.
348
+ * - connection — the `<driver>://host:port/` suffix is slot IDENTITY, not a predicate.
349
+ *
350
+ * Anything else — present or future — falls through to "narrowing", and the slot is
351
+ * conservatively invalidated on mutation rather than maintained. A new segment can therefore
352
+ * cost a cache refill, but it can never silently serve the wrong rows.
353
+ *
354
+ * @param parts - the fingerprint already split on '|'
355
+ */
356
+ hasNarrowingSegment(parts) {
357
+ const BASE_SEGMENTS = 7; // Entity|Filter|OrderBy|MaxRows|StartRow|AggHash|UserSearch
358
+ for (let i = BASE_SEGMENTS; i < parts.length; i++) {
359
+ const seg = parts[i];
360
+ if (!seg) {
361
+ continue;
362
+ }
363
+ if (seg.startsWith('imr:')) {
364
+ continue; // widens the set — safe to maintain
365
+ }
366
+ if (seg.includes('://')) {
367
+ continue; // connection identity — not a predicate
368
+ }
369
+ if (seg === 'f:*') {
370
+ // Full-width client projection. `ProviderBase.clientCacheFingerprint` appends an
371
+ // `f:<fields>` segment to EVERY client fingerprint, so omitting this classified
372
+ // 100% of client slots as narrowing — which disabled the client's entire
373
+ // differential-merge path (R1). `f:*` means "all fields", so it narrows neither
374
+ // rows nor columns and is genuinely safe to maintain.
375
+ //
376
+ // A NARROW `f:<a,b,c>` deliberately still falls through to narrowing: upserting a
377
+ // full row into a column-projected slot poisons its shape for the next reader.
378
+ continue;
379
+ }
380
+ return true; // unknown or known-narrowing segment → do not maintain
381
+ }
382
+ return false;
383
+ }
384
+ /**
385
+ * Returns true if the fingerprint identifies a **subset slot** — a cache entry whose rows are
386
+ * a TRUNCATION (`MaxRows`) or an OFFSET WINDOW (`StartRow`) of the matching set rather than
387
+ * the complete set.
388
+ *
389
+ * Subset slots are safe to STORE and SERVE (a cold read of the slot is exactly what the DB
390
+ * would have returned), but they must NEVER be maintained in place by the BaseEntity
391
+ * save/delete event path:
392
+ *
393
+ * - **Save/upsert** appends the saved row to the slot, so a `MaxRows: 1` slot grows to 2, 3,
394
+ * 4 … rows — silently violating the caller's own row limit and serving a set that is
395
+ * neither the first-N nor the full set (one arbitrary original row plus every locally
396
+ * saved row).
397
+ * - **Delete/remove** shrinks the slot below the limit, so a `MaxRows: 1` slot serves 0 rows
398
+ * while the DB still has 47 matching rows to choose a TOP 1 from.
399
+ *
400
+ * Neither can be repaired in JS: deciding whether a newly saved row belongs *inside* the
401
+ * window, and which row it would displace, requires re-running the query's TOP/OFFSET against
402
+ * the database. So we treat subset slots exactly as filtered slots are treated on save —
403
+ * conservatively INVALIDATE and let the next read repopulate from the DB.
404
+ *
405
+ * This is the row-level counterpart to the `totalRowCount` subset-slot handling: the total is
406
+ * maintained across the delta because the DB total is knowable; the ROWS are not, so the slot
407
+ * is dropped instead.
408
+ *
409
+ * Fingerprint format: `Entity|Filter|OrderBy|MaxRows|StartRow|AggHash|UserSearch[|…]`.
410
+ * Parsing is deliberately conservative — if the segments aren't cleanly numeric (e.g. a filter
411
+ * value containing a literal `|` shifts the positions), we return false and preserve existing
412
+ * behavior rather than over-invalidating. Such a fingerprint is filtered by definition, and
413
+ * filtered slots are already invalidated on save.
414
+ *
415
+ * @param fingerprint - The RunView cache fingerprint
416
+ */
417
+ isSubsetFingerprint(fingerprint) {
418
+ const parts = fingerprint.split('|');
419
+ if (parts.length < 5)
420
+ return false;
421
+ // MaxRows: -1 (or 0) means "no limit"; any positive value truncates the set.
422
+ const maxRows = Number(parts[3]);
423
+ if (Number.isFinite(maxRows) && maxRows > 0)
424
+ return true;
425
+ // StartRow: > 0 means the slot is an offset window, not the head of the set.
426
+ const startRow = Number(parts[4]);
427
+ return Number.isFinite(startRow) && startRow > 0;
244
428
  }
245
429
  /**
246
430
  * Checks whether a cached RunView entry is structurally stale due to a schema change
@@ -479,7 +663,24 @@ export class LocalCacheManager extends BaseSingleton {
479
663
  LogStatusVerbose(`LocalCacheManager: remote-invalidate (delete) for "${entityName}" PK=${key.ToConcatenatedString()}, removing from ${fingerprints.size} cached fingerprint(s)`);
480
664
  for (const fingerprint of fingerprintSnapshot) {
481
665
  try {
482
- await this.RemoveSingleEntity(fingerprint, key, nowISO);
666
+ // Subset slot (MaxRows/StartRow): removing a row would shrink it below the
667
+ // caller's own row limit while the DB still has rows to fill the window.
668
+ if (this.hasAggregates(fingerprint.split('|'))) {
669
+ // The remote DELETE branch checked only isSubsetFingerprint, so an
670
+ // aggregate slot took RemoveSingleEntity — whose storeCachedResults drops
671
+ // aggregates on the premise "this path never runs for one". False here.
672
+ // The slot survived with correct rows and NO aggregates, so later hits
673
+ // returned Success with nothing for a caller that requested COUNT(*).
674
+ // Same miss the LOCAL delete branch had; this is the second copy of the
675
+ // maintenance logic. (N2)
676
+ await this.InvalidateRunViewResult(fingerprint);
677
+ }
678
+ else if (this.isSubsetFingerprint(fingerprint)) {
679
+ await this.InvalidateRunViewResult(fingerprint);
680
+ }
681
+ else {
682
+ await this.RemoveSingleEntity(fingerprint, key, nowISO);
683
+ }
483
684
  }
484
685
  catch (err) {
485
686
  LogError(`HandleRemoteInvalidateEvent: failed to remove from "${fingerprint}": ${err.message}`);
@@ -498,7 +699,9 @@ export class LocalCacheManager extends BaseSingleton {
498
699
  LogStatusVerbose(`LocalCacheManager: remote-invalidate (save) for "${entityName}" PK=${key.ToConcatenatedString()}, updating ${fingerprints.size} cached fingerprint(s)`);
499
700
  for (const fingerprint of fingerprintSnapshot) {
500
701
  try {
501
- if (!this.isFilteredFingerprint(fingerprint)) {
702
+ // Subset slot (MaxRows/StartRow): upserting would grow the slot past the
703
+ // caller's own row limit. Invalidate, same as a filtered slot.
704
+ if (!this.isFilteredFingerprint(fingerprint) && !this.isSubsetFingerprint(fingerprint)) {
502
705
  await this.UpsertSingleEntity(fingerprint, recordData, key, nowISO);
503
706
  }
504
707
  else {
@@ -594,7 +797,23 @@ export class LocalCacheManager extends BaseSingleton {
594
797
  */
595
798
  async processEntityEventForFingerprint(eventType, fingerprint, baseEntity, key, nowISO) {
596
799
  const keyStr = key.ToConcatenatedString();
597
- if (eventType === 'delete') {
800
+ // Subset slots (MaxRows-truncated / StartRow-offset) cannot be maintained in place in
801
+ // EITHER direction — upserting grows them past the caller's own row limit and removing
802
+ // shrinks them below it. Drop the slot and let the next read repopulate it from the DB.
803
+ if (this.isSubsetFingerprint(fingerprint)) {
804
+ LogStatusVerbose(`LocalCacheManager: Invalidating subset (MaxRows/StartRow) cache "${fingerprint.substring(0, 60)}"`);
805
+ await this.InvalidateRunViewResult(fingerprint);
806
+ }
807
+ else if (this.hasAggregates(fingerprint.split('|'))) {
808
+ // Aggregates go stale on EITHER mutation, so this must precede the delete branch.
809
+ // Removal is safe for the ROWS of a filtered/view slot (a deleted row matches no
810
+ // predicate), which is why delete otherwise maintains in place — but a cached
811
+ // COUNT/SUM/MAX computed by the DB cannot be adjusted in JS, so the slot would serve
812
+ // rows=6 alongside COUNT(*)=7. Drop it and let the next read recompute (H2, delete half).
813
+ LogStatusVerbose(`LocalCacheManager: Invalidating aggregate-bearing cache "${fingerprint.substring(0, 60)}"`);
814
+ await this.InvalidateRunViewResult(fingerprint);
815
+ }
816
+ else if (eventType === 'delete') {
598
817
  LogStatusVerbose(`LocalCacheManager: Removing entity ${keyStr} from cache "${fingerprint.substring(0, 60)}"`);
599
818
  await this.RemoveSingleEntity(fingerprint, key, nowISO);
600
819
  }
@@ -842,7 +1061,11 @@ export class LocalCacheManager extends BaseSingleton {
842
1061
  * Generates a human-readable cache fingerprint for a RunView request.
843
1062
  * This fingerprint uniquely identifies the query based on its parameters and connection.
844
1063
  *
845
- * Format: EntityName|filter|orderBy|resultType|maxRows|startRow|aggHash|connection
1064
+ * Format: Entity|Filter|OrderBy|MaxRows|StartRow|AggHash|UserSearch[|appended…][|connection]
1065
+ * (the parts array below is the ground truth). NOTE: resultType is NOT a segment — an older
1066
+ * version of this comment listed it, which put every index after [2] off by one; that exact
1067
+ * off-by-one trap has already bitten a segment-indexing predicate once (see the note on
1068
+ * extractEntityFromFingerprint).
846
1069
  * Example: Users|Active=1|Name ASC|simple|100|0|a1b2c3d4|localhost
847
1070
  *
848
1071
  * @param params - The RunView parameters
@@ -878,10 +1101,13 @@ export class LocalCacheManager extends BaseSingleton {
878
1101
  // UserSearchString affects which rows are returned (generates LIKE/FTS WHERE clauses)
879
1102
  // and MUST be part of the fingerprint to prevent cross-query cache poisoning.
880
1103
  const userSearch = (params.UserSearchString ?? '').trim();
881
- // NOTE: ViewID and ViewName are intentionally excluded from the fingerprint.
882
- // Views are just containers for entity + filter + orderBy. Two different views
883
- // that resolve to the same entity/filter/orderBy produce identical SQL and results,
884
- // so they should share the same cache entry.
1104
+ // NOTE: a stored view's identity IS part of the fingerprint (appended below as `vw:`).
1105
+ // The prior assumption — "views are just containers for entity + filter + orderBy" is
1106
+ // false: a saved view carries its own server-side WhereClause that is NOT reflected in
1107
+ // params.ExtraFilter (it's applied later, in InternalRunView). Without the view segment a
1108
+ // filtered view and a plain unfiltered read of the same entity produce identical
1109
+ // fingerprints and cross-serve — the view is handed the unfiltered slot and returns rows
1110
+ // outside its own WhereClause (a correctness/permission leak). See the `vw:` append below.
885
1111
  // Build human-readable fingerprint with pipe separators
886
1112
  // Format: Entity|Filter|OrderBy|MaxRows|StartRow|AggHash|UserSearch[|Connection]
887
1113
  const parts = [
@@ -893,6 +1119,15 @@ export class LocalCacheManager extends BaseSingleton {
893
1119
  aggHash, // Aggregate hash (or '_' for no aggregates)
894
1120
  userSearch || '_' // User search string (generates LIKE/FTS clauses)
895
1121
  ];
1122
+ // IgnoreMaxRows skips the entity-level UserViewMaxRows TOP cap, so a request with it
1123
+ // returns a DIFFERENT (larger) row set than the otherwise-identical default (capped)
1124
+ // query for the same entity — the two must never share a cache slot (else the capped
1125
+ // result gets served to an IgnoreMaxRows caller, or vice-versa). Appended only when
1126
+ // true, so the common case keeps producing the exact pre-existing fingerprint and no
1127
+ // existing cache entries are invalidated.
1128
+ if (params.IgnoreMaxRows === true) {
1129
+ parts.push('imr:1');
1130
+ }
896
1131
  // Keyset (AfterKey) seek cursor MUST be part of the fingerprint. Each keyset page
897
1132
  // sends a different AfterKey but otherwise-identical params; without this, sequential
898
1133
  // pages collide on the same fingerprint and the dedup/linger layer hands page N+1 the
@@ -912,6 +1147,17 @@ export class LocalCacheManager extends BaseSingleton {
912
1147
  if (rls.length > 0) {
913
1148
  parts.push(`rls:${this.simpleHash(rls)}`);
914
1149
  }
1150
+ // Stored-view identity. A saved view's WhereClause/OrderBy live on the view, not in
1151
+ // params.ExtraFilter, so a view run and a plain entity read (or a different view) can
1152
+ // otherwise collide on the same fingerprint and be cross-served the wrong rows. Keyed by
1153
+ // ViewID / ViewName / the passed ViewEntity's PK. Appended ONLY when a view identifier is
1154
+ // present, so plain entity+filter queries keep the exact pre-existing fingerprint (no cache
1155
+ // invalidation). Per-view rendering is deterministic; per-user row scoping is the separate
1156
+ // `rls:` segment above.
1157
+ const viewKey = (params.ViewID || params.ViewName || params.ViewEntity?.PrimaryKey?.ToConcatenatedString() || '').trim();
1158
+ if (viewKey.length > 0) {
1159
+ parts.push(`vw:${viewKey}`);
1160
+ }
915
1161
  // Only include connection if provided
916
1162
  if (connection) {
917
1163
  parts.push(connection);
@@ -935,6 +1181,46 @@ export class LocalCacheManager extends BaseSingleton {
935
1181
  .join(';');
936
1182
  return this.simpleHash(aggString);
937
1183
  }
1184
+ /**
1185
+ * Reorders cached AggregateResults to match the CALLER's requested Aggregates[] order.
1186
+ *
1187
+ * The aggregate fingerprint (see {@link generateAggregateHash}) is deliberately
1188
+ * order-insensitive — it sorts the aggregates — so two semantically-identical views
1189
+ * requested as [A,B] and [B,A] share a single cache slot (cache-efficient). But the
1190
+ * {@link RunViewResult.AggregateResults} contract is "in same order as input Aggregates
1191
+ * array" — PER caller. A slot warmed as [A,B] therefore hands a [B,A] caller its results
1192
+ * in the wrong order unless we remap on the way out. This does that remap.
1193
+ *
1194
+ * Matching is by (expression, effective alias) — the same identity that produced the
1195
+ * aggHash (a result's alias defaults to its expression when the request omitted one).
1196
+ * Fail-safe: returns the input unchanged when there are no aggregates to reorder, the
1197
+ * counts differ, or any aggregate can't be matched — so a remap is never able to drop or
1198
+ * fabricate a result.
1199
+ */
1200
+ ReorderAggregateResultsToRequest(cachedResults, requestedAggregates) {
1201
+ if (!cachedResults || cachedResults.length === 0 || !requestedAggregates || requestedAggregates.length === 0) {
1202
+ return cachedResults;
1203
+ }
1204
+ if (cachedResults.length !== requestedAggregates.length) {
1205
+ return cachedResults; // shape mismatch — don't risk a bad remap
1206
+ }
1207
+ const key = (expression, alias) => `${expression}${alias}`;
1208
+ const remaining = new Map();
1209
+ for (const r of cachedResults) {
1210
+ remaining.set(key(r.expression, r.alias), r);
1211
+ }
1212
+ const reordered = [];
1213
+ for (const agg of requestedAggregates) {
1214
+ const k = key(agg.expression, agg.alias || agg.expression);
1215
+ const match = remaining.get(k);
1216
+ if (!match) {
1217
+ return cachedResults; // can't confidently remap — leave as-is
1218
+ }
1219
+ remaining.delete(k);
1220
+ reordered.push(match);
1221
+ }
1222
+ return reordered;
1223
+ }
938
1224
  /**
939
1225
  * Simple hash function for creating short fingerprints from strings.
940
1226
  * Not cryptographic, just for deduplication/fingerprinting purposes.
@@ -1066,6 +1352,15 @@ export class LocalCacheManager extends BaseSingleton {
1066
1352
  // Estimate size by sampling rows (eviction accounting only); the actual stored
1067
1353
  // value is the native object — no full JSON.stringify on the hot path.
1068
1354
  const sizeBytes = this.estimateResultsSize(data.results);
1355
+ // Oversized-entry gate: an entry above maxEntryPercentOfCache of the budget is
1356
+ // never cached. Attempting to store it would trigger a full-cache eviction to
1357
+ // make room for an entry the very next store would evict again — strictly worse
1358
+ // than serving this one query uncached. Always logged (not verbose-gated): an
1359
+ // oversized result is a perf smell the operator should be able to see.
1360
+ if (this.exceedsMaxEntrySize(sizeBytes)) {
1361
+ 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
+ return;
1363
+ }
1069
1364
  // Per-entity memory limit: evict oldest entries for this entity if over budget
1070
1365
  const entityName = params.EntityName || 'Unknown';
1071
1366
  await this.enforcePerEntityMemoryLimit(entityName, sizeBytes);
@@ -1207,6 +1502,8 @@ export class LocalCacheManager extends BaseSingleton {
1207
1502
  maxUpdatedAt: parsed.maxUpdatedAt,
1208
1503
  rowCount: results.length,
1209
1504
  totalRowCount: parsed.totalRowCount,
1505
+ // Surfaced so in-place maintenance can carry it forward on rewrite (B38).
1506
+ schemaHash: parsed.schemaHash,
1210
1507
  };
1211
1508
  if (parsed.aggregateResults) {
1212
1509
  result.aggregateResults = parsed.aggregateResults;
@@ -1249,16 +1546,55 @@ export class LocalCacheManager extends BaseSingleton {
1249
1546
  * @param deletedRecordIDs - Record IDs (in CompositeKey concatenated string format) that have been deleted
1250
1547
  * @param primaryKeyFieldName - The name of the primary key field (or first PK field for composite keys)
1251
1548
  * @param newMaxUpdatedAt - The new maxUpdatedAt timestamp after applying the delta
1252
- * @param _serverRowCount - DEPRECATED: This parameter is ignored. rowCount is always derived from merged results.length.
1549
+ * @param serverRowCount - The database's authoritative total row count (fresh COUNT(*) over the
1550
+ * view) from the smart-cache check. Used as the merged entry's `totalRowCount` when it exceeds
1551
+ * the cached slice size — this keeps paginated / MaxRows-limited slots from undercounting the
1552
+ * true total. The visible `rowCount` is still derived from the merged results length.
1253
1553
  * @param aggregateResults - Optional fresh aggregate results (since aggregates can't be differentially computed)
1254
1554
  * @param provider - The IMetadataProvider that produced these results (for AllowCaching gating
1255
1555
  * in multi-provider scenarios). Falls back to global Metadata.Provider when omitted.
1256
1556
  * @returns The merged results after applying the differential update, or null if cache not found
1257
1557
  */
1258
- async ApplyDifferentialUpdate(fingerprint, params, updatedRows, deletedRecordIDs, primaryKeyFieldName, newMaxUpdatedAt, _serverRowCount, aggregateResults, provider) {
1558
+ async ApplyDifferentialUpdate(fingerprint, params, updatedRows, deletedRecordIDs, primaryKeyFieldName, newMaxUpdatedAt, serverRowCount, aggregateResults, provider) {
1259
1559
  if (!this._storageProvider || !this._config.enabled)
1260
1560
  return null;
1261
1561
  try {
1562
+ // Subset / narrowing slots are NOT differentially updatable (H5 / H4).
1563
+ //
1564
+ // H5 — the #3199 defect, third instance. Merging a delta into a MaxRows/StartRow slot
1565
+ // shrinks it below the caller's limit on deletes and cannot know window membership on
1566
+ // inserts, exactly as the BaseEntity-event path could not. #3199 fixed
1567
+ // processEntityEventForFingerprint and HandleRemoteInvalidateEvent; this third write
1568
+ // path was left unfixed and, critically, unpinned by any test.
1569
+ //
1570
+ // H4 — this path delegates its write to SetRunViewResult, which RECOMPUTES schemaHash
1571
+ // from the CURRENT entity. That stamps today's schema onto a merged array containing
1572
+ // rows fetched under the OLD schema — asserting they match a field list they may not,
1573
+ // and masking the very drift the guard exists to catch. B38's fix was to CARRY the
1574
+ // hash, never recompute it; refusing the merge here keeps that invariant intact
1575
+ // instead of duplicating the carry logic on a second path.
1576
+ //
1577
+ // Aggregate slots are refused for a third reason: this path's own contract is "if
1578
+ // aggregateResults are not provided, cached aggregates are cleared (they'd be stale)".
1579
+ // A revalidation that carries only row deltas therefore SILENTLY STRIPS the aggregates
1580
+ // from a slot the caller still expects them from — the caller asked for COUNT(*) and
1581
+ // gets Success with nothing. Invalidating instead forces a clean refetch that returns
1582
+ // them. (Diagnosed from client-cache C13.)
1583
+ //
1584
+ // Refusing the merge is safe: the caller falls back to a normal fetch, which
1585
+ // repopulates the slot correctly. A missed optimization, never wrong data.
1586
+ const fpParts = fingerprint.split('|');
1587
+ // hasNarrowingSegment is INCLUDED here again (B41 closed). It was removed under R1
1588
+ // because the caller THREW on a decline with no refetch path — one undecidable slot
1589
+ // failed the whole batch. The caller now performs a real full fetch on decline
1590
+ // (processSingleSmartCacheResult), so declining a vw:/rls:/narrow-f: slot costs one
1591
+ // plain query instead of correctness or availability. Note `f:*` is allowlisted in
1592
+ // hasNarrowingSegment itself, so ordinary full-width client slots still merge.
1593
+ if (this.isSubsetFingerprint(fingerprint) || this.hasAggregates(fpParts) || this.hasNarrowingSegment(fpParts)) {
1594
+ LogStatusVerbose(`LocalCacheManager.ApplyDifferentialUpdate: refusing to merge into a subset/narrowing slot "${fingerprint.substring(0, 60)}" — invalidating instead`);
1595
+ await this.InvalidateRunViewResult(fingerprint);
1596
+ return null;
1597
+ }
1262
1598
  // Get existing cached data
1263
1599
  const cached = await this.GetRunViewResult(fingerprint);
1264
1600
  if (!cached) {
@@ -1287,9 +1623,19 @@ export class LocalCacheManager extends BaseSingleton {
1287
1623
  }
1288
1624
  // Convert map back to array
1289
1625
  const mergedResults = Array.from(resultMap.values());
1290
- // For differential updates, the merged result count IS the new total
1291
- // (differential applies to full-dataset caches, not paginated ones)
1292
- const mergedTotalRowCount = mergedResults.length;
1626
+ // TotalRowCount must reflect the DATABASE total, not the size of the cached
1627
+ // slice. The server sends the authoritative fresh COUNT(*) over the view in
1628
+ // `serverRowCount` (via the smart-cache check). Collapsing the total to
1629
+ // `mergedResults.length` is only correct for a FULL-dataset cache slot, where the
1630
+ // cached rows ARE every matching row. For a paginated / MaxRows-limited slot the
1631
+ // cached rows are a SUBSET, so `mergedResults.length` silently UNDERCOUNTS the true
1632
+ // total — the exact defect behind the RunView TotalRowCount discrepancy where a
1633
+ // fresh `count_only` read reported a LARGER count than a cached paginated read of
1634
+ // the same entity. Take the max so the total is never below the rows we actually
1635
+ // hold and always honors the server's (larger) authoritative count when provided.
1636
+ const mergedTotalRowCount = serverRowCount != null && serverRowCount > mergedResults.length
1637
+ ? serverRowCount
1638
+ : mergedResults.length;
1293
1639
  // Store the updated cache with optional aggregate results
1294
1640
  // Note: If aggregateResults not provided, cached aggregates are cleared (they'd be stale)
1295
1641
  await this.SetRunViewResult(fingerprint, params, mergedResults, newMaxUpdatedAt, aggregateResults, mergedTotalRowCount, provider);
@@ -1371,7 +1717,7 @@ export class LocalCacheManager extends BaseSingleton {
1371
1717
  // Upsert the entity (add or replace)
1372
1718
  resultMap.set(keyStr, entityData);
1373
1719
  const updatedResults = Array.from(resultMap.values());
1374
- return await this.storeCachedResults(fingerprint, updatedResults, newMaxUpdatedAt);
1720
+ return await this.storeCachedResults(fingerprint, updatedResults, newMaxUpdatedAt, { totalRowCount: cached.totalRowCount, rowCount: cached.results.length, schemaHash: cached.schemaHash });
1375
1721
  }
1376
1722
  catch (e) {
1377
1723
  LogError(`LocalCacheManager.UpsertSingleEntity failed: ${e}`);
@@ -1413,7 +1759,7 @@ export class LocalCacheManager extends BaseSingleton {
1413
1759
  }
1414
1760
  resultMap.delete(keyStr);
1415
1761
  const updatedResults = Array.from(resultMap.values());
1416
- return await this.storeCachedResults(fingerprint, updatedResults, newMaxUpdatedAt);
1762
+ return await this.storeCachedResults(fingerprint, updatedResults, newMaxUpdatedAt, { totalRowCount: cached.totalRowCount, rowCount: cached.results.length, schemaHash: cached.schemaHash });
1417
1763
  }
1418
1764
  catch (e) {
1419
1765
  LogError(`LocalCacheManager.RemoveSingleEntity failed: ${e}`);
@@ -1424,12 +1770,42 @@ export class LocalCacheManager extends BaseSingleton {
1424
1770
  /**
1425
1771
  * Stores updated results array back to the cache and updates the registry.
1426
1772
  * Shared by UpsertSingleEntity and RemoveSingleEntity to avoid duplication.
1427
- */
1428
- async storeCachedResults(fingerprint, updatedResults, newMaxUpdatedAt) {
1773
+ *
1774
+ * `prior` carries the pre-mutation total + row count so `totalRowCount` (the DATABASE
1775
+ * total) is MAINTAINED across the in-place add/remove rather than dropped. Dropping it
1776
+ * made reads fall back to `results.length`, which for a paginated / MaxRows-limited slot
1777
+ * is only a SUBSET of the rows — so after the first save/delete event the slot's total
1778
+ * collapsed to the cached slice size, undercounting the true total. That is the RunView
1779
+ * TotalRowCount discrepancy where a fresh `count_only` reported a larger count than a
1780
+ * cached paginated read. We adjust the prior total by the net row delta (add/remove) so a
1781
+ * full-dataset slot is unchanged (prior total == prior length) while a subset slot keeps a
1782
+ * correct total.
1783
+ */
1784
+ async storeCachedResults(fingerprint, updatedResults, newMaxUpdatedAt, prior) {
1429
1785
  const data = {
1430
1786
  results: updatedResults,
1431
1787
  maxUpdatedAt: newMaxUpdatedAt
1432
1788
  };
1789
+ // Carry the schemaHash FORWARD — never recompute it here (B38).
1790
+ //
1791
+ // Omitting it silently disabled schema-drift protection for the slot: rewriting a slot
1792
+ // without a hash makes `isSchemaStaleCacheEntry` short-circuit (`if (!data.schemaHash)
1793
+ // return false`), so a single save left that slot permanently unable to detect a
1794
+ // post-migration column change. Same class of omission as the totalRowCount loss fixed
1795
+ // in #3195, on this same write path.
1796
+ //
1797
+ // CARRY, don't RECOMPUTE: these rows were fetched under the OLD schema. Stamping the
1798
+ // CURRENT hash onto them would assert they match today's field list — actively masking
1799
+ // the very drift the guard exists to catch.
1800
+ if (prior?.schemaHash) {
1801
+ data.schemaHash = prior.schemaHash;
1802
+ }
1803
+ // Aggregates are deliberately NOT carried here — see hasAggregates(): an aggregate-bearing
1804
+ // slot is invalidated on mutation rather than maintained, so this path never runs for one.
1805
+ if (prior?.totalRowCount != null) {
1806
+ const delta = updatedResults.length - prior.rowCount;
1807
+ data.totalRowCount = Math.max(updatedResults.length, prior.totalRowCount + delta);
1808
+ }
1433
1809
  // Estimate size by sampling rows (eviction accounting only); the actual stored
1434
1810
  // value is the native object. This runs on every save/delete event per matching
1435
1811
  // unfiltered fingerprint, so avoiding a full serialization here matters most.
@@ -1486,14 +1862,20 @@ export class LocalCacheManager extends BaseSingleton {
1486
1862
  * @param connectionPrefix - Prefix identifying the connection (e.g., server URL) to differentiate caches across connections
1487
1863
  * @returns A unique, human-readable fingerprint string
1488
1864
  */
1489
- GenerateRunQueryFingerprint(queryId, queryName, parameters, connectionPrefix) {
1865
+ GenerateRunQueryFingerprint(queryId, queryName, parameters, connectionPrefix, categoryPath) {
1490
1866
  const name = queryName?.trim() || 'Unknown';
1491
1867
  const id = queryId || '_';
1492
1868
  const params = parameters ? JSON.stringify(parameters) : '_';
1493
1869
  const connection = connectionPrefix || '';
1494
- // Build human-readable fingerprint with pipe separators
1495
- // Format: QueryName|QueryID|Params|Connection
1496
- const parts = [name, id, params];
1870
+ // Full CategoryPath is a DISTINGUISHING element (B46). Two queries can share a Name in
1871
+ // different categories; without this a name-only request collides their cache slots and
1872
+ // serves one query's rows for the other. The RESOLVED canonical path is passed by the
1873
+ // caller (see resolveQueryCacheContext), so a request by ID, by name, or by name+category
1874
+ // that all resolve to the same query produce the same category segment. Normalized to '_'
1875
+ // when absent/unresolvable, so uncategorized and runtime-created queries keep a stable key.
1876
+ const category = (categoryPath && categoryPath.trim()) ? categoryPath.trim().toLowerCase() : '_';
1877
+ // Format: QueryName|QueryID|Category|Params[|Connection]
1878
+ const parts = [name, id, category, params];
1497
1879
  // Only include connection if provided
1498
1880
  if (connection) {
1499
1881
  parts.push(connection);
@@ -1511,13 +1893,24 @@ export class LocalCacheManager extends BaseSingleton {
1511
1893
  * @param queryId - Optional query ID for reference
1512
1894
  * @param ttlMs - Optional TTL in milliseconds (for cache expiry tracking)
1513
1895
  */
1514
- async SetRunQueryResult(fingerprint, queryName, results, maxUpdatedAt, rowCount, queryId, ttlMs) {
1896
+ async SetRunQueryResult(fingerprint, queryName, results, maxUpdatedAt, rowCount, queryId, ttlMs, warmedForUserID) {
1515
1897
  if (!this._storageProvider || !this._config.enabled)
1516
1898
  return;
1517
1899
  const actualRowCount = rowCount ?? results.length;
1518
- const data = { results, maxUpdatedAt, rowCount: actualRowCount, queryId };
1900
+ // warmedForUserID records WHO ran the (fully authorized) miss that produced this slot.
1901
+ // The B43 permission gate uses it as the tie-breaker when the query is not resolvable
1902
+ // from cached metadata (runtime-created queries never are — the provider's Queries cache
1903
+ // does not refresh in-process): the warmer proved their permission by executing; anyone
1904
+ // ELSE falls through to an authorized execution rather than being served unchecked.
1905
+ const data = { results, maxUpdatedAt, rowCount: actualRowCount, queryId, warmedForUserID };
1519
1906
  // Estimate size by sampling rows (eviction accounting only).
1520
1907
  const sizeBytes = this.estimateResultsSize(results);
1908
+ // Oversized-entry gate — same rationale as SetRunViewResult: never wipe the
1909
+ // cache to make room for an entry that can't be retained within budget.
1910
+ if (this.exceedsMaxEntrySize(sizeBytes)) {
1911
+ 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
+ return;
1913
+ }
1521
1914
  // Check if we need to evict entries
1522
1915
  await this.evictIfNeeded(sizeBytes);
1523
1916
  const now = Date.now();
@@ -1571,7 +1964,13 @@ export class LocalCacheManager extends BaseSingleton {
1571
1964
  results: parsed.results,
1572
1965
  maxUpdatedAt: parsed.maxUpdatedAt,
1573
1966
  rowCount: parsed.rowCount ?? parsed.results?.length ?? 0,
1574
- queryId: parsed.queryId
1967
+ queryId: parsed.queryId,
1968
+ // Pass-through, not optional garnish: the B43 gate serves an unresolvable-
1969
+ // metadata slot ONLY to its warmer. Rebuilding this object without the field
1970
+ // (the B38 omission pattern, which this session exists to stamp out — and
1971
+ // which the first version of this very fix repeated) silently disabled that
1972
+ // tie-break and with it TTL caching for runtime-created queries.
1973
+ warmedForUserID: parsed.warmedForUserID
1575
1974
  };
1576
1975
  }
1577
1976
  }
@@ -1908,6 +2307,20 @@ export class LocalCacheManager extends BaseSingleton {
1908
2307
  const avgLen = total / sampleCount;
1909
2308
  return Math.ceil(avgLen * rowCount) * 2;
1910
2309
  }
2310
+ /**
2311
+ * Returns true when a single entry of the given estimated size exceeds the
2312
+ * per-entry cap (maxEntryPercentOfCache of maxSizeBytes) and must not be
2313
+ * cached. See the config property's doc comment for the full rationale —
2314
+ * in short, an entry that large can only be stored by evicting most (or
2315
+ * all) of the cache, and it would be evicted again on the next store, so
2316
+ * caching it is strictly worse than skipping it.
2317
+ */
2318
+ exceedsMaxEntrySize(sizeBytes) {
2319
+ const pct = this._config.maxEntryPercentOfCache;
2320
+ if (pct <= 0)
2321
+ return false;
2322
+ return sizeBytes > Math.floor(this._config.maxSizeBytes * pct / 100);
2323
+ }
1911
2324
  /**
1912
2325
  * Evicts entries if needed to make room for new data.
1913
2326
  */