@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.
- 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 +25 -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 +179 -7
- package/dist/generic/localCacheManager.d.ts.map +1 -1
- package/dist/generic/localCacheManager.js +409 -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 +3 -3
|
@@ -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: `
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
882
|
-
//
|
|
883
|
-
//
|
|
884
|
-
//
|
|
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
|
|
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,
|
|
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
|
-
//
|
|
1291
|
-
//
|
|
1292
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1495
|
-
//
|
|
1496
|
-
|
|
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
|
-
|
|
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
|
}
|