querysub 0.93.0 → 0.95.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/-0-hooks/hooks.ts +22 -0
- package/src/-c-identity/IdentityController.ts +7 -1
- package/src/0-path-value-core/NodePathAuthorities.ts +2 -1
- package/src/3-path-functions/PathFunctionHelpers.ts +12 -15
- package/src/3-path-functions/PathFunctionRunner.ts +24 -0
- package/src/3-path-functions/PathFunctionRunnerMain.ts +5 -0
- package/src/4-querysub/QuerysubController.ts +20 -11
- package/src/4-querysub/querysubPrediction.ts +5 -0
- package/src/diagnostics/managementPages.tsx +2 -1
- package/src/misc/filterable.ts +391 -0
package/package.json
CHANGED
package/src/-0-hooks/hooks.ts
CHANGED
|
@@ -37,6 +37,23 @@ function createHookFunction<Fnc extends (...args: any[]) => void>(debugName: str
|
|
|
37
37
|
return fnc;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
function createHookFunctionReturn<Fnc extends (...args: any[]) => any>(debugName: string): {
|
|
41
|
+
(...args: Parameters<Fnc>): ReturnType<Fnc>;
|
|
42
|
+
declare: (fnc: Fnc) => void;
|
|
43
|
+
} {
|
|
44
|
+
let declaration: Fnc | undefined;
|
|
45
|
+
function fnc(...args: Parameters<Fnc>): ReturnType<Fnc> {
|
|
46
|
+
if (!declaration) {
|
|
47
|
+
throw new Error(`Hook function ${debugName} not declared`);
|
|
48
|
+
}
|
|
49
|
+
return declaration(...args);
|
|
50
|
+
};
|
|
51
|
+
fnc.declare = (fnc: Fnc) => {
|
|
52
|
+
declaration = fnc;
|
|
53
|
+
};
|
|
54
|
+
return fnc;
|
|
55
|
+
}
|
|
56
|
+
|
|
40
57
|
export const addStatPeriodic = createHookFunction<
|
|
41
58
|
(
|
|
42
59
|
config: {
|
|
@@ -94,3 +111,8 @@ export const logNodeStateStats = createHookFunction<
|
|
|
94
111
|
value: number,
|
|
95
112
|
) => void
|
|
96
113
|
>("logNodeStateStats");
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
export const isManagementUser = createHookFunctionReturn<
|
|
117
|
+
() => Promise<boolean>
|
|
118
|
+
>("isManagementUser");
|
|
@@ -11,10 +11,11 @@ import { cache, lazy } from "socket-function/src/caching";
|
|
|
11
11
|
import { getClientNodeId, getNodeId, getNodeIdDomain, getNodeIdIP, getNodeIdLocation, isClientNodeId } from "socket-function/src/nodeCache";
|
|
12
12
|
import { getCommonName, getIdentityCA, getMachineId, getPublicIdentifier, getThreadKeyCert, parseCert, sign, validateCertificate, verify } from "../-a-auth/certs";
|
|
13
13
|
import { getShortNumber } from "../bits";
|
|
14
|
-
import { measureFnc, measureWrap } from "socket-function/src/profiling/measure";
|
|
14
|
+
import { measureBlock, measureFnc, measureWrap } from "socket-function/src/profiling/measure";
|
|
15
15
|
import { timeoutToError } from "../errors";
|
|
16
16
|
import { delay } from "socket-function/src/batching";
|
|
17
17
|
import { formatTime } from "socket-function/src/formatting/format";
|
|
18
|
+
import { waitForFirstTimeSync } from "socket-function/time/trueTimeShim";
|
|
18
19
|
|
|
19
20
|
let callerInfo = new Map<CallerContext, {
|
|
20
21
|
reconnectNodeId: string | undefined;
|
|
@@ -175,6 +176,11 @@ const IdentityController = SocketFunction.register(
|
|
|
175
176
|
);
|
|
176
177
|
|
|
177
178
|
const changeIdentityOnce = cache(async function changeIdentityOnce(connectionId: { nodeId: string }) {
|
|
179
|
+
// Wait for time sync, otherwise the time in our change identity might be rejected.
|
|
180
|
+
await measureBlock(async () => {
|
|
181
|
+
await waitForFirstTimeSync();
|
|
182
|
+
}, "waitForFirstTimeSync");
|
|
183
|
+
|
|
178
184
|
let nodeId = connectionId.nodeId;
|
|
179
185
|
let threadKeyCert = getThreadKeyCert();
|
|
180
186
|
let issuer = getIdentityCA();
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { SocketFunction } from "socket-function/SocketFunction";
|
|
2
|
-
import { measureFnc, measureWrap } from "socket-function/src/profiling/measure";
|
|
2
|
+
import { measureBlock, measureFnc, measureWrap } from "socket-function/src/profiling/measure";
|
|
3
3
|
import { errorToUndefined, errorToUndefinedSilent, ignoreErrors, logErrors, timeoutToUndefined, timeoutToUndefinedSilent } from "../errors";
|
|
4
4
|
import { PromiseObj } from "../promise";
|
|
5
5
|
import { getAllNodeIds, getBrowserUrlNode, getOwnNodeId, isNodeDiscoveryLogging, isOwnNodeId, onNodeDiscoveryReady, triggerNodeChange, watchDeltaNodeIds, watchNodeIds } from "../-f-node-discovery/NodeDiscovery";
|
|
@@ -22,6 +22,7 @@ import { IdentityController_getCurrentReconnectNodeIdAssert, IdentityController_
|
|
|
22
22
|
import { getBufferFraction, getBufferInt, getShortNumber } from "../bits";
|
|
23
23
|
import { devDebugbreak, isDevDebugbreak } from "../config";
|
|
24
24
|
import { diskLog } from "../diagnostics/logs/diskLogger";
|
|
25
|
+
import { waitForFirstTimeSync } from "socket-function/time/trueTimeShim";
|
|
25
26
|
|
|
26
27
|
export const LOCAL_DOMAIN = "LOCAL";
|
|
27
28
|
export const LOCAL_DOMAIN_PATH = getPathStr1(LOCAL_DOMAIN);
|
|
@@ -2,7 +2,7 @@ import { getOwnMachineId } from "../-a-auth/certs";
|
|
|
2
2
|
import { atomicObjectWrite, proxyWatcher, specialObjectWriteSymbol } from "../2-proxy/PathValueProxyWatcher";
|
|
3
3
|
import { pathValueCommitter } from "../0-path-value-core/PathValueController";
|
|
4
4
|
import { getNextTime } from "../0-path-value-core/pathValueCore";
|
|
5
|
-
import { FunctionSpec, functionSchema, CallSpec } from "./PathFunctionRunner";
|
|
5
|
+
import { FunctionSpec, functionSchema, CallSpec, commitCall } from "./PathFunctionRunner";
|
|
6
6
|
import { logErrors } from "../errors";
|
|
7
7
|
import { FunctionMetadata } from "./syncSchema";
|
|
8
8
|
import debugbreak from "debugbreak";
|
|
@@ -12,8 +12,9 @@ import { secureRandom } from "../misc/random";
|
|
|
12
12
|
import { isDefined } from "../misc";
|
|
13
13
|
import { blue, green, red } from "socket-function/src/formatting/logColors";
|
|
14
14
|
import { getPathStr2 } from "../path";
|
|
15
|
-
import { sort } from "socket-function/src/misc";
|
|
15
|
+
import { isNode, sort } from "socket-function/src/misc";
|
|
16
16
|
import { decodeCborx, encodeCborx } from "../misc/cloneHelpers";
|
|
17
|
+
import { parseFilterable } from "../misc/filterable";
|
|
17
18
|
const cborxInstance = lazy(() => new cborx.Encoder({ structuredClone: true }));
|
|
18
19
|
|
|
19
20
|
|
|
@@ -93,19 +94,7 @@ export async function replaceFunctions(config: {
|
|
|
93
94
|
|
|
94
95
|
export const writeCall = {
|
|
95
96
|
value: async (callSpec: CallSpec, metadata: FunctionMetadata) => {
|
|
96
|
-
|
|
97
|
-
let moduleId = callSpec.ModuleId;
|
|
98
|
-
let callId = callSpec.CallId;
|
|
99
|
-
proxyWatcher.writeOnly({
|
|
100
|
-
canWrite: true,
|
|
101
|
-
eventWrite: true,
|
|
102
|
-
doNotStoreWritesAsPredictions: true,
|
|
103
|
-
// NOTE: We write the call in the present, it is callSpec.runAtTime that is in the past. No one cares
|
|
104
|
-
// when the call was added, only when it wants to run at.
|
|
105
|
-
watchFunction: function writeCall() {
|
|
106
|
-
functionSchema()[domainName].PathFunctionRunner[moduleId].Calls[callId] = atomicObjectWrite(callSpec);
|
|
107
|
-
},
|
|
108
|
-
});
|
|
97
|
+
commitCall(callSpec);
|
|
109
98
|
await pathValueCommitter.waitForValuesToCommit();
|
|
110
99
|
}
|
|
111
100
|
};
|
|
@@ -164,6 +153,14 @@ export function writeFunctionCall(config: {
|
|
|
164
153
|
// this is fine).
|
|
165
154
|
callerIP: "127.0.0.1",
|
|
166
155
|
};
|
|
156
|
+
if (!isNode()) {
|
|
157
|
+
// Get the "setfncfilter" querystring parameter
|
|
158
|
+
let url = new URL(window.location.href);
|
|
159
|
+
let setfncfilter = url.searchParams.get("setfncfilter");
|
|
160
|
+
if (setfncfilter) {
|
|
161
|
+
callSpec.filterable = parseFilterable(setfncfilter);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
167
164
|
|
|
168
165
|
if (curInterceptor) {
|
|
169
166
|
curInterceptor.onCall(callSpec, metadata);
|
|
@@ -19,6 +19,7 @@ import { PERMISSIONS_FUNCTION_ID, getExportPath } from "./syncSchema";
|
|
|
19
19
|
import { formatTime } from "socket-function/src/formatting/format";
|
|
20
20
|
import { set_debug_getFunctionRunnerShards } from "../-g-core-values/NodeCapabilities";
|
|
21
21
|
import { diskLog } from "../diagnostics/logs/diskLogger";
|
|
22
|
+
import { FilterSelector, Filterable, doesMatch } from "../misc/filterable";
|
|
22
23
|
|
|
23
24
|
export const functionSchema = rawSchema<{
|
|
24
25
|
[domainName: string]: {
|
|
@@ -33,6 +34,22 @@ export const functionSchema = rawSchema<{
|
|
|
33
34
|
};
|
|
34
35
|
}>();
|
|
35
36
|
|
|
37
|
+
export function commitCall(call: CallSpec) {
|
|
38
|
+
let domainName = call.DomainName;
|
|
39
|
+
let moduleId = call.ModuleId;
|
|
40
|
+
let callId = call.CallId;
|
|
41
|
+
proxyWatcher.writeOnly({
|
|
42
|
+
canWrite: true,
|
|
43
|
+
eventWrite: true,
|
|
44
|
+
doNotStoreWritesAsPredictions: true,
|
|
45
|
+
// NOTE: We write the call in the present, it is callSpec.runAtTime that is in the past. No one cares
|
|
46
|
+
// when the call was added, only when it wants to run at.
|
|
47
|
+
watchFunction: function writeCall() {
|
|
48
|
+
functionSchema()[domainName].PathFunctionRunner[moduleId].Calls[callId] = atomicObjectWrite(call);
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
36
53
|
export const DEPTH_TO_DATA = 4;
|
|
37
54
|
export const DOMAIN_INDEX = 0;
|
|
38
55
|
export const MODULE_INDEX = 2;
|
|
@@ -66,6 +83,8 @@ export interface CallSpec {
|
|
|
66
83
|
callerMachineId: string;
|
|
67
84
|
callerIP: string;
|
|
68
85
|
runAtTime: Time;
|
|
86
|
+
|
|
87
|
+
filterable?: Filterable;
|
|
69
88
|
}
|
|
70
89
|
export function debugCallSpec(spec: CallSpec): string {
|
|
71
90
|
return `${spec.DomainName}/${spec.ModuleId}/${spec.FunctionId}`;
|
|
@@ -155,6 +174,7 @@ export class PathFunctionRunner {
|
|
|
155
174
|
shardRange: { startFraction: number, endFraction: number };
|
|
156
175
|
secondaryShardRange?: { startFraction: number, endFraction: number };
|
|
157
176
|
PermissionsChecker: PermissionsCheckType | undefined;
|
|
177
|
+
filterSelector?: FilterSelector;
|
|
158
178
|
}) {
|
|
159
179
|
debugFunctionRunnerShards.push({
|
|
160
180
|
domainName: config.domainName,
|
|
@@ -402,6 +422,10 @@ export class PathFunctionRunner {
|
|
|
402
422
|
skipPermissions = PermissionsChecker.skipPermissionsChecks.bind(PermissionsChecker);
|
|
403
423
|
}
|
|
404
424
|
|
|
425
|
+
if (!doesMatch(callPath.filterable, this.config.filterSelector)) {
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
|
|
405
429
|
await this.letIOClear();
|
|
406
430
|
|
|
407
431
|
let secondaryDelay = this.getSecondaryShardDelay(callPath);
|
|
@@ -7,6 +7,7 @@ let yargObj = yargs(process.argv)
|
|
|
7
7
|
.option("shardcenter", { type: "number", default: 0.5, desc: "Center of sharding fraction from 0 to 1" })
|
|
8
8
|
.option("shardsize", { type: "number", default: 1, desc: "Size of sharding fraction from 0 to 1 (ex, shardsize = 0.35 and shardcenter = 0.5 results in sharding from 0.15 to 0.85)" })
|
|
9
9
|
.option("secondarysize", { type: "number", default: 0, desc: "Also shards this size HOWEVER, any values in this range (which are not matched in shardsize) are delayed." })
|
|
10
|
+
.option("filter", { type: "string", default: "", desc: `Filter to only include handle specific function calls (shardsize is still applied). For example, "a&b|c", using regular boolean rules.` })
|
|
10
11
|
.argv
|
|
11
12
|
;
|
|
12
13
|
|
|
@@ -24,6 +25,7 @@ import { timeInMinute } from "socket-function/src/misc";
|
|
|
24
25
|
import { getDomain, isPublic } from "../config";
|
|
25
26
|
import { publishMachineARecords } from "../-e-certs/EdgeCertController";
|
|
26
27
|
import { green } from "socket-function/src/formatting/logColors";
|
|
28
|
+
import { parseFilterSelector } from "../misc/filterable";
|
|
27
29
|
|
|
28
30
|
async function main() {
|
|
29
31
|
|
|
@@ -56,12 +58,15 @@ async function main() {
|
|
|
56
58
|
|
|
57
59
|
console.log(green(`Sharding from ${shardStart} to ${shardEnd}. Fallback sharding (dead function recovery) from ${secondaryStart} to ${secondaryEnd}`));
|
|
58
60
|
|
|
61
|
+
let filterSelector = parseFilterSelector(yargObj.filter);
|
|
62
|
+
|
|
59
63
|
new PathFunctionRunner({
|
|
60
64
|
domainName: getDomain(),
|
|
61
65
|
shardRange: { startFraction: shardStart, endFraction: shardEnd },
|
|
62
66
|
secondaryShardRange: yargObj.secondarysize ? { startFraction: secondaryStart, endFraction: secondaryEnd } : undefined,
|
|
63
67
|
// TODO: Maybe abstract this out even more, so anything can plug in permissions checks?
|
|
64
68
|
PermissionsChecker: PermissionsCheck,
|
|
69
|
+
filterSelector,
|
|
65
70
|
});
|
|
66
71
|
}
|
|
67
72
|
logErrors(main());
|
|
@@ -5,7 +5,7 @@ import { CHILD_CHECK_PREFIX, FunctionMetadata } from "../3-path-functions/syncSc
|
|
|
5
5
|
import { RemoteWatcher, remoteWatcher } from "../1-path-client/RemoteWatcher";
|
|
6
6
|
import { getProxyPath } from "../2-proxy/pathValueProxy";
|
|
7
7
|
import { atomic, atomicObjectWrite, proxyWatcher } from "../2-proxy/PathValueProxyWatcher";
|
|
8
|
-
import { CallSpec, DEPTH_TO_DATA, FunctionSpec, debugCallSpec, functionSchema } from "../3-path-functions/PathFunctionRunner";
|
|
8
|
+
import { CallSpec, DEPTH_TO_DATA, FunctionSpec, commitCall, debugCallSpec, functionSchema } from "../3-path-functions/PathFunctionRunner";
|
|
9
9
|
import { PathValueController } from "../0-path-value-core/PathValueController";
|
|
10
10
|
import { epochTime, MAX_ACCEPTED_CHANGE_AGE, PathValue, pathWatcher, Time, WatchConfig, compareTime, debugTime, getNextTime, MAX_CHANGE_AGE } from "../0-path-value-core/pathValueCore";
|
|
11
11
|
import { IdentityController_getMachineId, IdentityController_getPubKeyShort, IdentityController_getSecureIP } from "../-c-identity/IdentityController";
|
|
@@ -16,7 +16,7 @@ import { blue, green, magenta, red } from "socket-function/src/formatting/logCol
|
|
|
16
16
|
import { PermissionsCheck } from "./permissions";
|
|
17
17
|
import { parseArgs, writeCall } from "../3-path-functions/PathFunctionHelpers";
|
|
18
18
|
import { delay } from "socket-function/src/batching";
|
|
19
|
-
import { isNode, timeInMinute, timeInSecond } from "socket-function/src/misc";
|
|
19
|
+
import { isNode, isNodeTrue, timeInMinute, timeInSecond } from "socket-function/src/misc";
|
|
20
20
|
import { registerResource } from "../diagnostics/trackResources";
|
|
21
21
|
|
|
22
22
|
// Always whitelist preact, as most of the time we want it clientside
|
|
@@ -40,6 +40,17 @@ import { assertIsManagementUser } from "../diagnostics/managementPages";
|
|
|
40
40
|
import { getBrowserUrlNode } from "../-f-node-discovery/NodeDiscovery";
|
|
41
41
|
setFlag(require, "preact", "allowclient", true);
|
|
42
42
|
|
|
43
|
+
import yargs from "yargs";
|
|
44
|
+
import { mergeFilterables, parseFilterable, serializeFilterable } from "../misc/filterable";
|
|
45
|
+
import { isManagementUser } from "../-0-hooks/hooks";
|
|
46
|
+
|
|
47
|
+
let yargObj = isNodeTrue() && yargs(process.argv)
|
|
48
|
+
.option("fncfilter", { type: "string", default: "", desc: `Sets the filterable state for function calls, causing them to target specific FunctionRunners. If no FunctionRunner matches, all functions will fail to run. For example: "devtestserver" will match a FunctionRunner that uses the "devtestserver" filter. Merges with the existing filterable state if a client sets it explicitly.` })
|
|
49
|
+
.argv
|
|
50
|
+
;
|
|
51
|
+
|
|
52
|
+
const getFncFilter = lazy(() => parseFilterable(yargObj.fncfilter));
|
|
53
|
+
|
|
43
54
|
export { Querysub, id };
|
|
44
55
|
|
|
45
56
|
|
|
@@ -501,6 +512,11 @@ export class QuerysubControllerBase {
|
|
|
501
512
|
let callerCreatorId = IdentityController_getPubKeyShort(caller);
|
|
502
513
|
call.callerIP = IdentityController_getSecureIP(caller);
|
|
503
514
|
|
|
515
|
+
if (call.filterable && !await isManagementUser()) {
|
|
516
|
+
throw new Error(`Caller is not a management user, and so does not have permissions to set filterable on calls. Call ${debugCallSpec(call)}, filter was "${serializeFilterable(call.filterable)}"`);
|
|
517
|
+
}
|
|
518
|
+
call.filterable = mergeFilterables([getFncFilter(), call.filterable]);
|
|
519
|
+
|
|
504
520
|
if (Querysub.SIMULATE_LAG) {
|
|
505
521
|
await delay(Querysub.SIMULATE_LAG * 2);
|
|
506
522
|
}
|
|
@@ -558,16 +574,9 @@ export class QuerysubControllerBase {
|
|
|
558
574
|
throw new Error(`Caller does not have permission to call ${call.DomainName}.${call.ModuleId}`);
|
|
559
575
|
}
|
|
560
576
|
|
|
577
|
+
commitCall(call);
|
|
561
578
|
// NOTE: We don't wait for our writes to actually commit, because... there isn't any reason they shouldn't
|
|
562
|
-
// except the server being down, in which case, the client
|
|
563
|
-
proxyWatcher.writeOnly({
|
|
564
|
-
canWrite: true,
|
|
565
|
-
eventWrite: true,
|
|
566
|
-
doNotStoreWritesAsPredictions: true,
|
|
567
|
-
watchFunction: function writeCall() {
|
|
568
|
-
functionSchema()[call.DomainName].PathFunctionRunner[call.ModuleId].Calls[call.CallId] = atomicObjectWrite(call);
|
|
569
|
-
},
|
|
570
|
-
});
|
|
579
|
+
// except the server being down, in which case, the client will gracefully timeout when it doesn't receive the confirmation
|
|
571
580
|
}
|
|
572
581
|
|
|
573
582
|
public async getModulePath(config: {
|
|
@@ -92,6 +92,11 @@ export const addCall = runInSerial(async function addCall(call: CallSpec, metada
|
|
|
92
92
|
if (metadata.nopredict) {
|
|
93
93
|
predict = false;
|
|
94
94
|
}
|
|
95
|
+
|
|
96
|
+
// NOTE: We predict when call.filterable, as filterable usually just means we are testing
|
|
97
|
+
// new functions in development (instead of targeting specific hardware). If we find this
|
|
98
|
+
// is annoying, we could add a flag in filterable that explicitly screens out prediction?
|
|
99
|
+
|
|
95
100
|
let cancel = () => { };
|
|
96
101
|
|
|
97
102
|
if (predict) {
|
|
@@ -23,6 +23,7 @@ import { isTrusted } from "../-d-trust/NetworkTrust2";
|
|
|
23
23
|
import { devDebugbreak, getDomain } from "../config";
|
|
24
24
|
import { getCallWrites } from "../4-querysub/querysubPrediction";
|
|
25
25
|
import { SchemaObject } from "../3-path-functions/syncSchema";
|
|
26
|
+
import * as hooks from "../-0-hooks/hooks";
|
|
26
27
|
|
|
27
28
|
|
|
28
29
|
export const managementPageURL = new URLParam("managementpage", "");
|
|
@@ -134,7 +135,7 @@ export async function registerManagementPages2(config: {
|
|
|
134
135
|
}
|
|
135
136
|
}
|
|
136
137
|
|
|
137
|
-
|
|
138
|
+
hooks.isManagementUser.declare(isManagementUser);
|
|
138
139
|
export async function isManagementUser() {
|
|
139
140
|
let caller = SocketFunction.getCaller();
|
|
140
141
|
let callerMachineId = IdentityController_getMachineId(caller);
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
import { devDebugbreak } from "../config";
|
|
2
|
+
import debugbreak from "debugbreak";
|
|
3
|
+
|
|
4
|
+
export type Filterable = {
|
|
5
|
+
values: string[];
|
|
6
|
+
} | undefined;
|
|
7
|
+
// undefined FilterSelector only matches empty Filterables.
|
|
8
|
+
export type FilterSelector = {
|
|
9
|
+
expression: ASTNode;
|
|
10
|
+
} | undefined;
|
|
11
|
+
|
|
12
|
+
type ASTNode = {
|
|
13
|
+
type: "orList";
|
|
14
|
+
parts: ASTNode[];
|
|
15
|
+
} | {
|
|
16
|
+
type: "andList";
|
|
17
|
+
parts: ASTNode[];
|
|
18
|
+
} | {
|
|
19
|
+
type: "not";
|
|
20
|
+
part: ASTNode;
|
|
21
|
+
} | {
|
|
22
|
+
type: "leaf";
|
|
23
|
+
matcher: FilterMatcher;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type FilterMatcher = {
|
|
27
|
+
type: "includes";
|
|
28
|
+
value: string;
|
|
29
|
+
} | {
|
|
30
|
+
type: "regex";
|
|
31
|
+
value: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export function doesMatch(filterable: Filterable, filterSelector: FilterSelector): boolean {
|
|
35
|
+
if (!filterSelector) {
|
|
36
|
+
return !!filterable;
|
|
37
|
+
}
|
|
38
|
+
return evaluateASTNode(filterSelector.expression, filterable);
|
|
39
|
+
}
|
|
40
|
+
function evaluateASTNode(node: ASTNode, filterable: Filterable): boolean {
|
|
41
|
+
if (node.type === "orList") {
|
|
42
|
+
return node.parts.some(part => evaluateASTNode(part, filterable));
|
|
43
|
+
} else if (node.type === "andList") {
|
|
44
|
+
return node.parts.every(part => evaluateASTNode(part, filterable));
|
|
45
|
+
} else if (node.type === "not") {
|
|
46
|
+
return !evaluateASTNode(node.part, filterable);
|
|
47
|
+
} else if (node.type === "leaf") {
|
|
48
|
+
return evaluateLeaf(node.matcher, filterable);
|
|
49
|
+
} else {
|
|
50
|
+
let unhandled: never = node;
|
|
51
|
+
}
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
function evaluateLeaf(matcher: FilterMatcher, filterable: Filterable): boolean {
|
|
55
|
+
if (!filterable) return false;
|
|
56
|
+
if (matcher.type === "includes") {
|
|
57
|
+
return filterable.values.some(value => value.includes(matcher.value));
|
|
58
|
+
} else if (matcher.type === "regex") {
|
|
59
|
+
return filterable.values.some(value => new RegExp(matcher.value).test(value));
|
|
60
|
+
} else {
|
|
61
|
+
let unhandled: never = matcher;
|
|
62
|
+
}
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function mergeSelectors(selectors: FilterSelector[]): FilterSelector {
|
|
67
|
+
if (selectors.length === 0) return undefined;
|
|
68
|
+
if (selectors.length === 1) return selectors[0];
|
|
69
|
+
let nodes: ASTNode[] = [];
|
|
70
|
+
for (let selector of selectors) {
|
|
71
|
+
if (!selector) continue;
|
|
72
|
+
// Flatten orLists
|
|
73
|
+
if (selector.expression.type === "orList") {
|
|
74
|
+
nodes.push(...selector.expression.parts);
|
|
75
|
+
} else {
|
|
76
|
+
nodes.push(selector.expression);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
expression: {
|
|
81
|
+
type: "orList",
|
|
82
|
+
parts: nodes,
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
export function mergeFilterables(filterables: Filterable[]): Filterable {
|
|
87
|
+
if (filterables.length === 0) return undefined;
|
|
88
|
+
if (filterables.length === 1) return filterables[0];
|
|
89
|
+
let values = Array.from(new Set(filterables.flatMap(filterable => filterable?.values ?? [])));
|
|
90
|
+
return {
|
|
91
|
+
values,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// apple | b & "cats are | funny" | !(d | elephant)
|
|
96
|
+
// " at the start and end allows using special characters in a value
|
|
97
|
+
// "" inside of " is parsed as a single "
|
|
98
|
+
// The values are trimmed
|
|
99
|
+
export function parseFilterSelector(filterSelector: string): FilterSelector {
|
|
100
|
+
if (!filterSelector.trim()) return undefined;
|
|
101
|
+
|
|
102
|
+
let pos = 0;
|
|
103
|
+
const input = filterSelector;
|
|
104
|
+
|
|
105
|
+
function parseValue(): string {
|
|
106
|
+
let value = "";
|
|
107
|
+
if (input[pos] === "\"") {
|
|
108
|
+
pos++; // Skip opening quote
|
|
109
|
+
while (pos < input.length) {
|
|
110
|
+
if (input[pos] === "\"") {
|
|
111
|
+
if (input[pos + 1] === "\"") {
|
|
112
|
+
value += "\"";
|
|
113
|
+
pos += 2;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
pos++; // Skip closing quote
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
value += input[pos];
|
|
120
|
+
pos++;
|
|
121
|
+
}
|
|
122
|
+
} else {
|
|
123
|
+
while (pos < input.length && !/[\s|&!()]/.test(input[pos])) {
|
|
124
|
+
value += input[pos];
|
|
125
|
+
pos++;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return value.trim();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function skipWhitespace() {
|
|
132
|
+
while (pos < input.length && /\s/.test(input[pos])) pos++;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function parseExpression(): ASTNode {
|
|
136
|
+
skipWhitespace();
|
|
137
|
+
|
|
138
|
+
if (pos >= input.length) {
|
|
139
|
+
throw new Error(`Expected expression at position ${pos} but reached end of input`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Handle NOT
|
|
143
|
+
if (input[pos] === "!") {
|
|
144
|
+
pos++;
|
|
145
|
+
skipWhitespace();
|
|
146
|
+
|
|
147
|
+
// Support both (!x) and !(x) forms
|
|
148
|
+
const needsClosingParen = input[pos] === "(";
|
|
149
|
+
if (needsClosingParen) {
|
|
150
|
+
pos++;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const expr = parseOrExpression();
|
|
154
|
+
skipWhitespace();
|
|
155
|
+
|
|
156
|
+
if (needsClosingParen) {
|
|
157
|
+
if (input[pos] !== ")") {
|
|
158
|
+
throw new Error(`Expected ")" at position ${pos}, found "${input[pos]}"`);
|
|
159
|
+
}
|
|
160
|
+
pos++;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return { type: "not", part: expr };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Handle parentheses
|
|
167
|
+
if (input[pos] === "(") {
|
|
168
|
+
pos++;
|
|
169
|
+
const expr = parseOrExpression();
|
|
170
|
+
skipWhitespace();
|
|
171
|
+
if (input[pos] !== ")") {
|
|
172
|
+
throw new Error(`Expected ")" at position ${pos}, found "${input[pos]}"`);
|
|
173
|
+
}
|
|
174
|
+
pos++;
|
|
175
|
+
return expr;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Handle leaf node (simple value)
|
|
179
|
+
const value = parseValue();
|
|
180
|
+
if (!value) {
|
|
181
|
+
throw new Error(`Expected value at position ${pos}`);
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
type: "leaf",
|
|
185
|
+
matcher: { type: "includes", value }
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function parseAndExpression(): ASTNode {
|
|
190
|
+
let left = parseExpression();
|
|
191
|
+
skipWhitespace();
|
|
192
|
+
|
|
193
|
+
const parts: ASTNode[] = [left];
|
|
194
|
+
|
|
195
|
+
while (pos < input.length && input[pos] === "&") {
|
|
196
|
+
pos++;
|
|
197
|
+
skipWhitespace();
|
|
198
|
+
parts.push(parseExpression());
|
|
199
|
+
skipWhitespace();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (parts.length === 1) return parts[0];
|
|
203
|
+
return { type: "andList", parts };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function parseOrExpression(): ASTNode {
|
|
207
|
+
let left = parseAndExpression();
|
|
208
|
+
skipWhitespace();
|
|
209
|
+
|
|
210
|
+
const parts: ASTNode[] = [left];
|
|
211
|
+
|
|
212
|
+
while (pos < input.length && input[pos] === "|") {
|
|
213
|
+
pos++;
|
|
214
|
+
skipWhitespace();
|
|
215
|
+
parts.push(parseAndExpression());
|
|
216
|
+
skipWhitespace();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (parts.length === 1) return parts[0];
|
|
220
|
+
return { type: "orList", parts };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
const expression = parseOrExpression();
|
|
225
|
+
skipWhitespace();
|
|
226
|
+
|
|
227
|
+
if (pos < input.length) {
|
|
228
|
+
throw new Error(`Unexpected character "${input[pos]}" at position ${pos}`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return { expression };
|
|
232
|
+
} catch (error: any) {
|
|
233
|
+
throw new Error(`Failed to parse filter selector "${filterSelector}": ${error.message}`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
export function serializeFilterSelector(filterSelector: FilterSelector): string {
|
|
237
|
+
if (!filterSelector) return "";
|
|
238
|
+
|
|
239
|
+
function serializeASTNode(node: ASTNode, parentPrecedence = 0): string {
|
|
240
|
+
// Precedence: OR = 1, AND = 2, NOT = 3, LEAF = 4
|
|
241
|
+
let result: string;
|
|
242
|
+
let currentPrecedence: number;
|
|
243
|
+
|
|
244
|
+
if (node.type === "orList") {
|
|
245
|
+
currentPrecedence = 1;
|
|
246
|
+
result = node.parts.map(part => serializeASTNode(part, currentPrecedence)).join(" | ");
|
|
247
|
+
} else if (node.type === "andList") {
|
|
248
|
+
currentPrecedence = 2;
|
|
249
|
+
result = node.parts.map(part => serializeASTNode(part, currentPrecedence)).join(" & ");
|
|
250
|
+
} else if (node.type === "not") {
|
|
251
|
+
currentPrecedence = 3;
|
|
252
|
+
if (
|
|
253
|
+
(node.part.type === "andList" || node.part.type === "orList") && node.part.parts.length === 1
|
|
254
|
+
|| node.part.type === "leaf"
|
|
255
|
+
) {
|
|
256
|
+
result = `!${serializeASTNode(node.part, 0)}`;
|
|
257
|
+
} else {
|
|
258
|
+
result = `!(${serializeASTNode(node.part, 0)})`;
|
|
259
|
+
}
|
|
260
|
+
} else if (node.type === "leaf") {
|
|
261
|
+
currentPrecedence = 4;
|
|
262
|
+
const value = node.matcher.value;
|
|
263
|
+
// If the value contains special characters, wrap it in quotes
|
|
264
|
+
if (/[\s|&!()]/.test(value)) {
|
|
265
|
+
// Escape quotes by doubling them
|
|
266
|
+
result = `"${value.replace(/"/g, "\"\"")}"`;
|
|
267
|
+
} else {
|
|
268
|
+
result = value;
|
|
269
|
+
}
|
|
270
|
+
} else {
|
|
271
|
+
let unhandled: never = node;
|
|
272
|
+
return "";
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Add parentheses if our precedence is lower than parent's
|
|
276
|
+
if (currentPrecedence < parentPrecedence) {
|
|
277
|
+
result = `(${result})`;
|
|
278
|
+
}
|
|
279
|
+
return result;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return serializeASTNode(filterSelector.expression);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// a | b => { values: ["a", "b"] }
|
|
286
|
+
export function parseFilterable(filterable: string): Filterable {
|
|
287
|
+
if (!filterable.trim()) return undefined;
|
|
288
|
+
let values = filterable.split("|").map(value => value.trim());
|
|
289
|
+
return {
|
|
290
|
+
values,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
export function serializeFilterable(filterable: Filterable): string {
|
|
294
|
+
if (!filterable) return "";
|
|
295
|
+
return filterable.values.join("|");
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function main() {
|
|
299
|
+
function assert(cond: () => boolean, message?: string) {
|
|
300
|
+
if (!cond()) throw new Error(`Failed for ${cond.toString()}${message ? `: ${message}` : ""}`);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function testFilter(input: string, matches: string[][], nonMatches: string[][]) {
|
|
304
|
+
console.log(`\nTesting filter: ${input}`);
|
|
305
|
+
const filter = parseFilterSelector(input);
|
|
306
|
+
const serialized = serializeFilterSelector(filter);
|
|
307
|
+
console.log("Serialized:", serialized);
|
|
308
|
+
|
|
309
|
+
// Test that parsing the serialized version gives same result
|
|
310
|
+
const reparsed = parseFilterSelector(serialized);
|
|
311
|
+
assert(() => JSON.stringify(filter) === JSON.stringify(reparsed),
|
|
312
|
+
"Serialized form should parse to equivalent AST");
|
|
313
|
+
|
|
314
|
+
// Test positive matches
|
|
315
|
+
for (const values of matches) {
|
|
316
|
+
assert(() => doesMatch({ values }, filter),
|
|
317
|
+
`Should match values: [${values.join(", ")}]`);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Test negative matches
|
|
321
|
+
for (const values of nonMatches) {
|
|
322
|
+
assert(() => !doesMatch({ values }, filter),
|
|
323
|
+
`Should NOT match values: [${values.join(", ")}]`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Test basic operators
|
|
328
|
+
testFilter("a",
|
|
329
|
+
[["a"], ["ab"], ["a", "b"]],
|
|
330
|
+
[["b"], ["c"], []]);
|
|
331
|
+
|
|
332
|
+
testFilter("a & b",
|
|
333
|
+
[["a", "b"], ["ab", "b"], ["a", "ab"]],
|
|
334
|
+
[["a"], ["b"], ["c", "d"], []]);
|
|
335
|
+
|
|
336
|
+
testFilter("a | b",
|
|
337
|
+
[["a"], ["b"], ["a", "b"], ["ab"]],
|
|
338
|
+
[["c"], ["d"], []]);
|
|
339
|
+
|
|
340
|
+
// Test NOT operator
|
|
341
|
+
testFilter("!a",
|
|
342
|
+
[["b"], ["c"], ["b", "c"]],
|
|
343
|
+
[["a"], ["a", "b"]]);
|
|
344
|
+
|
|
345
|
+
testFilter("!(a | b)",
|
|
346
|
+
[["c"], ["d"], ["c", "d"]],
|
|
347
|
+
[["a"], ["b"], ["a", "c"], ["b", "d"]]);
|
|
348
|
+
|
|
349
|
+
// Test operator precedence
|
|
350
|
+
testFilter("a & b | c",
|
|
351
|
+
[["a", "b"], ["c"], ["a", "b", "c"]],
|
|
352
|
+
[["a"], ["b"]]);
|
|
353
|
+
|
|
354
|
+
testFilter("a | b & c",
|
|
355
|
+
[["a"], ["b", "c"], ["a", "b", "c"]],
|
|
356
|
+
[["b"], ["c"], ["b"]]);
|
|
357
|
+
|
|
358
|
+
// Test complex expressions
|
|
359
|
+
testFilter("(a | b & c) & !d",
|
|
360
|
+
[["a"], ["b", "c"], ["a", "b", "c"]],
|
|
361
|
+
[["a", "d"], ["b", "c", "d"], ["d"]]);
|
|
362
|
+
|
|
363
|
+
// Test quoted strings and special characters
|
|
364
|
+
testFilter("\"hello world\" & !\"good bye\"",
|
|
365
|
+
[["hello world"], ["hello world", "hi"]],
|
|
366
|
+
[["good bye"], ["hello world", "good bye"]]);
|
|
367
|
+
|
|
368
|
+
testFilter("\"a & b\" | \"c | d\"",
|
|
369
|
+
[["a & b"], ["c | d"], ["a & b", "e"]],
|
|
370
|
+
[["a"], ["b"], ["c"], ["d"]]);
|
|
371
|
+
|
|
372
|
+
// Test empty and undefined cases
|
|
373
|
+
assert(() => !doesMatch(undefined, undefined), "undefined should not match undefined");
|
|
374
|
+
assert(() => !doesMatch(undefined, parseFilterSelector("a")), "undefined should not match filter");
|
|
375
|
+
assert(() => doesMatch({ values: ["a"] }, undefined), "non-empty values should match undefined");
|
|
376
|
+
|
|
377
|
+
// Test mergeSelectors
|
|
378
|
+
const merged = mergeSelectors([
|
|
379
|
+
parseFilterSelector("a & b"),
|
|
380
|
+
parseFilterSelector("c | d"),
|
|
381
|
+
undefined,
|
|
382
|
+
parseFilterSelector("!e")
|
|
383
|
+
]);
|
|
384
|
+
assert(() => doesMatch({ values: ["a", "b"] }, merged), "should match first selector");
|
|
385
|
+
assert(() => doesMatch({ values: ["c"] }, merged), "should match second selector");
|
|
386
|
+
assert(() => doesMatch({ values: ["d"] }, merged), "should match second selector alternative");
|
|
387
|
+
assert(() => !doesMatch({ values: ["e"] }, merged), "should respect NOT in merged selectors");
|
|
388
|
+
|
|
389
|
+
console.log("All tests passed!");
|
|
390
|
+
}
|
|
391
|
+
//main().catch(console.error).finally(() => process.exit(0));
|