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