querysub 0.691.0 → 0.693.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "querysub",
3
- "version": "0.691.0",
3
+ "version": "0.693.0",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "note1": "note on node-forge fork, see https://github.com/digitalbazaar/forge/issues/744 for details",
@@ -13,7 +13,7 @@ import { ActionsHistory } from "../diagnostics/ActionsHistory";
13
13
  import { registerResource } from "../diagnostics/trackResources";
14
14
  import { ClientWatcher, WatchSpecData, clientWatcher } from "../1-path-client/pathValueClientWatcher";
15
15
  import { createPathValueProxy, getPathFromProxy, getProxyPath, getProxyPathAndWatch, isValueProxy, isValueProxy2 } from "./pathValueProxy";
16
- import { authorityStorage, compareTime, ReadLock, epochTime, getNextTime, MAX_ACCEPTED_CHANGE_AGE, PathValue, Time, getCreatorId, debugTime } from "../0-path-value-core/pathValueCore";
16
+ import { authorityStorage, compareTime, ReadLock, epochTime, getNextTime, MAX_ACCEPTED_CHANGE_AGE, PathValue, Time, getCreatorId, debugTime, MAX_CHANGE_AGE } from "../0-path-value-core/pathValueCore";
17
17
  import { runCodeWithDatabase, rawSchema } from "./pathDatabaseProxyBase";
18
18
  import debugbreak from "debugbreak";
19
19
  import { pathValueCommitter } from "../0-path-value-core/PathValueController";
@@ -69,6 +69,7 @@ export function runWithPathValueReadSource<T>(source: PathValueReadSource, code:
69
69
  }
70
70
 
71
71
  let nextSeqNum = 1;
72
+ let nextEvaluationId = 1;
72
73
  let nextOrderSeqNum = 1;
73
74
 
74
75
  export interface WatcherOptions<Result> {
@@ -328,6 +329,13 @@ export type SyncWatcher = {
328
329
  // Runs after any trigger happens (usually multiple triggers are required for a commit)
329
330
  onAfterTriggered: (() => void)[];
330
331
 
332
+ // A new id every run, so evaluation-scoped state (callLevelCache.ts) can detect it is stale.
333
+ evaluationId: number;
334
+ // Reset every time the function is run, like onInnerDisposed. Observe the evaluation's gets / getKeys / sets — they can't change them.
335
+ evaluationGetCallbacks: ((pathStr: string) => void)[];
336
+ evaluationGetKeysCallbacks: ((pathStr: string) => void)[];
337
+ evaluationSetCallbacks: ((pathStr: string) => void)[];
338
+
331
339
  // Not great, but... sometimes we just want to trigger the function
332
340
  explicitlyTrigger: (changes?: WatchSpecData) => void;
333
341
 
@@ -573,7 +581,8 @@ export class PathValueProxyWatcher {
573
581
  }
574
582
  };
575
583
 
576
- public getCallbackPathValue = (pathStr: string, syncParentKeys?: "parentKeys"): PathValue | undefined => {
584
+ // TEMPORARY measure, remove once the catalog import's slowness is found.
585
+ public getCallbackPathValue = measureWrap((pathStr: string, syncParentKeys?: "parentKeys"): PathValue | undefined => {
577
586
  const watcher = this.runningWatcher;
578
587
  if (!watcher) {
579
588
  debugger;
@@ -587,13 +596,13 @@ export class PathValueProxyWatcher {
587
596
  if (watcher.permissionsChecker) {
588
597
  if (!watcher.permissionsChecker.checkPermissions(pathStr).allowed) {
589
598
  if (
590
- !watcher.hasAnyUnsyncedAccesses()
591
- && currentReadSource.DEBUG_hasAnyValues(pathStr)
592
599
  // HACK: Don't show warnings for some framework paths, because they are filling up the console logs
593
600
  // and don't really matter. We could just not request them, but at depth 3 is valid,
594
601
  // so we kind of have to request that. And at depth 2, it would require special case code,
595
602
  // in a bunch of annoying places.
596
- && (getPathIndex(pathStr, 1) !== "PathFunctionRunner" || getPathDepth(pathStr) > 3)
603
+ (getPathIndex(pathStr, 1) !== "PathFunctionRunner" || getPathDepth(pathStr) > 3)
604
+ && currentReadSource.DEBUG_hasAnyValues(pathStr)
605
+ && !watcher.hasAnyUnsyncedAccesses()
597
606
  ) {
598
607
  console.warn(`Denied read access to path "${pathStr}"`);
599
608
  // console.warn(`${new Date().toLocaleTimeString()} Denied read access to path "${pathStr}"`);
@@ -662,8 +671,12 @@ export class PathValueProxyWatcher {
662
671
  }
663
672
 
664
673
  return pathValue;
665
- };
666
- public getCallback = (pathStr: string, syncParentKeys?: "parentKeys", readTransparent?: "readTransparent"): { value: unknown } | undefined => {
674
+ }, "PathValueProxyWatcher|getCallbackPathValue");
675
+ // TEMPORARY measure, remove once the catalog import's slowness is found.
676
+ public getCallback = measureWrap((pathStr: string, syncParentKeys?: "parentKeys", readTransparent?: "readTransparent"): { value: unknown } | undefined => {
677
+ if (this.runningWatcher && this.runningWatcher.evaluationGetCallbacks.length > 0) {
678
+ for (let callback of this.runningWatcher.evaluationGetCallbacks) callback(pathStr);
679
+ }
667
680
  if (PathValueProxyWatcher.BREAK_ON_READS.size > 0 && (proxyWatcher.isAllSynced({ checkCommitAllRunsFlag: true }) || this)) {
668
681
  // NOTE: We can't do a recursive match, as the parent paths include the
669
682
  // root, which is constantly read, but not relevant.
@@ -739,7 +752,7 @@ export class PathValueProxyWatcher {
739
752
  }
740
753
  return { value: readValue };
741
754
  }
742
- };
755
+ }, "PathValueProxyWatcher|getCallback");
743
756
 
744
757
  // We exclude undefined, BUT, we include "null", etc.
745
758
  // - This differs from javascript, but... due to how deletions work, we can't/don't differentiate between
@@ -749,7 +762,11 @@ export class PathValueProxyWatcher {
749
762
  return value?.value !== undefined;
750
763
  };
751
764
 
752
- public setCallback = (pathStr: string, value: unknown, inRecursion = false, allowSpecial = false): void => {
765
+ // TEMPORARY measure, remove once the catalog import's slowness is found.
766
+ public setCallback = measureWrap((pathStr: string, value: unknown, inRecursion = false, allowSpecial = false): void => {
767
+ if (this.runningWatcher && this.runningWatcher.evaluationSetCallbacks.length > 0) {
768
+ for (let callback of this.runningWatcher.evaluationSetCallbacks) callback(pathStr);
769
+ }
753
770
  if (PathValueProxyWatcher.BREAK_ON_WRITES.size > 0 && proxyWatcher.isAllSynced({ checkCommitAllRunsFlag: true })) {
754
771
  if (isRecursiveMatch(PathValueProxyWatcher.BREAK_ON_WRITES, pathStr)) {
755
772
  let unwatch = () => removeMatches(PathValueProxyWatcher.BREAK_ON_WRITES, pathStr);
@@ -922,9 +939,13 @@ export class PathValueProxyWatcher {
922
939
  }
923
940
  watcher.pendingWrites.set(pathStr, value);
924
941
  }
925
- };
942
+ }, "PathValueProxyWatcher|setCallback");
926
943
  /** Syncs keys AND values (as we won't return a key for a value that is undefined). */
927
- public getKeys = (pathStr: string): string[] => {
944
+ // TEMPORARY measure, remove once the catalog import's slowness is found.
945
+ public getKeys = measureWrap((pathStr: string): string[] => {
946
+ if (this.runningWatcher && this.runningWatcher.evaluationGetKeysCallbacks.length > 0) {
947
+ for (let callback of this.runningWatcher.evaluationGetKeysCallbacks) callback(pathStr);
948
+ }
928
949
  if (getPathDepth(pathStr) < MODULE_INDEX) {
929
950
  throw new Error(`Cannot call getKeys on path "${pathStr}" because it is too shallow. Must be at least ${MODULE_INDEX} levels deep.`);
930
951
  }
@@ -1005,7 +1026,7 @@ export class PathValueProxyWatcher {
1005
1026
  keysArray.sort();
1006
1027
 
1007
1028
  return keysArray;
1008
- };
1029
+ }, "PathValueProxyWatcher|getKeys");
1009
1030
 
1010
1031
  private getSymbol = (pathStr: string, symbol: symbol): { value: unknown } | undefined => {
1011
1032
  if (symbol === Symbol.toPrimitive) return {
@@ -1054,6 +1075,39 @@ export class PathValueProxyWatcher {
1054
1075
 
1055
1076
  private runningWatcher: SyncWatcher | undefined;
1056
1077
 
1078
+ /** Identifies the current watcher evaluation, or undefined outside of one. A new evaluation always gets a new id, so holding an id is how a caller detects that its evaluation-scoped state is stale. */
1079
+ public getEvaluationId(): number | undefined {
1080
+ if (!this.runningWatcher) return undefined;
1081
+ return this.runningWatcher.evaluationId;
1082
+ }
1083
+ public onEvaluationGet(callback: (pathStr: string) => void): () => void {
1084
+ const watcher = this.runningWatcher;
1085
+ if (!watcher) throw new Error(`onEvaluationGet requires a running watcher evaluation`);
1086
+ watcher.evaluationGetCallbacks.push(callback);
1087
+ return () => {
1088
+ let index = watcher.evaluationGetCallbacks.indexOf(callback);
1089
+ if (index >= 0) watcher.evaluationGetCallbacks.splice(index, 1);
1090
+ };
1091
+ }
1092
+ public onEvaluationGetKeys(callback: (pathStr: string) => void): () => void {
1093
+ const watcher = this.runningWatcher;
1094
+ if (!watcher) throw new Error(`onEvaluationGetKeys requires a running watcher evaluation`);
1095
+ watcher.evaluationGetKeysCallbacks.push(callback);
1096
+ return () => {
1097
+ let index = watcher.evaluationGetKeysCallbacks.indexOf(callback);
1098
+ if (index >= 0) watcher.evaluationGetKeysCallbacks.splice(index, 1);
1099
+ };
1100
+ }
1101
+ public onEvaluationSet(callback: (pathStr: string) => void): () => void {
1102
+ const watcher = this.runningWatcher;
1103
+ if (!watcher) throw new Error(`onEvaluationSet requires a running watcher evaluation`);
1104
+ watcher.evaluationSetCallbacks.push(callback);
1105
+ return () => {
1106
+ let index = watcher.evaluationSetCallbacks.indexOf(callback);
1107
+ if (index >= 0) watcher.evaluationSetCallbacks.splice(index, 1);
1108
+ };
1109
+ }
1110
+
1057
1111
  // NOTE: We can't support promises until we are able to run the function on another
1058
1112
  // thread (at which point we can ensure there is no parallel function running).
1059
1113
  public createWatcher<Result = void>(
@@ -1115,9 +1169,8 @@ export class PathValueProxyWatcher {
1115
1169
 
1116
1170
  const self = this;
1117
1171
  if (options.runAtTime) {
1118
- // 80% of our real max range, so calls with a runAtTime close to our limit have a bit of breathing room to run
1119
- let runLeeway = MAX_ACCEPTED_CHANGE_AGE * 0.8;
1120
- let runCutoffTime = Date.now() - runLeeway;
1172
+ // Fail early because if it's slow, it could easily take 10 or 15 minutes before it fully synchronizes. And there's no point in waiting all that time, re-evaluating so many times, and then just failing after all that when we knew we were going fail anyways.
1173
+ let runCutoffTime = Date.now() - MAX_CHANGE_AGE;
1121
1174
  if (options.runAtTime && options.runAtTime.time < runCutoffTime) {
1122
1175
  let message = `MAX_CHANGE_AGE_EXCEEDED! Cannot run watcher ${options.debugName} at time ${options.runAtTime.time} because it is older than the cutOff time of ${runCutoffTime}. Writing this far in the past would break things, and might be rejected by other authorities due to being too old.`;
1123
1176
  console.error(red(message));
@@ -1143,6 +1196,10 @@ export class PathValueProxyWatcher {
1143
1196
  disposed: false,
1144
1197
  onInnerDisposed: [],
1145
1198
  onAfterTriggered: [],
1199
+ evaluationId: 0,
1200
+ evaluationGetCallbacks: [],
1201
+ evaluationGetKeysCallbacks: [],
1202
+ evaluationSetCallbacks: [],
1146
1203
  explicitlyTrigger: () => { },
1147
1204
  hasAnyUnsyncedAccesses: () => false,
1148
1205
  currentReadTime: options.runAtTime,
@@ -1297,6 +1354,10 @@ export class PathValueProxyWatcher {
1297
1354
  // IMPORTANT! Reset onInnerDisposed, so onCommitFinished doesn't result in a callback
1298
1355
  // per time we wan the watcher!
1299
1356
  watcher.onInnerDisposed = [];
1357
+ watcher.evaluationId = nextEvaluationId++;
1358
+ watcher.evaluationGetCallbacks = [];
1359
+ watcher.evaluationGetKeysCallbacks = [];
1360
+ watcher.evaluationSetCallbacks = [];
1300
1361
  watcher.specialPromiseUnsynced = false;
1301
1362
 
1302
1363
  // NOTE: If runAtTime is undefined, the writeTime will be undefined, causing us to read the latest data.
@@ -1331,6 +1392,16 @@ export class PathValueProxyWatcher {
1331
1392
 
1332
1393
  let result: { result: Result } | { error: string };
1333
1394
  try {
1395
+ // Mirrors the same check at watcher creation, because a call can also die MID-sync: every rerun waits for more data while the write time stays fixed at runAtTime, so once that is too old the eventual commit can only ever be rejected (PathValueCommitter's MAX_CHANGE_AGE check). Failing here, before any reads (so nothing registers as unsynced and the error actually reports instead of the watcher waiting again), saves the entire remaining sync — potentially minutes of work that would end in that same error anyway.
1396
+ if (watcher.options.runAtTime && watcher.options.canWrite) {
1397
+ let runLeeway = MAX_ACCEPTED_CHANGE_AGE * 0.8;
1398
+ let runCutoffTime = Date.now() - runLeeway;
1399
+ if (watcher.options.runAtTime.time < runCutoffTime) {
1400
+ let message = `MAX_CHANGE_AGE_EXCEEDED! Cannot continue running watcher ${watcher.debugName} at time ${watcher.options.runAtTime.time}, it is older than the cutoff time of ${runCutoffTime}. Its writes could never be committed (they would be rejected as too far in the past), so failing now instead of after syncing even more.`;
1401
+ console.error(red(message));
1402
+ throw new Error(message);
1403
+ }
1404
+ }
1334
1405
  let rawResult: Result;
1335
1406
  const handling = watcher.options.nestedCalls;
1336
1407
  let curFunction = baseFunction;
@@ -1,4 +1,5 @@
1
1
  import { delay } from "socket-function/src/batching";
2
+ import { measureWrap } from "socket-function/src/profiling/measure";
2
3
  import { PathValue, hashPathForTransaction, compareTime, Time, authorityStorage, epochTime, createMissingEpochValue, MISSING_TRANSACTION_PART_TIMEOUT, debugPathValue } from "../0-path-value-core/pathValueCore";
3
4
  import { SyncWatcher, proxyWatcher } from "./PathValueProxyWatcher";
4
5
  import { isDefined } from "../misc";
@@ -66,33 +67,27 @@ export function isMissingTransactionPart(pathValues: PathValue[], timeoutCheck?:
66
67
  return newestWaitTime;
67
68
  }
68
69
 
69
- export function getAllPendingPathValueReads(watcher = proxyWatcher.getTriggeredWatcher()) {
70
+ function getPendingPathValueReadsNewerThan(watcher: SyncWatcher, oldestTime: number) {
70
71
  let values: PathValue[] = [];
71
72
  for (let map of watcher.pendingAccesses.values()) {
72
73
  for (let pathValue of map.values()) {
74
+ if (pathValue.pathValue.time.time < oldestTime) continue;
73
75
  values.push(pathValue.pathValue);
74
76
  }
75
77
  }
76
- // NOTE: These can't contribute the transaction paths, but they do contribute their path and their time, which will cause other transaction paths to know that they are invalid.
77
- let allEpochPaths = new Set<string>();
78
- for (let paths of watcher.pendingEpochAccesses.values()) {
79
- for (let path of paths) {
80
- allEpochPaths.add(path);
81
- }
82
- }
83
- for (let path of allEpochPaths) {
84
- values.push(createMissingEpochValue(path));
85
- }
86
78
  return values;
87
79
  }
88
80
 
89
81
 
90
- export function waitIfReceivedIncompleteTransaction(watcher: SyncWatcher) {
82
+ export const waitIfReceivedIncompleteTransaction = measureWrap(function waitIfReceivedIncompleteTransaction(watcher: SyncWatcher) {
91
83
  // We don't want to register a whole bunch of duplicate promises. So if somebody's waiting, there's no need to even do our check.
92
84
  if (watcher.specialPromiseUnsynced) return;
93
85
 
94
86
  let readTime = watcher.currentReadTime;
95
- let pendingValuePaths = getAllPendingPathValueReads(watcher);
87
+ // Transactions have the same time, so we don't have to worry about filtering out part of a transaction, not the other. And if the transaction is too old, we're not going to care if there are missing parts. So we can just ignore anything that's older than this.
88
+ let oldestTime = Date.now() - MISSING_TRANSACTION_PART_TIMEOUT;
89
+ let pendingValuePaths = getPendingPathValueReadsNewerThan(watcher, oldestTime);
90
+
96
91
  const newestTime = isMissingTransactionPart(pendingValuePaths);
97
92
  if (!newestTime) return;
98
93
 
@@ -111,4 +106,4 @@ export function waitIfReceivedIncompleteTransaction(watcher: SyncWatcher) {
111
106
  // ALSO! Plus the hash I think only stores like 48 bits. So the chance of collision is somewhat high, especially if we're accessing thousands of paths. So sometimes this will trigger even though we're not missing any part just because we had a collision between the path hashes.
112
107
  proxyWatcher.triggerOnPromiseFinish(promise, { waitReason: "Missing transaction part" });
113
108
  console.warn(`Waiting for missing transaction part ${newestTime.path} which was written at time ${newestTime.time} (now is ${now}). We have parts of this transaction, but we are missing this specific path.`);
114
- }
109
+ });
@@ -0,0 +1,71 @@
1
+ import { proxyWatcher } from "./PathValueProxyWatcher";
2
+ import { isDirectChildPathStr } from "../path";
3
+
4
+ type CallLevelCacheEntry<Result> = {
5
+ result: Result;
6
+ // Every path getCallback saw while the wrapped function ran. A set to exactly one of these invalidates the entry.
7
+ reads: Set<string>;
8
+ // Every path getKeys saw. A set to a DIRECT CHILD of one of these invalidates the entry (a new or deleted child changes the keys).
9
+ keyReads: string[];
10
+ };
11
+
12
+ /** Caches a function on synchronized data for the lifespan of a single watcher evaluation — one synchronous run of a watcher function, NOT a whole function call (which can re-run many times). The gets the function makes are recorded, and any set within the same evaluation that lands on one of them (or on a direct child of a getKeys it made) invalidates the entry, so the cache can never return a value the evaluation itself has made stale. Outside an evaluation the wrapped function is simply called through.
13
+ *
14
+ * Each live entry costs a comparison per set for the rest of the evaluation, so this is for functions called many times per evaluation with few distinct arguments — not a general memoizer. */
15
+ export function callLevelCache<Args extends unknown[], Result>(fnc: (...args: Args) => Result): (...args: Args) => Result {
16
+ let cachedEvaluationId = -1;
17
+ let entries = new Map<string, CallLevelCacheEntry<Result>>();
18
+ let hasSetCallback = false;
19
+
20
+ function onSet(pathStr: string): void {
21
+ for (let [key, entry] of entries) {
22
+ if (setInvalidatesEntry(entry, pathStr)) {
23
+ entries.delete(key);
24
+ }
25
+ }
26
+ }
27
+
28
+ return function callLevelCached(...args: Args): Result {
29
+ let evaluationId = proxyWatcher.getEvaluationId();
30
+ if (evaluationId === undefined) return fnc(...args);
31
+ if (evaluationId !== cachedEvaluationId) {
32
+ cachedEvaluationId = evaluationId;
33
+ entries = new Map();
34
+ // The previous evaluation's set callback was auto-unregistered when it ended.
35
+ hasSetCallback = false;
36
+ }
37
+ let key = JSON.stringify(args);
38
+ let existing = entries.get(key);
39
+ if (existing) return existing.result;
40
+ let reads = new Set<string>();
41
+ let keyReads: string[] = [];
42
+ let unregisterGet = proxyWatcher.onEvaluationGet(pathStr => {
43
+ reads.add(pathStr);
44
+ });
45
+ let unregisterGetKeys = proxyWatcher.onEvaluationGetKeys(pathStr => {
46
+ keyReads.push(pathStr);
47
+ });
48
+ let result: Result;
49
+ try {
50
+ result = fnc(...args);
51
+ } finally {
52
+ unregisterGet();
53
+ unregisterGetKeys();
54
+ }
55
+ entries.set(key, { result, reads, keyReads });
56
+ if (!hasSetCallback) {
57
+ hasSetCallback = true;
58
+ proxyWatcher.onEvaluationSet(onSet);
59
+ }
60
+ return result;
61
+ };
62
+ }
63
+
64
+ function setInvalidatesEntry(entry: CallLevelCacheEntry<unknown>, pathStr: string): boolean {
65
+ if (entry.reads.has(pathStr)) return true;
66
+ // Iterate the (very few) getKeys paths, rather than deriving the set path's parent and looking it up — isDirectChildPathStr does its cheap rejections before paying for the derive.
67
+ for (let keyReadPath of entry.keyReads) {
68
+ if (isDirectChildPathStr(keyReadPath, pathStr)) return true;
69
+ }
70
+ return false;
71
+ }
@@ -802,6 +802,7 @@ export class PathFunctionRunner {
802
802
  noWaitForCommit: true,
803
803
  debugName: debugName,
804
804
  source: debugName,
805
+ // NOTE: Run at time is needed not just so we read at a specific time, but it's also needed for rejections. We could technically make it so that the read of the result that we use for rejections always happens at a specific time and not set this in certain cases. But then, even then, we'll probably run into problems with the secondary runners trying to run the call because they think it's stuck. We should just make our calls faster by splitting them up.
805
806
  runAtTime: callSpec.runAtTime,
806
807
  getPermissionsCheck: PermissionsChecker && (() => new PermissionsChecker(callSpec)),
807
808
  nestedCalls: "inline",
@@ -813,7 +814,7 @@ export class PathFunctionRunner {
813
814
  stats.totalInternalLoopCount++;
814
815
  functionCallInnerCount++;
815
816
  if (PathFunctionRunner.DEBUG_CALLS) {
816
- console.log(`Evaluating (try count ${runCount}) ${debugNameColored}`);
817
+ console.log(`Evaluating (try count ${runCount}, ${proxyWatcher.getTriggeredWatcherMaybeUndefined()?.lastWatches.paths.size ?? 0} paths) ${debugNameColored}`);
817
818
  }
818
819
  if (runCount > PathFunctionRunner.MAX_WATCH_LOOPS) {
819
820
  let errorMessage = `MAX_WATCH_LOOPS exceeded for ${debugNameColored}. All accesses have to be consistent. So Querysub.time() instead of Date.now() and Querysub.nextId() instead of nextId() / Math.random(). If you need multiple random numbers, keep track of an index, and pass it to Querysub.nextId() for the nth random number.`;
@@ -166,7 +166,20 @@ export type PermissionsParameters = {
166
166
  * of undefined, and a time of 0.
167
167
 
168
168
  */
169
- export type PermissionsCallback = (config: PermissionsParameters) => PermissionsCheckResult;
169
+ export type PermissionsCallback = ((config: PermissionsParameters) => PermissionsCheckResult) & {
170
+ /** The check's result cannot change within a single call, so it may be cached for the call's lifetime (see the callImmutable cache in permissions.ts). Technically user permissions CAN change during a call — but every call that changes them must simply do nothing afterwards, so nothing ever acts on a result the change made stale. The functions that change user permissions carry a note saying exactly that. */
171
+ callImmutable?: boolean;
172
+ };
173
+
174
+ /** Restricts nothing itself (every ancestor check still applies) — its only purpose is to give a collection a LEAF permissions path, so checks under it resolve there instead of to a non-leaf ancestor and become cacheable for the call (see the callImmutable cache in permissions.ts). Use it on a collection whose siblings carry deeper rules:
175
+ * permissions: {
176
+ * PERMISSIONS: isRegisteredUserPERMISSIONS,
177
+ * myCollection: { PERMISSIONS: leafCachePERMISSIONS },
178
+ * myCollectionInternal: { PERMISSIONS: isSuperUserPERMISSIONS },
179
+ * }
180
+ */
181
+ export const leafCachePERMISSIONS: PermissionsCallback = () => true;
182
+ leafCachePERMISSIONS.callImmutable = true;
170
183
  export type PermissionsCheckResult = boolean | { allowed: boolean; skipParentChecks?: boolean; };
171
184
  /*
172
185
  NOTE: All ancestor permissions checks are applied as well.
@@ -1,6 +1,6 @@
1
- import { cache, cacheArgsEqual, cacheLimited } from "socket-function/src/caching";
1
+ import { cache, cacheArgsEqual, cacheJSONArgsEqual, cacheLimited } from "socket-function/src/caching";
2
2
  import { measureFnc, measureWrap, nameFunction } from "socket-function/src/profiling/measure";
3
- import { getPathSuffix, getPathDepth, trimPathStrToDepth, getPathFromStr, rootPathStr, getPathIndex, getPathStr1, joinPathStres, appendToPathStr, getPathStr } from "../path";
3
+ import { getPathSuffix, getPathDepth, trimPathStrToDepth, getPathFromStr, rootPathStr, getPathIndex, getPathStr1, isSameOrChildPathStr, joinPathStres, appendToPathStr, getPathStr } from "../path";
4
4
  import { atomic, atomicObjectRead, isSynced, proxyWatcher } from "../2-proxy/PathValueProxyWatcher";
5
5
  import { SchemaObject, getSchemaObject, hasWildcardMatch, getWildcardMatches, PERMISSIONS_FUNCTION_ID, PermissionsCheckResult, getDevelopmentModule } from "../3-path-functions/syncSchema";
6
6
  import { getModuleFromConfig } from "../3-path-functions/pathFunctionLoader";
@@ -30,6 +30,28 @@ function watchModule(config: FunctionSpec): NodeJS.Module | undefined {
30
30
 
31
31
  const callPermissionsPath = getPathStr1(CALL_PERMISSIONS_KEY);
32
32
 
33
+ // How many callImmutable results a single call may hold before the whole set resets. Every live entry costs a prefix comparison on EVERY permissions check, so a big cache makes everything slower — a small one that occasionally resets stays a pure win.
34
+ const CALL_IMMUTABLE_RESULTS_LIMIT = 32;
35
+ // A non-cacheable permissions path re-runs its rule callbacks on every single check. Once one is hit this many times within a call it is a real cost, so we warn — and again at every further multiple, so the scale of the problem stays visible.
36
+ const NON_CACHEABLE_WARN_INTERVAL = 100;
37
+ // The warning includes the source of each check missing its callImmutable flag; rule callbacks are usually short, but a long one shouldn't flood the log.
38
+ const NON_CALL_IMMUTABLE_SOURCE_MAX_LENGTH = 500;
39
+
40
+ // The spec is pure data derived from (domainName, moduleId), but building it inline gave it a fresh identity per call — which defeated perSchema (cacheArgsEqual compares by ===), recreating PermissionsCheckSchema and its checkCache on EVERY check on dev deployments. Resolving through here hands back the same object every time, so all the downstream caches hit.
41
+ const getDevelopmentFunctionSpec = cacheJSONArgsEqual(function getDevelopmentFunctionSpec(domainName: string, moduleId: string): FunctionSpec {
42
+ return {
43
+ DomainName: domainName,
44
+ ModuleId: moduleId,
45
+ FunctionId: PERMISSIONS_FUNCTION_ID,
46
+ exportPathStr: callPermissionsPath,
47
+ // NOTE: These SHOULDN'T be required, as we don't commit this function to the FunctionRunner,
48
+ // we just use this to run the function on the local machine.
49
+ FilePath: "LOCAL_PERMISSIONS_HACK",
50
+ gitURL: "LOCAL_PERMISSIONS_HACK",
51
+ gitRef: "LOCAL_PERMISSIONS_HACK",
52
+ };
53
+ }, 1000 * 100);
54
+
33
55
  /** NOTE: This can only be used synchronously, and must be recreated after the current
34
56
  * synchronous code is finished.
35
57
  */
@@ -57,17 +79,7 @@ export class PermissionsCheck {
57
79
  schema: SchemaObject;
58
80
  fnc: FunctionSpec;
59
81
  } | undefined {
60
- let fnc: FunctionSpec = {
61
- DomainName: domainName,
62
- ModuleId: moduleId,
63
- FunctionId: PERMISSIONS_FUNCTION_ID,
64
- exportPathStr: callPermissionsPath,
65
- // NOTE: These SHOULDN'T be required, as we don't commit this function to the FunctionRunner,
66
- // we just use this to run the function on the local machine.
67
- FilePath: "LOCAL_PERMISSIONS_HACK",
68
- gitURL: "LOCAL_PERMISSIONS_HACK",
69
- gitRef: "LOCAL_PERMISSIONS_HACK",
70
- };
82
+ let fnc = getDevelopmentFunctionSpec(domainName, moduleId);
71
83
  let modulePermissions = getDevelopmentModule(moduleId);
72
84
  if (!modulePermissions) {
73
85
  getDevelopmentModule(moduleId);
@@ -122,11 +134,81 @@ export class PermissionsCheck {
122
134
  callExamplePath: string;
123
135
  emptyKeyPath: string;
124
136
  }>();
137
+ // Leaf, all-callImmutable results, checked by prefix before anything else. Capped: every live entry is a comparison per check, so past the cap the whole thing resets rather than making every check slower.
138
+ private callImmutableResults = new Map<string, boolean>();
139
+ private registeredCallImmutableReset = false;
140
+ private ensureCallImmutableResetRegistered(): void {
141
+ if (this.registeredCallImmutableReset) return;
142
+ this.registeredCallImmutableReset = true;
143
+ // onAfterTriggered is the immediate end-of-call reset — it fires when this run finishes. onInnerDisposed would wait until the call is fully synchronized, which is too late: the next run must recheck.
144
+ proxyWatcher.getTriggeredWatcherMaybeUndefined()?.onAfterTriggered.push(() => {
145
+ this.callImmutableResults.clear();
146
+ this.callImmutableExactResults.clear();
147
+ this.nonCacheableCounts.clear();
148
+ });
149
+ }
150
+ private addCallImmutableResult(permissionsPath: string, allowed: boolean): void {
151
+ this.ensureCallImmutableResetRegistered();
152
+ if (this.callImmutableResults.size >= CALL_IMMUTABLE_RESULTS_LIMIT) this.callImmutableResults.clear();
153
+ this.callImmutableResults.set(permissionsPath, allowed);
154
+ }
155
+
156
+ // Non-leaf permissions paths with all-callImmutable checks, cached by the EXACT path checked. A child of a non-leaf path may resolve to a deeper rule, so no prefix matching — but the same path always resolves the same way, so an equality hit is sound. The hottest case is the module's Data root, which every write's parent-marker walk checks and which can never be a leaf (every rule sits below it). Unlimited, because lookups are a Map.get — unlike the prefix cache, size costs the other checks nothing.
157
+ private callImmutableExactResults = new Map<string, { permissionsPath: string; allowed: boolean }>();
158
+ private addCallImmutableExactResult(path: string, permissionsPath: string, allowed: boolean): void {
159
+ this.ensureCallImmutableResetRegistered();
160
+ this.callImmutableExactResults.set(path, { permissionsPath, allowed });
161
+ }
162
+
163
+ // How often checks have resolved to each non-cacheable permissions path this call. Reset with the results above. Non-cacheable checks re-run their rule callbacks every time, so a hot one is the exact problem the callImmutable cache exists to prevent — count it and say so.
164
+ private nonCacheableCounts = new Map<string, number>();
165
+ private countNonCacheableCheck(path: string, permissionsPath: string, nonCallImmutableSources: string[]): void {
166
+ this.ensureCallImmutableResetRegistered();
167
+ let count = (this.nonCacheableCounts.get(permissionsPath) || 0) + 1;
168
+ this.nonCacheableCounts.set(permissionsPath, count);
169
+ if (count % NON_CACHEABLE_WARN_INTERVAL !== 0) return;
170
+ // The unmarked checks themselves, because their source usually makes it obvious whether they are actually call-immutable — and says exactly which function to go mark. Empty means every check IS marked, so the problem must be leaf-ness.
171
+ let sourcesText = "";
172
+ if (nonCallImmutableSources.length > 0) {
173
+ sourcesText = `\nThe checks NOT marked callImmutable:\n` + nonCallImmutableSources.map(source => ` ${source.slice(0, NON_CALL_IMMUTABLE_SOURCE_MAX_LENGTH)}`).join("\n");
174
+ }
175
+ console.warn(red(
176
+ `Many permission checks (${count} this call) on paths which resolve to the non-cacheable permissions path "${permissionsPath}" (most recently the path "${path}"). `
177
+ + `A result is only cacheable when the permissions path is a leaf (no rules below it) AND every check that runs for it is marked callImmutable. `
178
+ + `Either your permissions are legitimately complicated (a unique check per path, which is simply slow), `
179
+ + `or a rule is missing its callImmutable flag, `
180
+ + `or these paths have no leaf rule of their own and are resolving to a non-leaf ancestor — `
181
+ + `adding a level of nesting with leafCachePERMISSIONS adds a redundant (allow-everything) check, but gives the paths a leaf so the result is cached for the rest of the call:\n`
182
+ + ` permissions: {\n`
183
+ + ` PERMISSIONS: isRegisteredUserPERMISSIONS,\n`
184
+ + ` myCollection: { PERMISSIONS: leafCachePERMISSIONS },\n`
185
+ + ` myCollectionInternal: { PERMISSIONS: isSuperUserPERMISSIONS },\n`
186
+ + ` }`
187
+ + sourcesText
188
+ ));
189
+ }
190
+
125
191
  // IMPORTANT! This function USED TO BE a major hotspot for function evaluation, and so is heavily optimized.
192
+ @measureFnc
126
193
  public checkPermissions(path: string): { permissionsPath: string; allowed: boolean; } {
127
194
  if (this.dead) throw new Error("PermissionsCheck MUST be used synchronously, after which it cannot be reused");
128
195
  if (PermissionsCheck.skippingChecks) return { permissionsPath: rootPathStr, allowed: true };
129
196
 
197
+ if (this.callImmutableExactResults.size > 0) {
198
+ let exact = this.callImmutableExactResults.get(path);
199
+ if (exact) {
200
+ return { permissionsPath: exact.permissionsPath, allowed: exact.allowed };
201
+ }
202
+ }
203
+ // The cached permissions paths are leaves, so anything at or under one resolves to it — a prefix check IS the whole resolution.
204
+ if (this.callImmutableResults.size > 0) {
205
+ for (let [cachedPath, allowed] of this.callImmutableResults) {
206
+ if (isSameOrChildPathStr(cachedPath, path)) {
207
+ return { permissionsPath: cachedPath, allowed };
208
+ }
209
+ }
210
+ }
211
+
130
212
  let pathPrefix = trimPathStrToDepth(path, DEPTH_TO_DATA);
131
213
  let pathParts = this.pathPartsCache.get(pathPrefix);
132
214
  if (!pathParts) {
@@ -177,7 +259,15 @@ export class PermissionsCheck {
177
259
  }
178
260
  if (rootKey === "Data") {
179
261
  let dataPath = getPathSuffix(path, DEPTH_TO_DATA);
180
- return instance.checkPermissionsBase(dataPath);
262
+ let result = instance.checkPermissionsBase(dataPath);
263
+ if (result.callCacheable) {
264
+ this.addCallImmutableResult(result.permissionsPath, result.allowed);
265
+ } else if (result.allCallImmutable) {
266
+ this.addCallImmutableExactResult(path, result.permissionsPath, result.allowed);
267
+ } else {
268
+ this.countNonCacheableCheck(path, result.permissionsPath, result.nonCallImmutableSources);
269
+ }
270
+ return result;
181
271
  }
182
272
  return { permissionsPath: path, allowed: false };
183
273
  }
@@ -216,6 +306,7 @@ class PermissionsCheckSchema {
216
306
  /** Converts a specific path to a more general path. All paths which map to this general path
217
307
  * will have identical permissions checks.
218
308
  */
309
+ @measureFnc
219
310
  private getCachePermissionsPath(path: string): string {
220
311
  if (this.schema.permissionsNonWildcards.has(path)) {
221
312
  return path;
@@ -244,7 +335,7 @@ class PermissionsCheckSchema {
244
335
  // NOTE: For our trivial "127.0.0.1" check this cache isn't needed. But permissions checks might be A LOT slower,
245
336
  // so it is important to cache it based on the permissionsPath.
246
337
  private checkCache = cacheLimited(1000 * 1000, measureWrap((permissionsPath: string) => {
247
- let checks = this.getChecks(permissionsPath);
338
+ let { checks, allCallImmutable, nonCallImmutableSources } = this.getChecks(permissionsPath);
248
339
  let allowed = overrideCurrentCall({
249
340
  spec: this.exampleCall,
250
341
  fnc: this.fnc,
@@ -286,32 +377,73 @@ class PermissionsCheckSchema {
286
377
  return true;
287
378
  });
288
379
  });
289
- return { permissionsPath: joinPathStres(this.pathPrefix, permissionsPath), allowed };
380
+ return {
381
+ permissionsPath: joinPathStres(this.pathPrefix, permissionsPath),
382
+ allowed,
383
+ // A leaf with only callImmutable checks means any path at or under permissionsPath resolves to this same result for the rest of the call, so the caller may cache it (see addCallImmutableResult). All-callImmutable WITHOUT the leaf still allows exact-path caching (addCallImmutableExactResult).
384
+ callCacheable: allCallImmutable && this.isLeafPermissionsPath(permissionsPath),
385
+ allCallImmutable,
386
+ nonCallImmutableSources,
387
+ };
290
388
  }, "permissionsCheckInsideCache"));
291
389
 
292
- public checkPermissionsBase(dataPath: string): { permissionsPath: string; allowed: boolean; } {
390
+ @measureFnc
391
+ public checkPermissionsBase(dataPath: string): { permissionsPath: string; allowed: boolean; callCacheable: boolean; allCallImmutable: boolean; nonCallImmutableSources: string[]; } {
293
392
  let permissionsPath = this.getCachePermissionsPath(dataPath);
294
393
  return this.checkCache(permissionsPath);
295
394
  }
395
+
396
+ // A permissions path is a leaf when NO rule sits strictly below it. That is what makes caching by prefix sound: with no deeper rules, every path at or under the leaf resolves to the leaf itself, so one cached result answers all of them.
397
+ private isLeafPermissionsPath = cacheLimited(1000 * 1000, (permissionsPath: string): boolean => {
398
+ for (let rulePath of this.schema.permissionsNonWildcards.keys()) {
399
+ if (rulePath.length <= permissionsPath.length) continue;
400
+ if (isSameOrChildPathStr(permissionsPath, rulePath)) return false;
401
+ }
402
+ if (this.schema.permissionsWildcards.length > 0) {
403
+ let depth = getPathDepth(permissionsPath);
404
+ let parts = getPathFromStr(permissionsPath);
405
+ for (let wildcardCheck of this.schema.permissionsWildcards) {
406
+ if (wildcardCheck.pathParts.length <= depth) continue;
407
+ // A deeper wildcard only breaks leaf-ness if it can actually match a descendant, which requires its first `depth` parts to match ours.
408
+ let couldMatchChild = true;
409
+ for (let i = 0; i < depth; i++) {
410
+ if (wildcardCheck.pathParts[i] === "*") continue;
411
+ if (wildcardCheck.pathParts[i] !== parts[i]) {
412
+ couldMatchChild = false;
413
+ break;
414
+ }
415
+ }
416
+ if (couldMatchChild) return false;
417
+ }
418
+ }
419
+ return true;
420
+ });
296
421
  // NOTE: We don't cache the result of a permissions check, because it might change while
297
422
  // a function is evaluating. If they need to be skipped for the purposes of speed (which is hard
298
423
  // to believe, considering all the other sources of overhead we have), skipPermissionsChecks
299
424
  // can be used to quickly skip them.
300
- private getChecks = cacheLimited(1000 * 1000, ((path: string): (() => PermissionsCheckResult)[] => {
425
+ private getChecks = cacheLimited(1000 * 1000, measureWrap((path: string): { checks: (() => PermissionsCheckResult)[]; allCallImmutable: boolean; nonCallImmutableSources: string[] } => {
301
426
  let checks: {
302
427
  check: () => PermissionsCheckResult;
303
428
  path: string;
304
429
  }[] = [];
430
+ let allCallImmutable = true;
431
+ let nonCallImmutableSources: string[] = [];
305
432
  let depth = getPathDepth(path);
306
433
  for (let i = depth; i >= 0; i--) {
307
434
  let ancestorPath = trimPathStrToDepth(path, i);
308
435
  const permissions = this.schema.permissionsNonWildcards.get(ancestorPath);
309
436
  if (permissions) {
310
- let check = () => permissions({
437
+ if (!permissions.callImmutable) {
438
+ allCallImmutable = false;
439
+ nonCallImmutableSources.push(permissions.toString());
440
+ }
441
+ // Named by the rule it came from, so the table says WHICH permission is doing the reading rather than just that permissions are slow. Built once per generalized path (this is inside a cache), so the wrapper is not rebuilt per check.
442
+ let check = measureWrap(() => permissions({
311
443
  callerMachineId: this.callerConfig.machineID,
312
444
  matchedPath: ancestorPath,
313
445
  pathWildcards: [],
314
- });
446
+ }), `permissionsRule ${ancestorPath}`);
315
447
  if (PermissionsCheck.DEBUG) {
316
448
  Object.assign(check, { debugName: ancestorPath });
317
449
  }
@@ -323,12 +455,17 @@ class PermissionsCheckSchema {
323
455
  for (let wildcardCheck of this.schema.permissionsWildcards) {
324
456
  let matches = getWildcardMatches(wildcardCheck.pathParts, getPathFromStr(path));
325
457
  if (!matches) continue;
458
+ if (!wildcardCheck.callback.callImmutable) {
459
+ allCallImmutable = false;
460
+ nonCallImmutableSources.push(wildcardCheck.callback.toString());
461
+ }
326
462
  let matchedPath = trimPathStrToDepth(path, wildcardCheck.pathParts.length);
327
- let check = () => wildcardCheck.callback({
463
+ // Named by the wildcard pattern rather than the path it matched, so every path a rule covers aggregates into one row instead of one row per key.
464
+ let check = measureWrap(() => wildcardCheck.callback({
328
465
  callerMachineId: this.callerConfig.machineID,
329
466
  matchedPath,
330
467
  pathWildcards: matches || [],
331
- });
468
+ }), `permissionsRule ${wildcardCheck.pathParts.join(".")}`);
332
469
  if (PermissionsCheck.DEBUG) {
333
470
  Object.assign(check, { debugName: wildcardCheck.pathParts.join(".") });
334
471
  }
@@ -337,6 +474,6 @@ class PermissionsCheckSchema {
337
474
 
338
475
  sort(checks, x => -x.path.length);
339
476
 
340
- return checks.map(x => x.check);
341
- }));
477
+ return { checks: checks.map(x => x.check), allCallImmutable, nonCallImmutableSources };
478
+ }, "permissionsGetChecksInsideCache"));
342
479
  }
@@ -17,7 +17,8 @@ import { PathAuditerController } from "./pathAuditer";
17
17
  import { t } from "../2-proxy/schema2";
18
18
  import { proxyWatcher } from "../2-proxy/PathValueProxyWatcher";
19
19
  import { remoteWatcher } from "../1-path-client/RemoteWatcher";
20
- import { getAllPendingPathValueReads, isMissingTransactionPart, waitIfReceivedIncompleteTransaction } from "../2-proxy/TransactionDelayer";
20
+ import { isMissingTransactionPart } from "../2-proxy/TransactionDelayer";
21
+ import { createMissingEpochValue, PathValue } from "../0-path-value-core/pathValueCore";
21
22
 
22
23
  module.hotreload = true;
23
24
 
@@ -290,6 +291,27 @@ export class SyncTestPage extends qreact.Component {
290
291
  }
291
292
  }
292
293
 
294
+ // A diagnostic wants every pending read, so this is the unfiltered version — TransactionDelayer's own copy filters by time as it loops, which is why it stopped being shared.
295
+ function getAllPendingPathValueReads(watcher = proxyWatcher.getTriggeredWatcher()) {
296
+ let values: PathValue[] = [];
297
+ for (let map of watcher.pendingAccesses.values()) {
298
+ for (let pathValue of map.values()) {
299
+ values.push(pathValue.pathValue);
300
+ }
301
+ }
302
+ // NOTE: These can't contribute the transaction paths, but they do contribute their path and their time, which will cause other transaction paths to know that they are invalid.
303
+ let allEpochPaths = new Set<string>();
304
+ for (let paths of watcher.pendingEpochAccesses.values()) {
305
+ for (let path of paths) {
306
+ allEpochPaths.add(path);
307
+ }
308
+ }
309
+ for (let path of allEpochPaths) {
310
+ values.push(createMissingEpochValue(path));
311
+ }
312
+ return values;
313
+ }
314
+
293
315
 
294
316
  class SyncTestControllerBase {
295
317
  async test() {
@@ -208,7 +208,6 @@ function syncStateToURL() {
208
208
  window.history.pushState({}, "", targetURL);
209
209
  });
210
210
  Querysub.createWriteWatcher(function syncStateToURL() {
211
- console.log("syncStateToURL", Querysub.getTriggerReason());
212
211
  let urlObj = new URL(document.location.href);
213
212
  urlObj.search = encodeSearchString(data().params, data().defaults);
214
213
  targetURL = urlObj.toString();
package/src/path.ts CHANGED
@@ -171,6 +171,18 @@ export function getParentPathStr(pathStr: string) {
171
171
  return pathStr.slice(0, getStartOfLastPart(pathStr));
172
172
  }
173
173
 
174
+ /** True when childPathStr is pathStr itself or any descendant of it. Every path str ends in the delimiter, so a plain prefix check cannot mistake a sibling with a longer key (".,ab.," is not a prefix of ".,abc.,"). */
175
+ export function isSameOrChildPathStr(pathStr: string, childPathStr: string): boolean {
176
+ return childPathStr.startsWith(pathStr);
177
+ }
178
+
179
+ /** True when childPathStr is EXACTLY one level below pathStr. The cheap length and prefix rejections run first, so the parent derivation is only paid once they pass. */
180
+ export function isDirectChildPathStr(pathStr: string, childPathStr: string): boolean {
181
+ if (childPathStr.length <= pathStr.length) return false;
182
+ if (!childPathStr.startsWith(pathStr)) return false;
183
+ return getParentPathStr(childPathStr) === pathStr;
184
+ }
185
+
174
186
  /** === getPathFromStr(pathStr).slice(-1)[0] || "" */
175
187
  export function getLastPathPart(pathStr: string) {
176
188
  let lastPartIndex = pathStr.lastIndexOf(pathDelimitEscaped);
@@ -1,11 +1,12 @@
1
1
  import { atomic, atomicObjectWrite } from "../2-proxy/PathValueProxyWatcher";
2
+ import { callLevelCache } from "../2-proxy/callLevelCache";
2
3
  import { Querysub } from "../4-querysub/Querysub";
3
4
  import { red } from "socket-function/src/formatting/logColors";
4
5
  import { isNode, sha256Hash, timeInHour } from "socket-function/src/misc";
5
6
  import { registerAliveChecker } from "../2-proxy/garbageCollection";
6
7
  import { generateLoginEmail } from "./loginEmail";
7
8
  import { logErrors } from "../errors";
8
- import { PermissionsParameters } from "../3-path-functions/syncSchema";
9
+ import { PermissionsCallback, PermissionsParameters } from "../3-path-functions/syncSchema";
9
10
  import { sendEmail_postmark } from "../email_ims_notifications/postmark";
10
11
  import { isClient } from "../config2";
11
12
  import { getExternalIP } from "../misc/networking";
@@ -438,6 +439,11 @@ export function isAdminUserPERMISSIONS(): boolean {
438
439
  export function isModeratorUserPERMISSIONS(): boolean {
439
440
  return createUserPERMISSIONS("moderator")();
440
441
  }
442
+ // A user's type does not change within a call, so these results may be cached for the call's lifetime (permissions.ts caches leaf paths whose checks all carry this). Technically it CAN change (specialSetUserType) — but a call that changes a user's type must do nothing after the change, so no caller ever acts on a cached result the change made stale. specialSetUserType carries the matching note.
443
+ isRegisteredUserPERMISSIONS.callImmutable = true;
444
+ isSuperUserPERMISSIONS.callImmutable = true;
445
+ isAdminUserPERMISSIONS.callImmutable = true;
446
+ isModeratorUserPERMISSIONS.callImmutable = true;
441
447
 
442
448
  export function isCurrentUserSuperUser() {
443
449
  return isSuperUserPERMISSIONS();
@@ -451,6 +457,17 @@ export function satisifiedUserType(currentType: UserType, minimumType: UserType)
451
457
  return currentType === minimumType || userTypeRanking[currentType] > userTypeRanking[minimumType];
452
458
  }
453
459
 
460
+ export function createWildcardUserPERMISSIONS(config: { wildcardIndex: number; allowSuperUser?: boolean; }): PermissionsCallback {
461
+ let check: PermissionsCallback = permissionsConfig => {
462
+ if (config.allowSuperUser && isSuperUserPERMISSIONS()) return true;
463
+ let userId = getUserIdAllowUndefined();
464
+ if (!userId) return false;
465
+ return permissionsConfig.pathWildcards[config.wildcardIndex] === userId;
466
+ };
467
+ check.callImmutable = true;
468
+ return check;
469
+ }
470
+
454
471
  export function createUserPERMISSIONS(userType: UserType) {
455
472
  return () => {
456
473
  let allowed = isCurrentUserType(userType);
@@ -523,9 +540,8 @@ export function assertUserType(userType: UserType) {
523
540
  }
524
541
  }
525
542
 
526
- export const getUserIdAllowUndefined = getCurrentUser;
527
- export const getUserId = getCurrentUserAssert;
528
- export function getCurrentUser(config?: { ignoreImpersonate?: boolean; }): string | undefined {
543
+ // Called constantly (every permissions check goes through it), on data that almost never changes within one evaluation — exactly what callLevelCache is for.
544
+ export const getCurrentUser = callLevelCache(function getCurrentUser(config?: { ignoreImpersonate?: boolean; }): string | undefined {
529
545
  if (!isNode()) {
530
546
  // NOTE: This check runs clientside, so it doesn't need to verify they are allowed to impersonate
531
547
  // (the user can pretend to be anyone on their machine, the server will just ignore it).
@@ -544,7 +560,9 @@ export function getCurrentUser(config?: { ignoreImpersonate?: boolean; }): strin
544
560
  return undefined;
545
561
  }
546
562
  return identifiedUserId;
547
- }
563
+ });
564
+ export const getUserIdAllowUndefined = getCurrentUser;
565
+ export const getUserId = getCurrentUserAssert;
548
566
  export const user = getCurrentUserAssert;
549
567
  export const userAssert = getCurrentUserAssert;
550
568
  export function getCurrentUserAssert(): string {
@@ -565,7 +583,8 @@ function getLoadingUserObj() {
565
583
  };
566
584
  }
567
585
 
568
- export function getCurrentUserObj(): User | undefined {
586
+ // Returns a proxy, which is safe to cache within one evaluation: a write that CHANGES which user object this resolves to also hits a path this read (machineSecure, the users key) and invalidates the entry, while a write inside the user object leaves the proxy itself still valid.
587
+ export const getCurrentUserObj = callLevelCache(function getCurrentUserObj(): User | undefined {
569
588
  let userId = getCurrentUser();
570
589
 
571
590
  if (!userId || userId === "loadinguser") {
@@ -593,7 +612,7 @@ export function getCurrentUserObj(): User | undefined {
593
612
  return getLoadingUserObj();
594
613
  }
595
614
  return users[userId];
596
- }
615
+ });
597
616
  export const getUserObj = getCurrentUserObjAssert;
598
617
  export const getUserObjAssert = getCurrentUserObjAssert;
599
618
  export function getCurrentUserObjAssert() {
@@ -1059,6 +1078,7 @@ function specialSetInviteCount(config: { userId: string; count: number; }) {
1059
1078
  data().users[config.userId].invitesRemaining = config.count;
1060
1079
  logActivity({ type: "setInviteCount", fields: { userId: config.userId, count: config.count } });
1061
1080
  }
1081
+ // IMPORTANT! This changes the user's permissions, and the user permission checks are marked callImmutable — callers everywhere assume "registered user" / "super user" cannot change during a call, and cached results are only reset when the call ends. So this must stay the LAST thing its call does: do not add work after the write here, and do not call this and then keep going.
1062
1082
  function specialSetUserType(config: { userId: string; userType: UserType; }) {
1063
1083
  assertUserType("superuser");
1064
1084
  data().users[config.userId].userType = config.userType;