Repository navigation
Expand file tree
/
Copy pathindex.ts
More file actions
8006 lines (7593 loc) · 377 KB
/
Copy pathindex.ts
File metadata and controls
8006 lines (7593 loc) · 377 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import { ObjectStackClient, type QueryOptions as ObjectStackQueryOptions } from '@objectstack/client';
import type { DroppedFieldsEvent, EngineAggregateOptions } from '@objectstack/spec/data';
import type { ListViewGroupHeaderRow } from '@objectstack/spec/ui';
// objectui#11013 — a VALUE import: the keys `listViews` carries off a stored
// ViewItem record are the spec's own record of the console's round-trip keys,
// read off the pin rather than hand-listed (see `VIEW_ITEM_ROUND_TRIP_KEYS`).
import { VIEW_CONSOLE_ROUND_TRIP_KEYS } from '@objectstack/spec/ui';
// The spec's one test for a view filter list's any-of group element
// (`{ or: [rule, ...] }`), read here rather than restated.
import { isViewFilterOrGroup } from '@objectstack/spec/ui';
// #4934 — a VALUE import, not a type one: the write-warning boundary parses the
// wire's `reason` against the enum the spec itself declares, so the accept set
// is read off the pin instead of hand-copied here (a hand copy is the drift
// this seam already paid for once — see `DroppedFieldsEvent`'s comment below).
import { DroppedFieldsEventSchema } from '@objectstack/spec/data';
// #6825 / #7752 — the spec's OWN filter-AST gate and its OWN lowering sink,
// imported as VALUES for the same reason: `aggregate()` decides whether a
// `where` is a filter, and turns a `FilterArray` into the `FilterCondition` the
// wire declares, by asking the contract — not by a hand-rolled shape sniff or a
// second lowering that could disagree with the door it is feeding. Same
// predicate and same sink the server ingress runs, so the producer-side refusal
// and the wire-side one cannot drift.
import { isFilterAST, parseFilterAST } from '@objectstack/spec/data';
// objectui#9048 — the case-insensitive-contains comparand door, read from the
// contract's owner rather than restated: the predicate that decides which
// comparands `FILTER_TEXT_CASES` declares REFUSED, and the reason text its
// `mustMention` makes load-bearing. `@object-ui/core`'s `convertFiltersToAST`
// and `ValueDataSource` read these same two functions, so the rule-entry form
// below cannot judge a different set than the `$` dialect beside it.
import { isRefusedTextComparand, textComparandRefusalReason } from '@objectstack/spec/data';
import type { ApiError, BatchUpdateResponse, ValidateDataIssue } from '@objectstack/spec/api';
import { ValidateDataIssueSchema } from '@objectstack/spec/api';
import { stripReadDecorations } from '@objectstack/spec/kernel';
import { isPlatformProvidedObjectName } from '@objectstack/spec/system';
// #4237 — the metadata save door's advisory reader, shared with `MetadataClient`
// rather than forked. ONE reader, two call sites: the other client class calls it
// from `MetadataClient.save` (#4133/#4236), this one from the interceptor below.
import {
readSaveAdvisories,
type MetadataSaveAdvisoryEvent,
type MetadataSaveAdvisoryListener,
} from './metadata-client';
import type { AnalyticsResult, DatasetSelection } from '@objectstack/spec/contracts';
import type {
DataSource,
RecordFieldAccess,
RecordPagePayload,
RecordPageRequest,
ConnectionState,
// #4564 — the canonical `deleteView` receipt shapes, declared beside the
// `DataSource` interface they belong to. Imported for the local uses below
// and re-exported under the same names further down, so this package's
// existing importers are untouched by the move.
DeleteViewResult,
ViewHomeDeleteOutcome,
BatchTransactionOperation,
BulkWriteResult,
BulkWriteRowError,
DataSourceMutationEvent,
QueryParams,
QueryResult,
GlobalSearchResult,
GlobalSearchHit,
FileUploadResult,
ExportDownloadRequest,
ImportRequestOptions,
ImportRecordsResult,
CreateImportJobResult,
ImportJobProgressInfo,
ImportJobResultsInfo,
ImportJobSummaryInfo,
ImportJobUndoResult,
ListImportJobsOptions,
} from '@object-ui/types';
import { errorCodeIs, errorCodeIsAnyOf } from '@object-ui/types';
import {
attachObjectSortability,
attachCascadeDeleteObjects,
markSwitchedOffActions,
convertFiltersToAST,
emulateBatchTransaction,
normalizeSchemaReferenceKeys,
isApiAccessDeniedError,
type DatasetDrillRange,
} from '@object-ui/core';
import { MetadataCache } from './cache/MetadataCache';
import { AppBundleStore, indexAppBundle, type AppBundleAnswer, type AppBundlePeek } from './app-bundle';
import { MetadataClient } from './metadata-client';
import { formatMetadataError } from './metadata-error';
import {
ObjectStackError,
MetadataNotFoundError,
BulkOperationError,
ConnectionError,
DataApiValidationError,
createErrorFromResponse,
} from './errors';
// #9594 - the ONE error-envelope reader for this module's HTTP failure paths.
// Four hand-copied ladders each read two of the three live envelope dialects
// and missed the flat `{ code, error: <string> }` one, so a 403 naming the
// missing grant reached the operator as the word `Forbidden`. Deliberately NOT
// re-exported from this entry point: it is internal, and exporting it would
// widen this package's published face.
import { readErrorEnvelope } from './error-envelope';
/**
* Map human-readable filter operator names produced by SDUI view configs
* (e.g. `lead.view.ts`) to the canonical operator symbols expected by the
* ObjectStack server's filter AST. Unknown operators fall through unchanged
* so existing AST-style entries keep working.
*
* Every VALUE here must be a member of the spec's `VALID_AST_OPERATORS`
* (`@objectstack/spec/data`) — that set gates `isFilterAST()`, and a filter it
* rejects is not converted, not validated, and then silently DROPPED by
* driver-sql (objectstack#3948). Pinned by `filter-operator-ast-parity.test.ts`.
*
* Exported for that test. @internal
*/
export const FILTER_OPERATOR_ALIASES: Record<string, string> = {
equals: '=',
eq: '=',
'==': '=',
not_equals: '!=',
notequals: '!=',
ne: '!=',
greater_than: '>',
greaterthan: '>',
gt: '>',
greater_than_or_equal: '>=',
greater_than_or_equals: '>=',
greaterthanorequal: '>=',
gte: '>=',
less_than: '<',
lessthan: '<',
lt: '<',
less_than_or_equal: '<=',
less_than_or_equals: '<=',
lessthanorequal: '<=',
lte: '<=',
in: 'in',
not_in: 'nin',
notin: 'nin',
nin: 'nin',
contains: 'contains',
// Case-insensitive contains. A canonical `VIEW_FILTER_OPERATORS` member that
// arrived with `@objectstack/spec` 17.1.0 (objectui#5328), and an IDENTITY row
// like `contains` above: `icontains` is itself a member of
// `VALID_AST_OPERATORS`, so the spelling the author writes is the spelling the
// AST takes — nothing is translated and no case-sensitivity is lost.
// Declared rather than left to the `?? op` fall-through on the rule this
// table's test states: the AST gate accepting a spelling is not the driver
// compiling it into a WHERE clause.
icontains: 'icontains',
not_contains: 'notcontains',
notcontains: 'notcontains',
starts_with: 'startswith',
startswith: 'startswith',
ends_with: 'endswith',
endswith: 'endswith',
between: 'between',
is_null: 'isnull',
isnull: 'isnull',
is_not_null: 'isnotnull',
isnotnull: 'isnotnull',
// Date comparisons. `before`/`after` are CANONICAL members of the spec's
// `VIEW_FILTER_OPERATORS` (ui/view.zod.ts), so a stored view legitimately
// carries them — but they are absent from `VALID_AST_OPERATORS`
// (data/filter.zod.ts), which gates `isFilterAST()`. Without these two
// entries they reached the wire unchanged, the server's AST gate rejected
// the shape, and driver-sql skipped the filter ENTIRELY — an unfiltered
// result set with no error anywhere. objectstack#3948.
before: '<',
after: '>',
};
/**
* Resolve an authored filter operator to the AST symbol the wire takes.
*
* Deliberately NOT named `normalizeFilterOperator`. That name belongs to a
* DIFFERENT function, which `@objectstack/spec/ui` exports and which this
* monorepo's view layer imports from there (`viewFilterFold`,
* `filter-converter`, `ListView`, `UserFilters`, the FilterBuilder). The two
* take the same input and agree on nothing else: the spec's folds a legacy
* spelling to the canonical VIEW vocabulary (`VIEW_FILTER_OPERATORS`) so that
* `ViewFilterRuleSchema`'s enum can judge it, leaving a canonical operator
* unchanged — `eq` becomes `equals` and `before` stays `before`. This one
* translates the same input into the server's filter-AST symbols through
* {@link FILTER_OPERATOR_ALIASES} — `eq` AND `equals` both become `=`, and
* `before` becomes `<`. Which spellings the two disagree on is enumerated by
* the pin named below rather than written down here; it is most of them.
*
* Renamed at objectui#7265, off the ledger in
* `scripts/check-spec-symbol-derivation.mjs`, so that a reader who has seen
* `normalizeFilterOperator` anywhere else in this tree cannot read the call
* below as the same fold. Nothing about the behaviour moved with the name.
*
* The `?? op` tail is shared with the spec's, and load-bearing for the same
* reason: an entry already written in AST form (`'='`, `'nin'`) has no row here
* and must pass through. The `null` arm is NOT shared — the spec's hands a
* non-string back verbatim (its body ends `return op as string`), this one
* refuses, and `objectFilterEntryToAST` turns the refusal into a
* `MalformedFilterError` instead of putting a number in the operator slot of a
* tuple it is about to send. Both halves are pinned, in both directions, in
* `scripts/__tests__/spec-symbol-ledger-data-objectstack-7265.test.ts`.
*/
function toAstFilterOperator(op: unknown): string | null {
if (typeof op !== 'string') return null;
const lower = op.toLowerCase();
return FILTER_OPERATOR_ALIASES[lower] ?? FILTER_OPERATOR_ALIASES[op] ?? op;
}
/**
* A filter entry as it appears inside a refusal message, without letting the
* explanation throw.
*
* `JSON.stringify` throws on a BigInt and on a cyclic object. A `TypeError`
* raised while a refusal builds its message escapes in the refusal's place, and
* a caller then reads a transport-looking failure about a filter this adapter
* had already judged (objectui#9048: the text-comparand refusal constructs a
* {@link MalformedFilterError} for exactly such an entry). The replacer renders
* a BigInt as its literal and leaves every other value alone, so an entry that
* serialised before serialises to the same bytes now. Module-private.
*/
function describeFilterEntry(entry: unknown): string {
try {
return JSON.stringify(entry, (_key, value) => (typeof value === 'bigint' ? `${value}n` : value))
?? String(entry);
} catch {
return String(entry);
}
}
/**
* A filter entry this adapter cannot translate into an AST tuple.
*
* Thrown rather than skipped. Dropping one entry out of an `and` WIDENS the
* result set, and dropping the last one emits no `filter=` at all — every row,
* no error, from a query that asked for a subset. That is the same silent
* over-fetch the server-side drivers stopped doing in objectstack#3948, and
* skipping it here just moves it one layer up.
*
* Carries the code and status the data API uses for its own version of this
* refusal (objectstack#4121) so a failed list renders "this view's filter is
* malformed" rather than "check your connection" (#3066).
*/
export class MalformedFilterError extends Error {
readonly code = 'INVALID_FILTER';
readonly httpStatus = 400;
readonly entry: unknown;
readonly index: number;
constructor(entry: unknown, index: number) {
// BigInt-safe (see `describeFilterEntry`); byte-identical to a bare
// `JSON.stringify(entry) ?? String(entry)` for every entry that did not throw.
const shown = describeFilterEntry(entry);
super(
`Filter entry ${index} is not a usable filter rule (${shown}). `
+ 'Expected { field, operator, value } with a non-empty field.',
);
this.name = 'MalformedFilterError';
this.entry = entry;
this.index = index;
}
}
/** Detect the malformed-filter refusal, whether raised here or by the server. */
export function isMalformedFilterError(error: unknown): boolean {
if (!error || typeof error !== 'object') return false;
const e = error as Record<string, unknown>;
return e.code === 'INVALID_FILTER' || e.name === 'MalformedFilterError';
}
/**
* A `where` that reached `aggregate()`'s SPEC-SHAPE branch as an array the spec
* cannot read as a filter — an unlowered `ViewFilterRule[]` above all.
*
* WHY THIS IS A REFUSAL AND NOT A LOWERING (objectui#6825, maintainer ruling
* 2026-08-30, option A). The two branches of `aggregate()` take a filter by
* different names: the analytics branch takes `filter` and lowers it through
* `translateFilterArray` (objectui#6302), the spec-shape branch takes `where`
* and posts it to `POST /data/:object/query` verbatim. Lowering here too was
* option B and was REFUSED: it is the tolerant-consumer direction AGENTS.md
* #0.1 exists to stop, and it would bless as supported a params shape
* `AggregateParams` does not even declare. Retiring the branch was option D and
* was refused as the riskier move — this repo has no producer that can reach it
* (`ObjectChart`'s gate requires a non-array `aggregate.groupBy`, and all three
* in-tree constructors pass a string), but `ObjectChart`'s props are `any` and
* an out-of-repo host may already send this shape. So the branch stays, and it
* says no out loud.
*
* What that buys is the FAILURE MODE, not a failure count. The receiving engine
* already refuses these arrays ("is not a filter" — objectstack's
* `engine-filter-array-lowering.test.ts`), so nothing that reaches this point
* could have produced a correct number; the pre-existing behaviour was to find
* that out on the wire, or worse, to have the predicate quietly dropped while
* the chart rendered confident, wrong figures with no signal to their author.
* This moves the same refusal to the producer, where the author can act on it.
*
* Carries the `INVALID_FILTER` / 400 pair its sibling {@link MalformedFilterError}
* carries, so `isMalformedFilterError()` recognises it and a failed widget renders
* "this filter is malformed" rather than "check your connection" (objectui#3066).
*/
export class UnloweredAggregateWhereError extends Error {
readonly code = 'INVALID_FILTER';
readonly httpStatus = 400;
/** The value as received, so a caller can log what its producer actually built. */
readonly where: unknown;
/** The object `aggregate()` was called for. */
readonly resource: string;
constructor(where: unknown, resource: string) {
const shown = JSON.stringify(where) ?? String(where);
super(
`aggregate('${resource}'): the spec-shape branch received a \`where\` array `
+ `that is not a filter — ${shown}. This branch posts \`where\` to `
+ 'POST /data/:object/query verbatim, so it must ALREADY be lowered: either a '
+ 'FilterCondition object (`QuerySchema.where`, @objectstack/spec '
+ 'data/query.zod.ts), or a FilterArray the ingress can lower — a comparison '
+ "tuple (['stage','=','won']), a logical node (['and',[..],[..]]), or an array "
+ 'of nodes (data/filter.zod.ts, gate `isFilterAST`). A ViewFilterRule[] '
+ '([{ field, operator, value }, ...]) is authoring sugar, not a filter: lower it '
+ 'in the producer that built these aggregate params, not here. Nothing was sent '
+ 'to the server, so no unfiltered numbers came back.',
);
this.name = 'UnloweredAggregateWhereError';
this.where = where;
this.resource = resource;
}
}
/**
* Gate the spec-shape branch's `where` on the spec's own AST predicate.
*
* Scoped to ARRAYS on purpose, and the two carve-outs are measured, not assumed:
*
* - A NON-array `where` is the declared shape. `QuerySchema.where` is
* `FilterConditionSchema` — the MongoDB-style condition object — so refusing
* `{ stage: 'won' }` here would refuse the contract itself. Untouched.
* - An EMPTY array is "no filter", and the engine says so in as many words:
* objectstack's `engine-filter-array-lowering.test.ts` pins `where: []`
* returning every row from `find()` and `3` from `count()`. `isFilterAST([])`
* is `false`, so gating on the predicate alone would refuse a value the
* receiving door accepts — a refusal the ruling did not order.
*
* Every remaining array is one the receiving door refuses too, so this adds no
* new failure — it moves an existing one to where an author can read it.
*/
function assertSpecShapeWhereIsFilterAst(where: unknown, resource: string): void {
if (!Array.isArray(where)) return;
if (where.length === 0) return;
if (isFilterAST(where)) return;
throw new UnloweredAggregateWhereError(where, resource);
}
/**
* The keys `aggregate()`'s ANALYTICS branch reads and its SPEC-SHAPE branch does
* not: `filter`, `field`, `function`.
*
* Written out once, and read by both the refusal below and its message, so the
* set a caller is told about cannot drift from the set that is checked.
*
* ⛔ Deliberately NOT "every key the spec-shape branch does not read". A refusal
* scoped that widely would reject `orderBy`, a future spec key, or any harmless
* extra a host happens to carry, and it would satisfy a naive "the spec-shape
* branch refuses `filter`" test while breaking traffic nobody complained about.
* These three are named one by one because they are the OTHER branch's own
* parameters: their presence says the params were built for the analytics wire
* and arrived on the spec-shape one, which is a producer defect with a name.
*/
const ANALYTICS_ONLY_AGGREGATE_KEYS = ['filter', 'field', 'function'] as const;
type AnalyticsOnlyAggregateKey = (typeof ANALYTICS_ONLY_AGGREGATE_KEYS)[number];
/** What each refused key means, and where its spec-shape equivalent lives. */
const ANALYTICS_ONLY_KEY_ADVICE: Record<AnalyticsOnlyAggregateKey, string> = {
filter:
'`filter` is the ANALYTICS branch\'s filter and is lowered there through '
+ '`parseFilterAST`; this branch\'s filter is `where` (`QuerySchema.where`, '
+ '@objectstack/spec data/query.zod.ts) and is posted VERBATIM — rename it to '
+ '`where` and lower it in the producer.',
field:
'`field` is half of the analytics branch\'s `${field}_${function}` measure; '
+ 'this branch\'s measure is `aggregations`, an AggregationNode[] such as '
+ "[{ function: 'sum', field: 'amount', alias: 'amount_sum' }].",
function:
'`function` is the other half of that measure pair and belongs inside an '
+ '`aggregations` node, not beside `groupBy`.',
};
/** Which disjunct(s) of `looksLikeSpecShape` put this call on the branch. */
function specShapeSelectorReasons(params: any): string[] {
const reasons: string[] = [];
if (Array.isArray(params?.groupBy)) reasons.push('`groupBy` is an array');
if (Array.isArray(params?.aggregations)) reasons.push('`aggregations` is an array');
if (params?.where !== undefined) reasons.push('a `where` key is present');
return reasons;
}
/**
* Analytics-branch params — `filter`, `field`, `function` — that reached
* `aggregate()`'s SPEC-SHAPE branch, which reads none of them.
*
* WHY A REFUSAL AND NOT A DROP (`503cd8b89`). This applies the maintainer
* ruling of 2026-08-30 on objectui#6825 — option A, REFUSE at the producer — to
* the rest of the same branch. That ruling's reason was that a shape the spec's
* own gate would reject is off-contract at the PRODUCER, so the adapter says no
* instead of degrading leniently; the reason lands harder here, because a
* silent drop is precisely the disposition it refused for `where`. Leaving one
* key on this branch refusing while three others vanish without a word would be
* harder to explain than the old behaviour.
*
* ⭐ AND THE DROP HERE IS WORSE THAN THE `where` HALF. `filter` is not the only
* casualty: `field` and `function` are the analytics branch's whole measure, and
* the spec-shape branch copies a measure only out of `aggregations`. So the
* legacy shape `{ field, function, groupBy, filter }` whose `groupBy` happens to
* be an ARRAY produced a `queryAst` carrying a `groupBy` and NO `aggregations`
* at all — a grouping with no measure — with the author's predicate gone too.
* The chart still rendered, the numbers were wrong, and there was nothing to
* look at.
*
* HOW A LEGACY CALLER GETS HERE, measured in this repo on 2026-09-08.
* `Array.isArray(params.groupBy)` is one of `looksLikeSpecShape`'s three
* disjuncts, and `ObjectChart`'s `runAggregate` gates only on the STRUCTURED
* node shape (`gb && typeof gb === 'object' && !Array.isArray(gb)`), so an
* ARRAY `aggregate.groupBy` falls through to its legacy call — `{ field,
* function, groupBy, filter }` — and that call lands here.
* `ObjectMetricWidget.computeOne` forwards `aggregate.groupBy` the same way.
* Both take the value from authored widget metadata across an `any` seam
* (`isObjectProvider`'s `aggregate?: any`, `ds: any`), so the type system
* cannot refuse it and this runtime refusal is what an author gets instead.
*
* ⛔ WHAT THIS DELIBERATELY DOES NOT DO. It does not ROUTE the legacy shape back
* to the analytics branch: that is the tolerant-consumer direction AGENTS.md
* §0.1 exists to stop, it is the option #6825 refused for `where`, and it could
* not work anyway — the analytics branch posts `dimensions: [params.groupBy]`,
* so an array `groupBy` would go out nested. It does not widen `AggregateParams`
* (`@object-ui/types`), which is a separate contract question. And it does not
* refuse a key that is present but nullish: `undefined`/`null` carries nothing
* for the branch to drop, and both `ObjectChart` and `ObjectMetricWidget` build
* their params by spreading possibly-absent authored values.
*
* Carries the `INVALID_FILTER` / 400 pair its siblings {@link MalformedFilterError}
* and {@link UnloweredAggregateWhereError} carry, so `isMalformedFilterError()`
* recognises it and a failed widget renders "this filter is malformed" rather
* than "check your connection" (objectui#3066). One branch, one envelope: a
* caller can catch `aggregate()`'s producer-side refusals with one predicate.
*/
export class AnalyticsKeysOnSpecShapeError extends Error {
readonly code = 'INVALID_FILTER';
readonly httpStatus = 400;
/** The offending keys, in the order {@link ANALYTICS_ONLY_AGGREGATE_KEYS} lists them. */
readonly keys: readonly AnalyticsOnlyAggregateKey[];
/** What each offending key carried, so a producer is identifiable from a log. */
readonly received: Record<string, unknown>;
/** The object `aggregate()` was called for. */
readonly resource: string;
constructor(
keys: readonly AnalyticsOnlyAggregateKey[],
params: any,
resource: string,
) {
const received: Record<string, unknown> = {};
for (const key of keys) received[key] = params?.[key];
const named = keys.map((k) => `\`${k}\``).join(', ');
const shown = keys
.map((k) => `${k}=${JSON.stringify(params?.[k]) ?? String(params?.[k])}`)
.join(', ');
const why = specShapeSelectorReasons(params).join(' and ');
const noMeasure = !Array.isArray(params?.aggregations)
? ' Because no `aggregations` was supplied either, the query this would '
+ 'have built is a grouping with NO MEASURE — the chart renders and its '
+ 'numbers mean nothing.'
: '';
super(
`aggregate('${resource}'): the spec-shape branch received the analytics `
+ `branch's ${named} — ${shown} — and reads none of them. This call took the `
+ `spec-shape branch because ${why}; the branch posts only \`groupBy\`, `
+ '`aggregations`, `where` and `limit` to POST /data/:object/query, so those '
+ `keys would have been dropped in silence.${noMeasure} `
+ keys.map((k) => ANALYTICS_ONLY_KEY_ADVICE[k]).join(' ')
+ ' The two shapes are alternatives, not a mixture: the analytics branch '
+ '(`{ field, function, groupBy, filter }`, which lowers its filter for you) '
+ 'is reached only when NONE of an array `groupBy`, an array `aggregations` '
+ 'or a `where` key is present. Nothing was sent to the server, so no '
+ 'unfiltered numbers came back.',
);
this.name = 'AnalyticsKeysOnSpecShapeError';
this.keys = keys;
this.received = received;
this.resource = resource;
}
}
/**
* Refuse the analytics branch's own keys when they reach the spec-shape branch.
*
* Presence is `!= null` on purpose, not `in`: a params object that spreads an
* absent authored value (`filter: filterForRun` where nothing was authored)
* carries the key with `undefined`, and there is nothing there to drop. Both
* in-tree producers build their params exactly that way, so keying on `in`
* would refuse calls that lose nothing.
*/
function assertNoAnalyticsKeysOnSpecShape(params: any, resource: string): void {
const present = ANALYTICS_ONLY_AGGREGATE_KEYS.filter((key) => params?.[key] != null);
if (present.length === 0) return;
throw new AnalyticsKeysOnSpecShapeError(present, params, resource);
}
/**
* An ARRAY `filter` that reached `aggregate()`'s ANALYTICS branch and that the
* protocol's lowering sink cannot turn into a `FilterCondition` — an infix join
* (`[condA, 'or', condB]`) above all.
*
* WHY A REFUSAL AND NOT A DROP (objectui#7752; maintainer ruling of 2026-09-05
* on objectstack#15828, which reads that card as a frontend defect: the
* protocol is right and the adapter was wrong). `parseFilterAST` answers
* `undefined` for an array it cannot read, and `undefined` here would post a
* body with NO `where` at all: the aggregate would then be computed over every
* row and returned as a confident number, with the author's predicate gone and
* nothing on the wire to say it went. That is the same silent over-fetch
* {@link MalformedFilterError} exists to stop, one door further along.
*
* Deliberately NOT {@link UnloweredAggregateWhereError}, whose meaning is not
* widened to cover this: that one guards the SPEC-SHAPE branch, whose `where`
* is posted verbatim and must therefore have been lowered by its PRODUCER
* (objectui#6825, ruling 2026-08-30 option A — refuse, never lower). This
* branch's `filter` is authoring input and IS lowered here, at the door the
* protocol names; this error covers only the input that sink cannot lower.
*
* Carries the `INVALID_FILTER` / 400 pair both siblings carry, so
* `isMalformedFilterError()` recognises it and a failed widget renders "this
* filter is malformed" rather than "check your connection" (objectui#3066).
*/
export class UnlowerableAnalyticsFilterError extends Error {
readonly code = 'INVALID_FILTER';
readonly httpStatus = 400;
/** The filter as received, so a caller can log what its producer actually built. */
readonly filter: unknown;
/** The object `aggregate()` was called for. */
readonly resource: string;
constructor(filter: unknown, resource: string, detail?: string) {
const shown = JSON.stringify(filter) ?? String(filter);
super(
`aggregate('${resource}'): the analytics branch received a \`filter\` array `
+ `that \`parseFilterAST\` cannot lower into a FilterCondition — ${shown}.`
+ (detail ? ` (${detail})` : '')
+ ' `where` on the analytics wire is a FilterCondition (`AnalyticsQuerySchema`, '
+ '@objectstack/spec data/analytics.zod.ts), and a FilterArray is input-only '
+ 'sugar lowered into one at the single sink `parseFilterAST` '
+ '(data/filter.zod.ts, objectstack#5158 ruling C). Lowerable shapes are a '
+ "comparison tuple (['stage','=','won']), a logical node (['and',[..],[..]]), "
+ 'an array of such nodes, and a ViewFilterRule[] this adapter translates '
+ "first. An infix join ([condA, 'or', condB]) is none of them. Nothing was "
+ 'sent to the server, so no unfiltered numbers came back.',
);
this.name = 'UnlowerableAnalyticsFilterError';
this.filter = filter;
this.resource = resource;
}
}
/**
* Refuse a rule entry whose case-insensitive-contains comparand is one of the
* two shapes `@objectstack/spec`'s `FILTER_TEXT_CASES` declares REFUSED — an
* empty string, or not a string at all (objectui#9048).
*
* The rule-entry form was the one dialect this adapter lowered that shape in.
* The `$` dialect has refused it since objectui#9001 (the object branch of
* {@link translateFilterToAST} delegates to `convertFiltersToAST`), and
* `ValueDataSource` refuses it in every form it reads. So the SAME wire node
* was refused when written `{ name: { $icontains: '' } }` and sent when written
* `[{ field: 'name', operator: 'icontains', value: '' }]`: a result that turned
* on the filter's shape rather than its meaning. One predicate and one reason,
* both the spec's; this function is only the envelope.
*
* The reason is seated verbatim, since `mustMention` makes its bytes the
* contract. It names the spelling that ARRIVED, never a canonical one put in
* its place; the table's rows spell the operator in the `$` dialect, so the
* tail names that twin rather than substituting it — the seating
* `viewFilterRuleToNode` uses for the same vocabulary.
*
* No carve-out for an absent `value`. The stored-view lowering has one because
* it emits a 2-tuple for a valueless operator; this translator always emits a
* 3-tuple, and an absent value went out as JSON `null` — a non-string
* comparand, which is the table's second row. `ValueDataSource` refuses the
* same rule for the same reason.
*
* The envelope is an ordinary {@link MalformedFilterError} whose message is
* replaced by the refusal sentence, so `instanceof`, `name`, `code` and
* `httpStatus` are the class's own. The sentence is seated here, module-private,
* rather than through a constructor parameter, because the class is published
* and no consumer needs a way to pass one: the refusal is this module's, and
* its public constructor stays exactly what it was. The shape advice the
* constructor writes first is discarded; building it is BigInt-safe (see
* `describeFilterEntry`), so a BigInt comparand still reaches this sentence.
*/
function refuseTextComparandEntry(
entry: { readonly value?: unknown },
index: number,
field: string,
arrived: string,
): never {
const tail =
`This is a { field, operator, value } rule entry, so it is refused as the filter is `
+ `translated rather than sent: the spelling @objectstack/spec's FILTER_TEXT_CASES `
+ `uses for this operator is the $-dialect '$icontains', which this form spells `
+ `'${arrived}'. Remove the entry, or give it a non-empty string value `
+ `(objectui#9048; the same refusal convertFiltersToAST and ValueDataSource already share).`;
const error = new MalformedFilterError(entry, index);
error.message =
`Filter entry ${index} is refused. `
+ `The ${textComparandRefusalReason(field, arrived, entry.value)}. ${tail}`;
throw error;
}
/**
* One rule entry → one AST comparison tuple, or `null` when the entry is not a
* usable rule. `index` is the entry's position in the array it was found in,
* named by a refusal so the author can find the entry in the view config.
*/
function objectFilterEntryToAST(entry: any, index: number): [string, string, any] | null {
if (!entry || typeof entry !== 'object') return null;
// `field` only. A `?? entry.name` fallback lived here from the day the
// function was written (4b93db4e6) and was unreachable for exactly as long:
// the shape sniff below has always keyed on `field`, so a `name`-keyed entry
// fell through to the "already AST" branch and shipped raw. The spec agrees
// it is not a real shape — `ViewFilterRuleSchema.field` is required, so a
// `name`-keyed rule cannot be saved as view metadata in the first place.
const field = entry.field;
const rawOp = entry.operator ?? entry.op ?? '=';
const op = toAstFilterOperator(rawOp);
if (!field || !op) return null;
// Asked AFTER the fold, so every spelling `toAstFilterOperator` resolves to
// `icontains` meets the door. `rawOp` is a string here: the fold answers
// `null` for anything else, which returned above.
if (op === 'icontains' && isRefusedTextComparand(entry.value)) {
refuseTextComparandEntry(entry, index, String(field), rawOp);
}
return [String(field), op, entry.value];
}
/**
* Which of the two array filter shapes is this — object entries or an AST node?
*
* One definition on purpose. This test used to be written out twice (here and
* inline in `convertQueryParams`) and the copies had already drifted: the
* inline one omitted the `!== null` guard, so a `$filter` of `[null]` threw a
* TypeError on the plain `find()` path while the same value was handled on the
* `$expand` path. Same stored view, different answer, decided by whether it
* happened to expand a lookup.
*/
function isObjectFilterEntryForm(filter: readonly unknown[]): boolean {
const first = filter[0];
return filter.length > 0
&& typeof first === 'object'
&& first !== null
&& !Array.isArray(first)
// A view filter list may open with an any-of group (`{ or: [...] }`), which
// carries no `field` of its own.
&& ((first as any).field !== undefined || isViewFilterOrGroup(first));
}
/**
* One any-of group element of a view filter list — `{ or: [rule, ...] }`,
* `@objectstack/spec/ui`'s `ViewFilterOrGroupSchema` — to an `or` node of its
* rules' tuples (one member: that member's tuple), or `null` when it is not a
* usable group: an `or` that is not a non-empty array, or a member that is not
* a usable rule. Every member is translated by {@link objectFilterEntryToAST},
* so a rule inside a group is refused for what it says exactly as at the top
* level. `index` is the group's position in its list.
*/
function objectFilterOrGroupToAST(group: { or?: unknown }, index: number): unknown[] | null {
const members = group.or;
if (!Array.isArray(members) || members.length === 0) return null;
const tuples: unknown[] = [];
for (const member of members) {
const tuple = objectFilterEntryToAST(member, index);
if (!tuple) return null;
tuples.push(tuple);
}
return tuples.length === 1 ? (tuples[0] as unknown[]) : ['or', ...tuples];
}
/**
* Translate `[{ field, operator, value }, ...]` into a filter AST node. Every
* entry must translate; see `MalformedFilterError` for why one that doesn't is
* an error rather than an omission. An any-of group entry translates to one
* `or` node inside the list's `and`, and a group that does not translate is
* refused whole, by the same error.
*/
function objectFilterEntriesToAST(entries: readonly unknown[]): unknown[] {
const nodes = entries.map((entry, i) => {
// An entry that is itself an array is already a node — a mixed array keeps
// both conditions instead of losing one to a drop or the whole query to an
// error.
if (Array.isArray(entry)) return translateFilterChild(entry, i);
if (isViewFilterOrGroup(entry)) {
const node = objectFilterOrGroupToAST(entry, i);
if (!node) throw new MalformedFilterError(entry, i);
return node;
}
const tuple = objectFilterEntryToAST(entry, i);
if (!tuple) throw new MalformedFilterError(entry, i);
return tuple;
});
return nodes.length === 1 ? (nodes[0] as unknown[]) : ['and', ...nodes];
}
/** Logical heads `parseFilterAST` recognizes (`data/filter.zod.ts`). No `not`. */
const LOGICAL_AST_HEADS = new Set(['and', 'or']);
/**
* Translate a filter array at EVERY level, not just the top one.
*
* Translating only the top level left the commonest composite filter there is
* shipping raw. A list whose view carries a stored filter and whose user adds
* one in the panel produces `['and', <ViewFilterRule[]>, <AST tuples>]`; the
* head is the string `and`, so the old top-level-only check called the whole
* thing "already AST" and sent the rules on untouched.
*
* What the server does with that depends on its version, and both answers are
* wrong:
*
* ```
* const n = ['and', [{ field: 'stage', operator: 'eq', value: 'won' }], [['amount', '>', 1]]];
* isFilterAST(n) // false — a bare rule object is not an AST child
* parseFilterAST(n) // { amount: { $gt: 1 } } ← `stage = won` is simply GONE
* ```
*
* Since objectstack#4121 the `isFilterAST` gate turns that into a 400 and the
* list fails to load. Before it — or anywhere `parseFilterAST` is reached
* without the gate — the view's own condition is dropped without a word and the
* list shows records the view exists to exclude.
*/
function translateFilterArray(filter: unknown[]): unknown[] {
if (isObjectFilterEntryForm(filter)) return objectFilterEntriesToAST(filter);
const head = filter[0];
if (typeof head === 'string' && LOGICAL_AST_HEADS.has(head.toLowerCase())) {
return [head, ...filter.slice(1).map((child, i) => translateFilterChild(child, i + 1))];
}
// Legacy flat array of child nodes: [[...], [...]] — implicit AND.
if (filter.every((child) => Array.isArray(child))) {
return filter.map((child, i) => translateFilterChild(child, i));
}
// A comparison tuple, or a shape we do not recognize. Leave it alone; the
// server decides, and since objectstack#4121 it says so with a 400.
return filter;
}
/**
* A child of a logical node: another array node, a bare rule object, or a value
* we leave alone.
*
* The bare-rule case comes from producers that SPREAD a `ViewFilterRule[]` into
* an `and` (`['and', ...rules, ...tuples]`) instead of wrapping it. That puts
* rule objects where the AST expects nodes, and the server has no good answer:
* `isFilterAST` rejects it (a 400 since objectstack#4121), while
* `parseFilterAST` reads the rule as a MongoDB condition and filters on columns
* literally named `field` / `operator` / `value` — three columns that do not
* exist, so the honest-looking result is empty.
*
* Only rule-SHAPED objects are translated: a child with no `field` is a genuine
* MongoDB condition (`{ status: 'active' }`) and must pass through untouched.
* Same discriminator `isObjectFilterEntryForm` uses at the top level.
*
* A bare rule that does not translate is left for the server to refuse, but one
* the contract REFUSES is not: {@link objectFilterEntryToAST} throws for it
* here exactly as it does at the top level (objectui#9048). Translating it
* would send the refused node, and leaving it raw would earn a refusal of its
* SHAPE rather than of what it says. `index` is the child's position in its
* parent array.
*/
function translateFilterChild(child: unknown, index: number): unknown {
if (Array.isArray(child)) return child.length > 0 ? translateFilterArray(child) : child;
// A spread any-of group element: translated when every member is a usable
// rule, otherwise left for the server to refuse, like a bare rule above.
if (isViewFilterOrGroup(child)) return objectFilterOrGroupToAST(child, index) ?? child;
if (child && typeof child === 'object' && (child as any).field !== undefined) {
const tuple = objectFilterEntryToAST(child, index);
if (tuple) return tuple;
}
return child;
}
/**
* Translate any of the filter shapes accepted by ObjectUI into the AST format
* understood by the ObjectStack server's `parseFilterAST()`.
*
* Accepted inputs:
* - `[{ field, operator, value }, ...]` — a view filter list from view configs,
* whose elements may also be any-of groups `{ or: [rule, ...] }`
* - `[field, op, value]` — single AST tuple (passed through)
* - `['and'|'or', ...children]` — logical AST node (passed through)
* - `[[...], [...]]` — legacy nested AST (passed through)
* - `{ field: value }` / `{ field: { $op: value } }` — MongoDB-style object
*
* Returns `undefined` when the input is empty/unrecognized so callers can
* skip emitting `?filter=` entirely.
*/
function translateFilterToAST(filter: unknown): unknown | undefined {
if (filter === undefined || filter === null) return undefined;
if (Array.isArray(filter)) {
if (filter.length === 0) return undefined;
return translateFilterArray(filter);
}
if (typeof filter === 'object') {
if (Object.keys(filter as Record<string, unknown>).length === 0) return undefined;
// Same conversion `convertQueryParams` applies. This branch used to return
// the object VERBATIM, so the two `find()` routes disagreed about the same
// filter — decided, as ever, by whether the query happened to expand a
// lookup. Measured across 21 operator shapes, four diverged; the one that
// mattered is that `convertFiltersToAST`'s unknown-operator guard — added
// expressly "to avoid silent failure" — never ran on this route, so a typo'd
// operator threw on a plain read and shipped silently on an expanded one.
return convertFiltersToAST(filter as Record<string, unknown>);
}
return undefined;
}
/**
* Lower an analytics `filter` into the `where` the analytics wire declares.
*
* THE CONTRACT (objectstack#5158 ruling C, `data/filter.zod.ts`'s `FilterArray`
* docblock, verbatim): "A `FilterArray` is not a storage shape and not a
* protocol shape. It is lowered to a `FilterCondition` at the single sink
* `parseFilterAST` (`@objectstack/spec/data`) the moment it arrives, and only
* the lowered `FilterCondition` travels any further. `where` on a query is a
* `FilterCondition` and stays one." A POST body is transport, not one of the
* declared doors where an array may arrive (React block props, `FilterBuilder`,
* the REST `$filter` query string) — so the lowering happens HERE, before the
* body is built, and only a `FilterCondition` goes out.
*
* Three shapes, three answers:
*
* - A NON-array filter is already a `FilterCondition` — the MongoDB-style
* object this branch was written for, and what `AnalyticsQuerySchema.where`
* declares. Passed through untouched; translating it here would be a semantic
* change, not a fix.
* - An EMPTY array is no filter, so no `where` key is posted at all. Decided
* here rather than inferred: `parseFilterAST([])` is `undefined`, and the
* in-process analytics door reads `[]` the same way ("`[]` is no filter",
* objectstack#5334). The two readings agree, and this pins that they do.
* - Every other array is first normalised by `translateFilterArray` — the SAME
* function `find()` runs in `convertQueryParams`, so one stored filter cannot
* mean two things on two paths — and then lowered by the sink.
*
* Anything the sink cannot lower is refused, never posted and never dropped:
* see {@link UnlowerableAnalyticsFilterError} for why `undefined` on the wire
* would be the worst of the three possible answers.
*/
function lowerAnalyticsFilterForWire(filter: unknown, resource: string): unknown | undefined {
if (!Array.isArray(filter)) return filter;
if (filter.length === 0) return undefined;
// Throws `MalformedFilterError` on an untranslatable rule entry, exactly as it
// does on the `find()` path. Raised from here — outside `aggregate()`'s
// analytics `try` — so the refusal reaches the caller as itself instead of
// being classified as an unknown analytics failure and answered by the
// client-side fallback.
const ast = translateFilterArray(filter);
// The spec's own predicate, not a shape sniff: an array it rejects is an array
// the sink cannot read, and `parseFilterAST` would answer `undefined` for it.
if (!isFilterAST(ast)) throw new UnlowerableAnalyticsFilterError(filter, resource);
let lowered: unknown;
try {
lowered = parseFilterAST(ast);
} catch (e) {
// The sink's own refusal (a comparison tuple missing its comparand, say).
// Re-dressed in this adapter's `INVALID_FILTER` / 400 envelope, its message
// kept, so a widget can tell a malformed filter from a dead connection.
throw new UnlowerableAnalyticsFilterError(
filter, resource, e instanceof Error ? e.message : String(e),
);
}
// Belt to the gate's braces: the gate said this array is a filter, so the sink
// owes a condition. `undefined` here would post an unfiltered aggregate under
// a filtered question, so it is a refusal rather than a value.
if (lowered === undefined) {
throw new UnlowerableAnalyticsFilterError(
filter, resource, '`parseFilterAST` answered `undefined` for a non-empty filter',
);
}
return lowered;
}
/**
* Serialize a `$orderby` to the server's `sort` shorthand
* (`field,-other_field`), for every shape `QueryParams['$orderby']` declares.
*
* The type declares four — `string`, `string[]`, `SortNode[]`,
* `Record<field, direction>` — and the two `find()` routes each open-coded a
* fold that handled three of them. The missing one was the bare string, and it
* did not degrade quietly: `Object.entries('name asc')` enumerates a string's
* character indices, so the request went out as `sort=0,1,2,3,4,5,6,7`. Against
* a server that rejects an unreadable sort rather than ignoring it
* (objectstack#4226), that is a `400 INVALID_SORT` and an empty list — so a
* standalone `ObjectGrid` with a `sort` in its metadata, which is exactly the
* shape it builds (`ObjectGrid.tsx`: `` `${field} ${order}` ``), failed to load
* at all.
*
* One serializer for both routes, for the reason the filter path already has
* one: two copies of a fold can only agree by inspection, and these two did not.
*
* Returns `undefined` when nothing is sortable, so callers skip the parameter
* entirely rather than sending an empty one.
*/
export function serializeOrderBy(orderby: QueryParams['$orderby']): string | undefined {
if (orderby === undefined || orderby === null) return undefined;
// `field asc` / `-field` / a comma-separated list of either — already the
// wire shorthand the server parses, so it rides through untouched.
if (typeof orderby === 'string') {
const trimmed = orderby.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
const shorthand = (field: string, order?: unknown) =>
String(order).toLowerCase() === 'desc' ? `-${field}` : field;
if (Array.isArray(orderby)) {
const parts = orderby
.map((item) => (typeof item === 'string' ? item.trim() : shorthand(item.field, item.order)))
.filter((s) => s.length > 0);
return parts.length > 0 ? parts.join(',') : undefined;
}
if (typeof orderby === 'object') {
const parts = Object.entries(orderby).map(([field, order]) => shorthand(field, order));
return parts.length > 0 ? parts.join(',') : undefined;
}
return undefined;
}
// Module-level discovery cache. Multiple ObjectStackAdapter instances pointed
// at the same baseUrl (e.g. ConditionalAuthWrapper's throwaway adapter +
// AdapterProvider's main adapter) would otherwise each fire `/discovery`. By
// keying on baseUrl we collapse them to a single network round trip per origin.
/**
* The one {@link MetadataCache} key for the published view list,
* `GET /api/v1/meta/view` (objectui#13044).
*
* The client's list read (`client.meta.getItems('view')`, which the app-shell
* `MetadataProvider` asks through {@link ObjectStackAdapter.getClient}) shares
* its request IN FLIGHT under this key: whoever asks while one is on the wire
* joins it.
*
* The answer is also stored under it (objectui#13057, completing objectui#13044
* ruling 6268839381 (a)-(c)). The client read still always asks the server.
* The adapter's own two view readers, {@link ObjectStackAdapter.listViews} and
* {@link ObjectStackAdapter.listViewOverrides}, are answered from what it
* stored, narrowed to their object; with nothing stored they ask for their one
* object's views instead of this list (objectstack#29550, see the adapter's
* `readObjectViewRows`). The settled entry is dropped by every path that
* should see a fresh list:
*
* - every view write: {@link ObjectStackAdapter.invalidateViewKeys}, the client
* write wrap in `installViewListShare`, and app-shell's console
* `MetadataClient` (`useMetadataClient`), which drops it after each of its
* view-capable write doors;
* - a user refresh (app-shell `ObjectView`'s `refreshData`);
* - an organization or session switch, and the publish pulse, which clear the
* whole cache (app-shell `MetadataProvider`).
*
* The draft-overlaid list (`listViews` with `previewDrafts`) is a different
* request on a different client, and never fills this key.
*/
const VIEW_LIST_KEY = 'list:view';
/**
* Whether a key handed to {@link ObjectStackAdapter.invalidateCache} names an
* item an app bundle holds (objectstack#27627), in every spelling the cache's
* keys and its host callers use: a bare object name (this adapter's object
* key), `view:OBJECT:NAME`, `TYPE:NAME` (app-shell `MetadataService`), and a
* type's list key (`list:TYPE`, {@link VIEW_LIST_KEY}), which names every
* item of that type.
*/
function cacheKeyNamesBundledItem(key: string, bundles: AppBundleStore): boolean {
if (!key.includes(':')) return bundles.holds('object', key);
const [head, ...rest] = key.split(':');
if (head === 'list') return bundles.holdsType(rest.join(':'));
if (head === 'view' && rest.length >= 2) return bundles.holds('view', rest.slice(1).join(':'));
return bundles.holds(head, rest.join(':'));
}
const discoveryCache = new Map<string, Promise<unknown>>();
/**
* The server ORIGIN a `baseUrl` names: no trailing slash, and no trailing