@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.
- package/dist/generic/RegisterForStartup.d.ts +78 -1
- package/dist/generic/RegisterForStartup.d.ts.map +1 -1
- package/dist/generic/RegisterForStartup.js +108 -7
- package/dist/generic/RegisterForStartup.js.map +1 -1
- package/dist/generic/baseEntity.d.ts +5 -0
- package/dist/generic/baseEntity.d.ts.map +1 -1
- package/dist/generic/baseEntity.js +76 -4
- package/dist/generic/baseEntity.js.map +1 -1
- package/dist/generic/compositeKey.d.ts.map +1 -1
- package/dist/generic/compositeKey.js +8 -1
- package/dist/generic/compositeKey.js.map +1 -1
- package/dist/generic/localCacheManager.d.ts +199 -7
- package/dist/generic/localCacheManager.d.ts.map +1 -1
- package/dist/generic/localCacheManager.js +439 -26
- package/dist/generic/localCacheManager.js.map +1 -1
- package/dist/generic/permissionInterfaces.d.ts +11 -0
- package/dist/generic/permissionInterfaces.d.ts.map +1 -1
- package/dist/generic/permissionInterfaces.js +13 -2
- package/dist/generic/permissionInterfaces.js.map +1 -1
- package/dist/generic/providerBase.d.ts +87 -4
- package/dist/generic/providerBase.d.ts.map +1 -1
- package/dist/generic/providerBase.js +312 -52
- package/dist/generic/providerBase.js.map +1 -1
- package/dist/generic/util.d.ts.map +1 -1
- package/dist/generic/util.js +6 -0
- package/dist/generic/util.js.map +1 -1
- package/dist/views/runView.d.ts +1 -1
- package/dist/views/runView.d.ts.map +1 -1
- package/dist/views/runView.js +18 -3
- package/dist/views/runView.js.map +1 -1
- package/package.json +4 -4
|
@@ -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: `
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
882
|
-
//
|
|
883
|
-
//
|
|
884
|
-
//
|
|
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
|
|
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,
|
|
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
|
-
//
|
|
1291
|
-
//
|
|
1292
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1495
|
-
//
|
|
1496
|
-
|
|
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
|
-
|
|
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
|
*/
|