@aglyn/shared-ui-jsx 1.0.0-beta.198 → 1.0.0-beta.199

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.
@@ -0,0 +1,624 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ import { listFilterDay, listFilterOperators } from "./list-filter.js";
17
+ /** The document id, as a path. */ export const LIST_QUERY_ID_PATH = '__name__';
18
+ /** Firestore's cap on disjunctions — `in` values times `array-contains-any` values. */ export const LIST_QUERY_DISJUNCTIONS = 30;
19
+ /** A very high private-use codepoint: `[p, p + HIGH]` spans every string starting `p`. */ const HIGH = '\uf8ff';
20
+ const csv = (raw)=>raw.split(',').map((entry)=>entry.trim()).filter(Boolean);
21
+ const dayBounds = (raw)=>{
22
+ const start = listFilterDay(raw);
23
+ if (Number.isNaN(start.getTime())) return null;
24
+ const day = new Date(start);
25
+ day.setHours(0, 0, 0, 0);
26
+ const end = new Date(day);
27
+ end.setDate(end.getDate() + 1);
28
+ return {
29
+ start: day,
30
+ end
31
+ };
32
+ };
33
+ /** The order a range over `path` puts the list in. */ const rangeOrder = (declaration, field, path)=>{
34
+ const offered = declaration.sorts.find((sort)=>sort.path === path);
35
+ if (offered) return offered;
36
+ return {
37
+ path,
38
+ direction: (field == null ? void 0 : field.kind) === 'date' ? 'desc' : 'asc'
39
+ };
40
+ };
41
+ /**
42
+ * One clause as predicates, or the reason it has none. The same operator
43
+ * meanings as the AGL-2501 twins (`applyListFilter`, `listFilterConstraints`),
44
+ * which this replaces for a list that composes clauses.
45
+ */ function shapeClause(field, input, names) {
46
+ var _input_value;
47
+ if (field.windowOnly) return 'this field is not stored where a query can reach it';
48
+ if (!listFilterOperators(field).includes(input.op)) {
49
+ return `${input.op} is not something this list can ask of ${field.column}`;
50
+ }
51
+ const raw = ((_input_value = input.value) != null ? _input_value : '').trim();
52
+ const op = input.op;
53
+ if (op === 'isEmpty') {
54
+ if (field.presence !== 'nullable') return 'only a field stored as null can be asked for empty';
55
+ return {
56
+ filters: [
57
+ {
58
+ path: field.path,
59
+ op: '==',
60
+ value: null
61
+ }
62
+ ]
63
+ };
64
+ }
65
+ if (op === 'isNotEmpty') {
66
+ if (field.presence === 'always') return 'this field is never empty';
67
+ return {
68
+ filters: [
69
+ {
70
+ path: field.path,
71
+ op: '!=',
72
+ value: null
73
+ }
74
+ ],
75
+ inequality: field.path
76
+ };
77
+ }
78
+ if (field.kind === 'boolean') {
79
+ if (raw !== 'true' && raw !== 'false') return 'pick true or false';
80
+ return {
81
+ filters: [
82
+ {
83
+ path: field.path,
84
+ op: '==',
85
+ value: raw === 'true'
86
+ }
87
+ ]
88
+ };
89
+ }
90
+ if (!raw) return 'no value yet';
91
+ if (field.keysOf) {
92
+ if (op !== 'equals' || !/^[A-Za-z0-9_]+$/.test(raw)) return 'pick one of the choices';
93
+ return {
94
+ filters: [
95
+ {
96
+ path: `${field.path}.${raw}`,
97
+ op: '==',
98
+ value: true
99
+ }
100
+ ]
101
+ };
102
+ }
103
+ if (field.kind === 'text') {
104
+ if (op === 'contains' && field.tokensPath) {
105
+ const token = field.verbatimTokens ? raw : names.token(raw);
106
+ if (!token) return 'no value yet';
107
+ return {
108
+ filters: [
109
+ {
110
+ path: field.tokensPath,
111
+ op: 'array-contains',
112
+ value: token
113
+ }
114
+ ],
115
+ array: true
116
+ };
117
+ }
118
+ if (op === 'equals' && field.lowerPath) {
119
+ return {
120
+ filters: [
121
+ {
122
+ path: field.lowerPath,
123
+ op: '==',
124
+ value: names.key(raw)
125
+ }
126
+ ]
127
+ };
128
+ }
129
+ if (op === 'equals' && field.presence === 'always' && !field.lowerPath) {
130
+ return {
131
+ filters: [
132
+ {
133
+ path: field.path,
134
+ op: '==',
135
+ value: raw
136
+ }
137
+ ]
138
+ };
139
+ }
140
+ if (op === 'startsWith' && field.lowerPath) {
141
+ const key = names.key(raw);
142
+ return {
143
+ filters: [
144
+ {
145
+ path: field.lowerPath,
146
+ op: '>=',
147
+ value: key
148
+ },
149
+ {
150
+ path: field.lowerPath,
151
+ op: '<=',
152
+ value: `${key}${HIGH}`
153
+ }
154
+ ],
155
+ inequality: field.lowerPath
156
+ };
157
+ }
158
+ if (op === 'endsWith' && field.reversedPath) {
159
+ const key = names.reversed(raw);
160
+ return {
161
+ filters: [
162
+ {
163
+ path: field.reversedPath,
164
+ op: '>=',
165
+ value: key
166
+ },
167
+ {
168
+ path: field.reversedPath,
169
+ op: '<=',
170
+ value: `${key}${HIGH}`
171
+ }
172
+ ],
173
+ inequality: field.reversedPath
174
+ };
175
+ }
176
+ if (op === 'isAnyOf' && field.lowerPath) {
177
+ const values = csv(raw).map((value)=>names.key(value));
178
+ if (values.length > LIST_QUERY_DISJUNCTIONS) return `at most ${LIST_QUERY_DISJUNCTIONS} values`;
179
+ return {
180
+ filters: [
181
+ {
182
+ path: field.lowerPath,
183
+ op: 'in',
184
+ value: values
185
+ }
186
+ ],
187
+ disjunctions: values.length
188
+ };
189
+ }
190
+ return `${op} is not something this list can ask of ${field.column}`;
191
+ }
192
+ if (field.kind === 'id') {
193
+ if (op === 'equals') return {
194
+ filters: [
195
+ {
196
+ path: LIST_QUERY_ID_PATH,
197
+ op: '==',
198
+ value: raw
199
+ }
200
+ ]
201
+ };
202
+ if (op === 'isAnyOf') {
203
+ const values = csv(raw);
204
+ if (values.length > LIST_QUERY_DISJUNCTIONS) return `at most ${LIST_QUERY_DISJUNCTIONS} values`;
205
+ return {
206
+ filters: [
207
+ {
208
+ path: LIST_QUERY_ID_PATH,
209
+ op: 'in',
210
+ value: values
211
+ }
212
+ ],
213
+ disjunctions: values.length
214
+ };
215
+ }
216
+ if (op === 'startsWith') {
217
+ return {
218
+ filters: [
219
+ {
220
+ path: LIST_QUERY_ID_PATH,
221
+ op: '>=',
222
+ value: raw
223
+ },
224
+ {
225
+ path: LIST_QUERY_ID_PATH,
226
+ op: '<=',
227
+ value: `${raw}${HIGH}`
228
+ }
229
+ ],
230
+ inequality: LIST_QUERY_ID_PATH
231
+ };
232
+ }
233
+ return `${op} is not something this list can ask of ${field.column}`;
234
+ }
235
+ if (field.kind === 'exact') {
236
+ if (op === 'equals') return {
237
+ filters: [
238
+ {
239
+ path: field.path,
240
+ op: '==',
241
+ value: raw
242
+ }
243
+ ]
244
+ };
245
+ if (op === 'isAnyOf') {
246
+ const values = csv(raw);
247
+ if (values.length > LIST_QUERY_DISJUNCTIONS) return `at most ${LIST_QUERY_DISJUNCTIONS} values`;
248
+ if (field.tokensPath) {
249
+ // An array field asked "any of these": one array clause.
250
+ return {
251
+ filters: [
252
+ {
253
+ path: field.tokensPath,
254
+ op: 'array-contains-any',
255
+ value: values
256
+ }
257
+ ],
258
+ array: true,
259
+ disjunctions: values.length
260
+ };
261
+ }
262
+ return {
263
+ filters: [
264
+ {
265
+ path: field.path,
266
+ op: 'in',
267
+ value: values
268
+ }
269
+ ],
270
+ disjunctions: values.length
271
+ };
272
+ }
273
+ if (op === 'contains' && field.tokensPath) {
274
+ return {
275
+ filters: [
276
+ {
277
+ path: field.tokensPath,
278
+ op: 'array-contains',
279
+ value: raw
280
+ }
281
+ ],
282
+ array: true
283
+ };
284
+ }
285
+ return `${op} is not something this list can ask of ${field.column}`;
286
+ }
287
+ if (field.kind === 'number') {
288
+ const value = Number(raw);
289
+ if (!Number.isFinite(value)) return 'type a number';
290
+ if (op === '=') return {
291
+ filters: [
292
+ {
293
+ path: field.path,
294
+ op: '==',
295
+ value
296
+ }
297
+ ]
298
+ };
299
+ const comparison = {
300
+ '!=': '!=',
301
+ '>': '>',
302
+ '>=': '>=',
303
+ '<': '<',
304
+ '<=': '<='
305
+ };
306
+ const found = comparison[op];
307
+ if (!found) return `${op} is not something this list can ask of ${field.column}`;
308
+ return {
309
+ filters: [
310
+ {
311
+ path: field.path,
312
+ op: found,
313
+ value
314
+ }
315
+ ],
316
+ inequality: field.path
317
+ };
318
+ }
319
+ if (field.kind === 'date') {
320
+ const day = dayBounds(raw);
321
+ if (!day) return 'pick a date';
322
+ // A `…AtMs` field is compared as the number it is stored as.
323
+ const at = (date)=>field.storedAs === 'millis' ? date.getTime() : date;
324
+ if (op === 'is') {
325
+ return {
326
+ filters: [
327
+ {
328
+ path: field.path,
329
+ op: '>=',
330
+ value: at(day.start)
331
+ },
332
+ {
333
+ path: field.path,
334
+ op: '<',
335
+ value: at(day.end)
336
+ }
337
+ ],
338
+ inequality: field.path
339
+ };
340
+ }
341
+ const bound = {
342
+ after: [
343
+ '>=',
344
+ day.end
345
+ ],
346
+ onOrAfter: [
347
+ '>=',
348
+ day.start
349
+ ],
350
+ before: [
351
+ '<',
352
+ day.start
353
+ ],
354
+ onOrBefore: [
355
+ '<',
356
+ day.end
357
+ ]
358
+ };
359
+ const found = bound[op];
360
+ if (!found) return `${op} is not something this list can ask of ${field.column}`;
361
+ return {
362
+ filters: [
363
+ {
364
+ path: field.path,
365
+ op: found[0],
366
+ value: at(found[1])
367
+ }
368
+ ],
369
+ inequality: field.path
370
+ };
371
+ }
372
+ return `${op} is not something this list can ask of ${field.column}`;
373
+ }
374
+ const disjunctionsOf = (filter)=>filter.op === 'in' || filter.op === 'array-contains-any' ? Math.max(1, Array.isArray(filter.value) ? filter.value.length : 1) : 1;
375
+ /**
376
+ * Every clause and the search, as ONE query — or as much of it as one query
377
+ * can hold, with the rest refused by name.
378
+ *
379
+ * Order of claim: the base (the list's scope) first, then the search, then
380
+ * the clauses in the order they were added. A clause that would be a second
381
+ * array clause, a second inequality field or push the disjunctions past
382
+ * thirty is refused, and the list shows the refusal beside its chip; the
383
+ * clauses before it stand. The search is claimed before the clauses because
384
+ * it is the thing a reader typed last and looks at.
385
+ */ export function planListQuery(declaration, request, names) {
386
+ var _request_base, _ref, _request_search, _ref1;
387
+ var _filters_find;
388
+ const filters = [
389
+ ...(_request_base = request.base) != null ? _request_base : []
390
+ ];
391
+ const served = [];
392
+ const refused = [];
393
+ const notices = [];
394
+ let arrayTaken = filters.some((filter)=>filter.op === 'array-contains' || filter.op === 'array-contains-any');
395
+ let inequality = (_ref = (_filters_find = filters.find((filter)=>[
396
+ '!=',
397
+ '<',
398
+ '<=',
399
+ '>',
400
+ '>='
401
+ ].includes(filter.op))) == null ? void 0 : _filters_find.path) != null ? _ref : null;
402
+ let disjunctions = filters.reduce((product, filter)=>product * disjunctionsOf(filter), 1);
403
+ // The search.
404
+ let searched = null;
405
+ const words = ((_request_search = request.search) != null ? _request_search : []).map((word)=>word.trim()).filter(Boolean);
406
+ if (words.length) {
407
+ const search = declaration.search;
408
+ const token = names.token(words.join(' '));
409
+ if (!search) {
410
+ refused.push({
411
+ clause: 'search',
412
+ reason: 'this list has no search'
413
+ });
414
+ } else if (!token) {
415
+ // Nothing searchable was typed.
416
+ } else {
417
+ const scopeAt = filters.findIndex((filter)=>filter.op === 'array-contains-any' || filter.op === 'array-contains');
418
+ if (scopeAt === -1) {
419
+ filters.push({
420
+ path: search.tokensPath,
421
+ op: 'array-contains',
422
+ value: token
423
+ });
424
+ arrayTaken = true;
425
+ searched = token;
426
+ } else if (search.scoped) {
427
+ // Fold the search into the scope: one array clause answers both.
428
+ const scope = filters[scopeAt];
429
+ const scopes = Array.isArray(scope.value) ? scope.value : [
430
+ scope.value
431
+ ];
432
+ filters.splice(scopeAt, 1, {
433
+ path: search.scoped.tokensPath,
434
+ op: 'array-contains-any',
435
+ value: scopes.map((entry)=>{
436
+ var _search_scoped;
437
+ return `${String(entry)}${(_search_scoped = search.scoped) == null ? void 0 : _search_scoped.join}${token}`;
438
+ })
439
+ });
440
+ searched = token;
441
+ } else {
442
+ refused.push({
443
+ clause: 'search',
444
+ reason: 'this list is already narrowed to what you can see, which search cannot combine with'
445
+ });
446
+ }
447
+ if (searched && words.length > 1) {
448
+ notices.push(`Search matches one word at a time: showing results for "${token}".`);
449
+ }
450
+ if (searched && names.key(words[0]).length > names.maxPrefix) {
451
+ notices.push(`Search reads the first ${names.maxPrefix} letters of a word.`);
452
+ }
453
+ }
454
+ }
455
+ // The clauses.
456
+ for (const clause of request.clauses){
457
+ const field = declaration.fields.find((entry)=>entry.column === clause.field);
458
+ if (!field) {
459
+ refused.push({
460
+ clause,
461
+ reason: 'this list does not filter by that'
462
+ });
463
+ continue;
464
+ }
465
+ const shape = shapeClause(field, clause, names);
466
+ if (typeof shape === 'string') {
467
+ refused.push({
468
+ clause,
469
+ reason: shape
470
+ });
471
+ continue;
472
+ }
473
+ if (shape.array && arrayTaken) {
474
+ refused.push({
475
+ clause,
476
+ reason: searched ? 'cannot be combined with the search — clear the search to use it' : 'cannot be combined with another "contains" or "any of" filter on a list'
477
+ });
478
+ continue;
479
+ }
480
+ if (shape.inequality && inequality && shape.inequality !== inequality) {
481
+ refused.push({
482
+ clause,
483
+ reason: 'only one range (dates, numbers, starts with) can apply at a time'
484
+ });
485
+ continue;
486
+ }
487
+ const product = disjunctions * shape.filters.reduce((total, filter)=>total * disjunctionsOf(filter), 1);
488
+ if (product > LIST_QUERY_DISJUNCTIONS) {
489
+ refused.push({
490
+ clause,
491
+ reason: `too many values at once (the limit is ${LIST_QUERY_DISJUNCTIONS})`
492
+ });
493
+ continue;
494
+ }
495
+ filters.push(...shape.filters);
496
+ served.push(clause);
497
+ if (shape.array) arrayTaken = true;
498
+ if (shape.inequality) inequality = shape.inequality;
499
+ disjunctions = product;
500
+ }
501
+ // The order.
502
+ const asked = request.sort ? declaration.sorts.find((sort)=>{
503
+ var _request_sort, _request_sort1;
504
+ return sort.path === ((_request_sort = request.sort) == null ? void 0 : _request_sort.path) && sort.direction === ((_request_sort1 = request.sort) == null ? void 0 : _request_sort1.direction);
505
+ }) : undefined;
506
+ const orderBy = inequality ? rangeOrder(declaration, declaration.fields.find((field)=>field.path === inequality || field.lowerPath === inequality || field.reversedPath === inequality), inequality) : (_ref1 = asked != null ? asked : declaration.sorts[0]) != null ? _ref1 : {
507
+ path: LIST_QUERY_ID_PATH,
508
+ direction: 'asc'
509
+ };
510
+ return {
511
+ filters,
512
+ orderBy,
513
+ served,
514
+ searched,
515
+ refused,
516
+ notices
517
+ };
518
+ }
519
+ const ORDER = {
520
+ asc: 'ASCENDING',
521
+ desc: 'DESCENDING'
522
+ };
523
+ /**
524
+ * Every composite a declaration's queries can need, under index merging:
525
+ * one `(predicate field, order)` index per equality or array field and per
526
+ * order the list can take — its sorts, and the order each range imposes.
527
+ *
528
+ * `base` names the scope predicates the list always applies (their paths
529
+ * and whether they are array clauses), which need their own index per order
530
+ * like any other equality. Pinned by each list's spec against
531
+ * `cloud/firebase-firestore.indexes.json`.
532
+ */ export function listQueryIndexes(declaration, base = []) {
533
+ const equalities = new Map() // path → is array clause
534
+ ;
535
+ const orders = [
536
+ ...declaration.sorts
537
+ ];
538
+ const addOrder = (sort)=>{
539
+ if (!orders.some((entry)=>entry.path === sort.path && entry.direction === sort.direction)) {
540
+ orders.push(sort);
541
+ }
542
+ };
543
+ for (const entry of base)equalities.set(entry.path, Boolean(entry.array));
544
+ if (declaration.search) {
545
+ equalities.set(declaration.search.tokensPath, true);
546
+ if (declaration.search.scoped) equalities.set(declaration.search.scoped.tokensPath, true);
547
+ }
548
+ for (const field of declaration.fields){
549
+ if (field.windowOnly) continue;
550
+ const ops = listFilterOperators(field);
551
+ const has = (...names)=>names.some((name)=>ops.includes(name));
552
+ if (field.keysOf) {
553
+ var _ref;
554
+ var _declaration_keys;
555
+ for (const key of (_ref = (_declaration_keys = declaration.keys) == null ? void 0 : _declaration_keys[field.column]) != null ? _ref : []){
556
+ equalities.set(`${field.path}.${key}`, false);
557
+ }
558
+ continue;
559
+ }
560
+ if (field.kind === 'text') {
561
+ if (has('contains') && field.tokensPath) equalities.set(field.tokensPath, true);
562
+ if (has('equals', 'isAnyOf') && field.lowerPath) equalities.set(field.lowerPath, false);
563
+ if (has('equals') && !field.lowerPath && field.presence === 'always') equalities.set(field.path, false);
564
+ if (has('startsWith') && field.lowerPath) addOrder(rangeOrder(declaration, field, field.lowerPath));
565
+ if (has('endsWith') && field.reversedPath) addOrder(rangeOrder(declaration, field, field.reversedPath));
566
+ } else if (field.kind === 'exact') {
567
+ if (has('equals', 'isAnyOf')) equalities.set(field.path, false);
568
+ if (has('contains', 'isAnyOf') && field.tokensPath) equalities.set(field.tokensPath, true);
569
+ } else if (field.kind === 'boolean') {
570
+ equalities.set(field.path, false);
571
+ } else if (field.kind === 'number') {
572
+ if (has('=')) equalities.set(field.path, false);
573
+ if (has('!=', '>', '>=', '<', '<=')) addOrder(rangeOrder(declaration, field, field.path));
574
+ } else if (field.kind === 'date') {
575
+ if (has('is', 'after', 'onOrAfter', 'before', 'onOrBefore')) {
576
+ addOrder(rangeOrder(declaration, field, field.path));
577
+ }
578
+ }
579
+ if (has('isEmpty')) equalities.set(field.path, false);
580
+ if (has('isNotEmpty')) addOrder(rangeOrder(declaration, field, field.path));
581
+ }
582
+ const indexes = [];
583
+ const seen = new Set();
584
+ for (const [path, array] of equalities){
585
+ for (const order of orders){
586
+ if (order.path === path || order.path === LIST_QUERY_ID_PATH) continue;
587
+ const index = {
588
+ fields: [
589
+ array ? {
590
+ fieldPath: path,
591
+ arrayConfig: 'CONTAINS'
592
+ } : {
593
+ fieldPath: path,
594
+ order: 'ASCENDING'
595
+ },
596
+ {
597
+ fieldPath: order.path,
598
+ order: ORDER[order.direction]
599
+ }
600
+ ]
601
+ };
602
+ const key = JSON.stringify(index);
603
+ if (!seen.has(key)) {
604
+ seen.add(key);
605
+ indexes.push(index);
606
+ }
607
+ }
608
+ }
609
+ return indexes;
610
+ }
611
+ /**
612
+ * The composites a declaration needs that an index file does not hold — what
613
+ * a list's spec asserts is empty, so a declared filter cannot ship without
614
+ * the index that serves it.
615
+ */ export function missingListQueryIndexes(file, collectionGroup, needed, queryScope = 'COLLECTION') {
616
+ const shape = (fields)=>fields.filter((field)=>field.fieldPath !== LIST_QUERY_ID_PATH).map((field)=>{
617
+ var _field_order;
618
+ return `${field.fieldPath}:${(_field_order = field.order) != null ? _field_order : field.arrayConfig}`;
619
+ }).join(',');
620
+ const present = new Set(file.indexes.filter((index)=>index.collectionGroup === collectionGroup && index.queryScope === queryScope).map((index)=>shape(index.fields)));
621
+ return needed.filter((index)=>!present.has(shape(index.fields)));
622
+ }
623
+
624
+ //# sourceMappingURL=list-query-plan.js.map