this repo has no description
1type QueryClause = {
2 match?: Record<string, unknown>;
3 term?: Record<string, unknown>;
4 bool?: {
5 must?: QueryClause[];
6 must_not?: QueryClause[];
7 should?: QueryClause[];
8 filter?: QueryClause[];
9 };
10 range?: Record<string, Record<string, unknown>>;
11 nested?: {
12 path: string;
13 query: QueryClause;
14 };
15 exists?: {
16 field: string;
17 };
18};
19
20// function getField(obj: Record<string, any>, path: string): any {
21// return path.split('.').reduce((acc, part) => acc && acc[part], obj);
22// }
23
24export function getSafeField(obj: Record<string, any>, path: string): any {
25 if (obj && typeof obj === 'object' && Object.prototype.hasOwnProperty.call(obj, path)) {
26 return obj[path];
27 }
28 return path.split('.').reduce((acc, part) => acc?.[part], obj);
29}
30
31export function matchDocAgainstQuery(doc: Record<string, unknown>, query: QueryClause): boolean {
32 console.log(JSON.stringify(doc, null, 2));
33 if (!query || Object.keys(query).length === 0) {
34 console.log("✅ Accepted: No query provided, accepting all docs.");
35 return true;
36 }
37
38 for (const key in query) {
39 const clauseValue = (query as any)[key];
40
41 switch (key) {
42 case 'term': {
43 const [field, value] = Object.entries(clauseValue as object)[0];
44 const docValue = getSafeField(doc, field);
45 if (docValue !== value) {
46 console.log(`❌ Rejected: term mismatch on field '${field}', expected '${value}', got '${docValue}'`);
47 return false;
48 }
49 console.log(`✅ Passed: term match on field '${field}' with value '${value}'`);
50 break;
51 }
52
53 case 'terms': {
54 const [field, queryValues] = Object.entries(clauseValue as object)[0];
55 if (!Array.isArray(queryValues)) return false;
56
57 const docValue = getSafeField(doc, field);
58 if (docValue === undefined) return false;
59
60 if (Array.isArray(docValue)) {
61 return docValue.some(v => queryValues.includes(v));
62 } else {
63 return queryValues.includes(docValue);
64 }
65 }
66
67 case 'match': {
68 const [field, value] = Object.entries(clauseValue as object)[0];
69 const fieldValue = getSafeField(doc, field);
70 if (typeof fieldValue !== 'string') {
71 console.log(`❌ Rejected: match expected string field '${field}', got non-string:`, fieldValue);
72 return false;
73 }
74 if (!fieldValue.includes(String(value))) {
75 console.log(`❌ Rejected: match failed on field '${field}', expected to include '${value}', got '${fieldValue}'`);
76 return false;
77 }
78 console.log(`✅ Passed: match succeeded on field '${field}' includes '${value}'`);
79 break;
80 }
81
82 case 'bool': {
83 const boolQuery = clauseValue as QueryClause['bool'];
84
85 if (boolQuery?.must?.some(q => !matchDocAgainstQuery(doc, q))) {
86 console.log("❌ Rejected: bool.must clause failed.");
87 return false;
88 }
89 if (boolQuery?.filter?.some(q => !matchDocAgainstQuery(doc, q))) {
90 console.log("❌ Rejected: bool.filter clause failed.");
91 return false;
92 }
93 if (boolQuery?.must_not?.some(q => matchDocAgainstQuery(doc, q))) {
94 console.log("❌ Rejected: bool.must_not clause matched (should not have).");
95 return false;
96 }
97 if (boolQuery?.should?.length && !boolQuery.should.some(q => matchDocAgainstQuery(doc, q))) {
98 console.log("❌ Rejected: bool.should clause did not match any.");
99 return false;
100 }
101 console.log("✅ Passed: bool clause matched.");
102 break;
103 }
104
105 case 'range': {
106 const [field, range] = Object.entries(clauseValue as object)[0];
107 const value = getSafeField(doc, field);
108 if (value === undefined) {
109 console.log(`❌ Rejected: range field '${field}' is undefined in doc.`);
110 return false;
111 }
112
113 for (const [op, opValue] of Object.entries(range as object)) {
114 const numValue = Number(value);
115 const numOpValue = Number(opValue);
116 const isNumeric = !isNaN(numValue) && !isNaN(numOpValue);
117 let passed = true;
118
119 switch (op) {
120 case 'gt':
121 passed = isNumeric ? numValue > numOpValue : String(value) > String(opValue);
122 break;
123 case 'gte':
124 passed = isNumeric ? numValue >= numOpValue : String(value) >= String(opValue);
125 break;
126 case 'lt':
127 passed = isNumeric ? numValue < numOpValue : String(value) < String(opValue);
128 break;
129 case 'lte':
130 passed = isNumeric ? numValue <= numOpValue : String(value) <= String(opValue);
131 break;
132 }
133
134 if (!passed) {
135 console.log(`❌ Rejected: range.${op} failed on field '${field}', value '${value}' vs '${opValue}'`);
136 return false;
137 }
138 }
139
140 console.log(`✅ Passed: range match on field '${field}'`);
141 break;
142 }
143
144 case 'exists': {
145 const field = (clauseValue as { field: string }).field;
146 if (getSafeField(doc, field) === undefined) {
147 console.log(`❌ Rejected: exists failed, field '${field}' not found`);
148 return false;
149 }
150 console.log(`✅ Passed: exists matched, field '${field}' exists`);
151 break;
152 }
153
154 default:
155 console.log(`ℹ️ Ignored unknown query clause: '${key}'`);
156 break;
157 }
158 }
159
160 console.log("✅ Accepted: Document matched all clauses.");
161 return true;
162}
163
164const ALLOWED_QUERY_CLAUSES = new Set([
165 'term',
166 'terms',
167 'bool',
168 'range',
169 'exists',
170]);
171
172const FORBIDDEN_QUERY_CLAUSES = new Set([
173 'match',
174 'multi_match',
175 'match_phrase',
176 'query_string',
177 'simple_query_string',
178 'fuzzy',
179 'script',
180 'function_score',
181 'more_like_this',
182 'percolate',
183]);
184
185function validateClause(clause: Record<string, unknown>) {
186 for (const key in clause) {
187 if (FORBIDDEN_QUERY_CLAUSES.has(key)) {
188 throw new Error(`Query clause '${key}' is not allowed for live sync due to non-deterministic behavior. Please use deterministic clauses like 'term' or 'range'.`);
189 }
190
191 if (!ALLOWED_QUERY_CLAUSES.has(key)) {
192 throw new Error(`Query clause '${key}' is not supported for live sync.`);
193 }
194
195 const value = clause[key];
196 if (key === 'bool' && typeof value === 'object' && value !== null) {
197 const boolClauses = value as Record<string, unknown[]>;
198 for (const boolType of ['must', 'filter', 'should', 'must_not']) {
199 if (Array.isArray(boolClauses[boolType])) {
200 for (const subClause of boolClauses[boolType]) {
201 if (typeof subClause === 'object' && subClause !== null) {
202 validateClause(subClause as Record<string, unknown>);
203 }
204 }
205 }
206 }
207 }
208 }
209}
210
211export function validateLiveQuery(esQuery: Record<string, unknown>): void {
212 // a query must have an explicit 'sort' clause
213 // which prevents non-deterministic sorting (bad)
214 const sortClause = esQuery.sort as any[];
215 if (!Array.isArray(sortClause) || sortClause.length === 0) {
216 throw new Error("Live queries must include an explicit 'sort' clause for deterministic ordering (e.g., sort: [{ '$metadata.indexedAt': 'desc' }]).");
217 }
218
219 // and also of the supported deterministic clauses (no text searches)
220 const queryPart = esQuery.query as Record<string, unknown>;
221 if (queryPart) {
222 validateClause(queryPart);
223 }
224}