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 +1 -1
- package/src/2-proxy/PathValueProxyWatcher.ts +86 -15
- package/src/2-proxy/TransactionDelayer.ts +9 -14
- package/src/2-proxy/callLevelCache.ts +71 -0
- package/src/3-path-functions/PathFunctionRunner.ts +2 -1
- package/src/3-path-functions/syncSchema.ts +14 -1
- package/src/4-querysub/permissions.ts +161 -24
- package/src/diagnostics/SyncTestPage.tsx +23 -1
- package/src/library-components/URLParam.ts +0 -1
- package/src/path.ts +12 -0
- package/src/user-implementation/userData.ts +27 -7
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1119
|
-
let
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
527
|
-
export const
|
|
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
|
-
|
|
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;
|