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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "querysub",
3
- "version": "0.93.0",
3
+ "version": "0.95.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",
@@ -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
- let domainName = callSpec.DomainName;
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 can figure that out anyways?
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));