-
Notifications
You must be signed in to change notification settings - Fork 10
Expand file tree
/
Copy pathview.zod.ts
More file actions
5368 lines (5138 loc) · 295 KB
/
Copy pathview.zod.ts
File metadata and controls
5368 lines (5138 loc) · 295 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
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
/**
* View protocol schemas — the `view` metadata type and its three persisted body spellings.
*
* Covers the authoring surfaces (`defineView`, `defineViewItem`), the wire
* doors Studio and the REST layer write through, and
* {@link ViewMetadataSchema} — the union every persisted `view` body is judged
* by.
*
* ## Name grammar depends on the body spelling
*
* {@link ViewMetadataSchema} is a union over three persisted body shapes, and
* they do not share one `name` grammar. Which grammar applies is decided by the
* shape of the body — something an author never names explicitly — so neither
* failure direction below is discoverable from the key being written:
*
* | body spelling | recognised by | `name` is declared as | flat (undotted) name |
* |:---|:---|:---|:---|
* | standalone **ViewItem record** | a nested `config` | {@link ViewItemNameSchema} — `QUALIFIED_ITEM_NAME_PATTERN`, dot REQUIRED | **rejected**, located at `["name"]` |
* | flattened runtime **overlay** | an inline view config; no `config`, no container slot | `z.string().optional()` — no grammar at all | accepted |
* | `defineView` **container** | a container slot (`list` / `form` / `listViews` / `formViews`) | `z.string().optional()` on {@link ViewSchema} — no grammar at all | accepted, and normally IS flat |
*
* The two permissive rows are deliberate, not gaps left to tighten later:
*
* - A container's own name is the **bare object key**. ADR-0017 §3.2's
* dual-read loader registers the aggregated container under `<object>` and
* each expanded item under `<object>.<viewKey>`, so an object-scoped
* container is named `crm_lead` — a name with no dot to carry.
* - An overlay's name is **stamped by the write path**, not authored:
* `normalizeViewMetadata` puts it on every view body at the single write
* chokepoint, and a personalization PUT inherits the identity of the entry it
* shadows.
*
* So both of the readings an author naturally forms are wrong:
*
* - *"the dot is mandatory on every view row"* — read off
* {@link ViewItemNameSchema} alone. It is not: overlay and container rows
* accept flat names, and rows in this repo legitimately use them
* (`case_grid`, `cases`, the container row `crm_lead`). Those rows are
* correct as written, not defects awaiting a dotted rewrite.
* - *"flat names are fine generally"* — read off one of those flat-named rows.
* It is not: put the same name on a standalone ViewItem record and it is
* refused, on the one field the flat-named row told you to fill.
*
* This describes what the three shapes already do; it widens and narrows
* nothing. The one item-name grammar itself lives in
* `shared/identifiers.zod.ts` — grammar changes belong there, not here.
*/
import { z } from 'zod';
import { ProtectionSchema } from '../shared/protection.zod';
import { MetadataProtectionFields } from '../kernel/metadata-protection.zod';
import { strictObject, strictObjectError } from '../shared/strict-object';
import { SnakeCaseIdentifierSchema, QUALIFIED_ITEM_NAME_PATTERN } from '../shared/identifiers.zod';
import { ExpressionInputSchema } from '../shared/expression.zod';
import { normalizeVisibleWhen, VISIBILITY_STRICT_OPTIONS } from '../shared/visibility';
import { SELECT_OPTION_EDITABILITY_GUIDANCE, VISIBILITY_ONLY_STRICT_OPTIONS } from '../shared/editability-boundary';
// [#13855] The section → field-group reference form, shared with the
// `record:details` section shape (component.zod.ts) so one mixing rule serves
// both layout escape hatches.
import { SectionGroupKeySchema, sectionGroupReferenceRefinement } from '../shared/section-group-reference';
import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod';
import { ChartTypeSchema } from './chart.zod';
import { SharingConfigSchema } from './sharing.zod';
import { retiredKey } from '../shared/retired-key';
import { FieldType, SelectOptionSchema } from '../data/field.zod';
import { BulkActionDefSchema } from './bulk-action.zod';
/**
* HTTP Method Enum & HTTP Request Schema
* Migrated to shared/http.zod.ts. Re-exported here for backward compatibility.
*/
import { HttpMethodSubsetSchema, HttpRequestSchema } from '../shared/http.zod';
import { lazySchema } from '../shared/lazy-schema';
/**
* Shared history for this file (#4001).
*
* Views are the surface an author iterates on visually, which is exactly why a
* dropped key hides here: the view still renders, just not the way it was
* described. `FormFieldBaseSchema` / `FormSectionSchema` / `FormButtonConfig`
* were closed years ago (ADR-0089 D3a); the other forty-odd shapes in this file
* kept the posture those three were rescued from.
*/
const VIEW_HISTORY =
'Until these shapes were closed an unknown key was dropped silently — the view still '
+ 'rendered, without whatever the key was meant to configure.';
export { HttpMethodSubsetSchema, HttpRequestSchema };
/**
* [#4688] `HttpRequest` is RE-EXPORTED from its one declaration in
* `shared/http.zod.ts` — never re-inferred here.
*
* The line this replaces was `export type HttpRequest = z.infer< typeof
* HttpRequestSchema >` in the alias block at the bottom of this file. It looked
* single-source: it inferred from the very schema object imported above, so the
* resolved shape was identical. But it was a SECOND type declaration carrying
* one name, and symbol identity — not shape — is what
* `check:dual-source-exports` measures, and what an auto-import or a model
* completion resolves by. That is how `./shared` and `./ui` came to name two
* different declarations `HttpRequest` (the #4411 trap). A re-export keeps every
* existing `import type { HttpRequest } from '@objectstack/spec/ui'` working
* while leaving exactly one declaration that could ever diverge.
*/
export type { HttpRequest } from '../shared/http.zod';
/**
* [#4691, renamed at #5832] The type of `HttpMethodSubsetSchema` is
* `HttpMethodSubset`, RE-EXPORTED from its one declaration in
* `shared/http.zod.ts`.
*
* `./ui` used to export that same 5-value type under the name `HttpMethod`
* (`export type HttpMethod = z.infer< typeof HttpMethodSchema >`, in the alias
* block at the bottom of this file). That name is already taken across the
* package by a DIFFERENT declaration — `shared/http.zod.ts`'s 7-value
* `z.enum([… 'HEAD', 'OPTIONS'])`, exported by `./shared` and `./api` — so one
* name resolved to two incompatible types depending on the import path (the
* #4411 trap; the last row of `dual-source-exports.baseline.json`).
*
* Converging by re-exporting `./shared`'s `HttpMethod` here — the fix #4688
* used for `HttpRequest` — would have been WRONG: it silently widens `./ui`'s
* type from 5 values to 7 while `HttpRequestSchema.method` still validates
* against the 5-value subset. `method: 'HEAD'` would type-check and then throw
* at `.parse()` — the type would start lying about the runtime. So the NAME is
* dropped from `./ui` instead, and the 5-value type carries a name of its own.
*
* #4691 spelled that name `HttpMethodType`, which was merely the name left
* over once `HttpMethod` was taken. #5832 renamed the whole trio to
* `HttpMethodSubsetSchema` / `HttpMethodSubset` / `<category>/HttpMethodSubset`
* because the CONST still collided one layer down: `schemaNameFromExportKey`
* strips the `Schema` suffix, so `HttpMethodSchema` published as
* `shared/HttpMethod` and overwrote the 7-value enum's own def.
*
* Re-exported here (rather than only left in `./shared`) so the shortest fix
* for `import type { HttpMethod } from '@objectstack/spec/ui'` is also the
* CORRECT one: TypeScript's "did you mean" points at `HttpMethodSubset` in the
* same entry point, instead of tempting a path swap to `./shared`, where the
* name `HttpMethod` does still exist and means the wider 7-value enum.
*/
export type { HttpMethodSubset } from '../shared/http.zod';
/**
* View Data Source Configuration
* Supports three modes:
* 1. 'object': Standard Protocol - Auto-connects to ObjectStack Metadata and Data APIs
* 2. 'api': Custom API - Explicitly provided API URLs
* 3. 'value': Static Data - Hardcoded data array
*/
export const ViewDataSchema = lazySchema(() => z.discriminatedUnion('provider', [
strictObject({
surface: 'this `object` data source',
history: VIEW_HISTORY,
// `objectName` is the canonical spelling on the QUERY surface
// (`data/query.zod.ts`); the view data source names it `object`, and an
// author moving between the two writes the neighbouring word rather than a
// typo edit distance could reach.
// NOT aliased: a bare `name`. It is a real key on the view ITEM
// (`ViewItemSchema.name`), so an author who wrote it here may have meant
// the view's name, not the object's — and this campaign's own finding 7 is
// that a confidently wrong prescription is worse than none.
aliases: { objectName: 'object' },
}, {
provider: z.literal('object'),
object: z.string().describe('Target object name'),
}),
strictObject({
surface: 'this `api` data source',
history: VIEW_HISTORY,
// The HTTP verbs are the request BLOCKS' business (`read`/`write` each hold
// an `HttpRequestSchema`), so an author who put the whole request inline is
// pointed at the block that owns it rather than at a near-miss key.
guidance: {
url: 'Set the URL inside the request block: `read: { url, method }` (or `write: { … }`) — the data source itself declares only `read` / `write`.',
method: 'Set the method inside the request block: `read: { url, method }` (or `write: { … }`).',
fetch: 'Use `read` for the fetch request and `write` for the submit request.',
submit: 'Use `write` for the submit request (and `read` for the fetch request).',
},
}, {
provider: z.literal('api'),
read: HttpRequestSchema.optional().describe('Configuration for fetching data'),
write: HttpRequestSchema.optional().describe('Configuration for submitting data (for forms/editable tables)'),
}),
strictObject({
surface: 'this `value` data source',
history: VIEW_HISTORY,
// `data`/`rows`/`records` are the words the surrounding surfaces use for a
// row set; on this provider the static array is `items`.
aliases: { data: 'items', rows: 'items', records: 'items', values: 'items' },
}, {
provider: z.literal('value'),
items: z.array(z.unknown()).describe('Static data array'),
}),
/**
* Schema-bound data source — used by standalone forms whose data is
* shaped by a JSON Schema (or Zod-derived schema) rather than by an
* ObjectQL object. Powers the metadata editor, action input dialogs,
* and any Form that is not bound to a CRUD object.
*/
strictObject({
surface: 'this `schema` data source',
history: VIEW_HISTORY,
// `schemaId` and `schema` are one key apart in prose but not in edit
// distance, and the pair is genuinely confusable: one NAMES a schema the
// server resolves, the other INLINES it.
aliases: { type: 'schemaId', metadataType: 'schemaId', jsonSchema: 'schema' },
}, {
provider: z.literal('schema'),
/** Schema identifier (e.g. metadata type name "report"). Resolved at runtime against /meta entries. */
schemaId: z.string().describe('Schema identifier — typically the metadata type name'),
/** Optional inline JSON Schema; when omitted the runtime resolves schemaId from the server. */
schema: z.record(z.string(), z.unknown()).optional().describe('Inline JSON Schema (Draft 2020-12). Optional when schemaId is resolvable.'),
}),
]));
/**
* Canonical filter operators for view filter rules.
*
* This is the SINGLE authoring vocabulary for `ViewFilterRule.operator`.
* Exposing it as an enum (rather than a free `z.string()`) lets JSON-Schema
* consumers — notably ObjectUI's SchemaForm — auto-render an operator
* dropdown, and rejects genuinely unknown operators at parse time.
*
* Unary operators (`is_empty`, `is_not_empty`, `is_null`, `is_not_null`) take
* no `value`. `before` / `after` are the date-friendly spellings of
* `less_than` / `greater_than`; `between` expects a two-element `value` array.
*
* `icontains` (#8934) is the case-insensitive twin of `contains`: an ASCII-only
* fold on both sides (#4706 Q1 = A — `café` does NOT match `CAFÉ`), comparand
* LITERAL (`%`, `_` and regex metacharacters are ordinary characters). It
* lowers to `$icontains`, which LIKE-escapes the comparand — it is NOT a
* spelling of `ilike`/`$ilike`, which takes a raw LIKE pattern, and the two
* must never be aliased onto each other (the #7536 boundary, restated on
* `AST_OPERATOR_MAP` in `data/filter.zod.ts`). There is deliberately no
* negative form: the `$` dialect has no `$notIcontains`, and this vocabulary
* mirrors the executed set rather than widening it.
*
* Note: relative-date operators (`this_quarter`, `last_7_days`, …) are NOT
* filter-rule operators — they are date-range presets and live on dashboard
* date-range config (`DashboardFilterSchema.defaultRange`), not here.
*/
export const VIEW_FILTER_OPERATORS = [
'equals', 'not_equals',
'contains', 'not_contains', 'icontains',
'starts_with', 'ends_with',
'greater_than', 'less_than',
'greater_than_or_equal', 'less_than_or_equal',
'in', 'not_in',
'is_empty', 'is_not_empty',
'is_null', 'is_not_null',
'before', 'after', 'between',
] as const;
export type ViewFilterOperator = (typeof VIEW_FILTER_OPERATORS)[number];
/**
* The operators whose `value` is a LIST rather than a scalar (#6227).
*
* These are the authoring spellings that lower to `$in` / `$nin`
* (`AST_OPERATOR_MAP`, `data/filter.zod.ts`), which
* {@link https://github.com/objectstack-ai/objectstack/issues/5869 | the runtime
* gate} requires to be arrays. Exported so a producer can ask the question the
* schema asks instead of hard-coding its own list: `@object-ui`'s filter builder
* decides `isMultiOperator` from a local `["in", "notIn"]` literal
* (`components/src/custom/filter-builder.tsx`), a second dialect of exactly this
* fact that is already one spelling adrift — `notIn` is an alias, not the
* canonical member. One declared vocabulary, same reasoning as
* {@link VIEW_FILTER_OPERATOR_ALIASES}.
*/
export const VIEW_FILTER_LIST_VALUE_OPERATORS = [
'in', 'not_in',
] as const satisfies readonly ViewFilterOperator[];
/**
* The operators whose `value` is a two-element `[min, max]` array (#6227).
*
* Separate from {@link VIEW_FILTER_LIST_VALUE_OPERATORS} because the check is
* different in kind: membership takes ANY arity (`[]` included), a range takes
* exactly two bounds.
*/
export const VIEW_FILTER_PAIR_VALUE_OPERATORS = [
'between',
] as const satisfies readonly ViewFilterOperator[];
/**
* Legacy operator spellings normalized to the canonical vocabulary above.
*
* These are historical shorthand (`eq`, `gt`) and camelCase (`notEquals`,
* `greaterThan`) forms that older authoring tools and already-stored view
* metadata may still carry. They are folded to canonical on parse so every
* downstream consumer sees exactly one vocabulary — one strict contract, not
* N dialects. Deprecated: new producers MUST emit the canonical forms; these
* aliases are a migration bridge and may be dropped in a future major.
*
* `icontains` (#8934) deliberately has NO rows here: this table bridges
* spellings that already live in stored metadata, and a canonical operator
* born after the table has none — inventing "synonyms" for it would widen the
* authoring surface rather than bridge a legacy one. Single-token canonicals
* (`contains`, `in`, `between`) carry no camelCase/squashed folds for the same
* reason: the folds exist per measured legacy spelling, not per operator.
*/
export const VIEW_FILTER_OPERATOR_ALIASES: Record<string, ViewFilterOperator> = {
eq: 'equals',
ne: 'not_equals', neq: 'not_equals', notequals: 'not_equals', notEquals: 'not_equals',
notcontains: 'not_contains', notContains: 'not_contains',
startswith: 'starts_with', startsWith: 'starts_with',
endswith: 'ends_with', endsWith: 'ends_with',
gt: 'greater_than', greaterthan: 'greater_than', greaterThan: 'greater_than',
lt: 'less_than', lessthan: 'less_than', lessThan: 'less_than',
gte: 'greater_than_or_equal', greaterorequal: 'greater_than_or_equal',
greaterOrEqual: 'greater_than_or_equal', greaterThanOrEqual: 'greater_than_or_equal',
lte: 'less_than_or_equal', lessorequal: 'less_than_or_equal',
lessOrEqual: 'less_than_or_equal', lessThanOrEqual: 'less_than_or_equal',
nin: 'not_in', notin: 'not_in', notIn: 'not_in',
isempty: 'is_empty', isEmpty: 'is_empty',
isnotempty: 'is_not_empty', isNotEmpty: 'is_not_empty',
isnull: 'is_null', isNull: 'is_null',
isnotnull: 'is_not_null', isNotNull: 'is_not_null',
};
/**
* Fold a legacy operator spelling to its canonical form. Returns canonical
* operators unchanged, maps known aliases, and returns unknown input verbatim
* (so the enum's own validation reports it as invalid). Exported so producers
* and renderers can normalize stored metadata against the SAME canonical map
* the schema uses, instead of inventing a second dialect.
*/
export function normalizeFilterOperator(op: unknown): string {
if (typeof op !== 'string') return op as string;
if ((VIEW_FILTER_OPERATORS as readonly string[]).includes(op)) return op;
return VIEW_FILTER_OPERATOR_ALIASES[op] ?? VIEW_FILTER_OPERATOR_ALIASES[op.toLowerCase()] ?? op;
}
// ───────────────────────────────────────────────────────────────────────────
// Write-time console decorations (#5074) — the mirror of `stripReadDecorations`
// ───────────────────────────────────────────────────────────────────────────
//
// `stripReadDecorations` (`kernel/metadata-read-decorations.ts`) exists because
// the READ path stamps keys onto a served document that were never part of it,
// so a served body is not a valid input to the schema that produced it. The
// console's row builders create the same problem from the other side, and this
// is that function's write-path twin.
//
// The producer is a React list key, not a protocol decision: the filter builder
// (`components/src/custom/filter-builder.tsx:228`, re-stamped on read-back at
// `plugin-view/src/config/view-config-utils.ts:146`/`:160`) and the sort builder
// (`components/src/custom/sort-builder.tsx:68`/`:94`) both stamp
// `id: crypto.randomUUID()` on every row they render. `saveMetaItem` validates
// the PUT body and persists the AUTHORED body verbatim, so those ids reach the
// wire and the store.
//
// ⚠️ Why a `.strip()` on the wire member cannot do this job — the #4001 批 18 /
// #5114 finding, and the reason this vocabulary exists at all: **`.strip()` does
// not recurse, any more than `.strict()` does.** Re-opening a union member
// re-opens its TOP level; every nested block is still reached through it at that
// block's own posture. `filter[]` and `sort[]` are nested blocks, so a top-level
// reopen leaves them 422ing the platform's own writes. Removing the decoration
// BEFORE validation is what makes the wire opening recursive-effective, and it
// is the only one of the two routes that does not require the authoring surface
// to declare a UI artifact (批 18 Q1: declaring `id` teaches an AI author to
// emit a UUID for a filter rule — a `??` fallback wearing a schema).
//
// Deliberately NOT solved by a second parallel schema tree: a hand-maintained
// wire twin of every carrier of `ViewFilterRuleSchema` is a second copy of the
// truth (PD#12's fork), and it rots silently the first time someone adds a new
// carrier. One declared vocabulary, applied at the wire door, covers every
// carrier that exists today and every one added later.
/** Keys the console stamps onto builder ROWS, which are therefore never authored. */
export const VIEW_CONSOLE_ROW_DECORATIONS = ['id'] as const;
/**
* Keys whose array value holds console builder rows. Both are written by a
* row-per-entry widget that needs a stable React key; neither element shape
* declares `id`, on any surface, by design.
*/
const VIEW_DECORATED_ROW_CARRIERS: readonly string[] = ['filter', 'sort'];
/** Depth guard — a view body is a bounded document, not a general graph. */
const VIEW_DECORATION_MAX_DEPTH = 12;
/** The prescription an authored `id` gets on a row shape. Shared by both sites. */
const VIEW_CONSOLE_ROW_ID_GUIDANCE =
'`id` is a console row key (the filter/sort builders stamp a `crypto.randomUUID()` '
+ 'per row for React) — it is not part of the authoring contract, and the write path '
+ 'removes it before validating. Delete it from authored metadata.';
function stripRowDecorations(value: unknown, isRow: boolean, depth: number): unknown {
if (depth > VIEW_DECORATION_MAX_DEPTH || !value || typeof value !== 'object') return value;
if (Array.isArray(value)) {
let changed = false;
const next = value.map((el) => {
const out = stripRowDecorations(el, isRow, depth + 1);
if (out !== el) changed = true;
return out;
});
return changed ? next : value;
}
const dict = value as Record<string, unknown>;
let next: Record<string, unknown> | undefined;
if (isRow) {
for (const k of VIEW_CONSOLE_ROW_DECORATIONS) {
if (k in dict) {
next ??= { ...dict };
delete next[k];
}
}
}
for (const [k, v] of Object.entries(next ?? dict)) {
const out = stripRowDecorations(v, VIEW_DECORATED_ROW_CARRIERS.includes(k), depth + 1);
if (out !== v) {
next ??= { ...dict };
next[k] = out;
}
}
return next ?? value;
}
/**
* Remove {@link VIEW_CONSOLE_ROW_DECORATIONS} from the builder rows of a `view`
* body, at every depth they occur — `filter[]` / `sort[]` under a flattened
* overlay, under a ViewItem's `config`, under `userFilters.tabs[]`, under
* `tabs[]`, and under any carrier added later.
*
* A **silent** removal, for the same reason `stripReadDecorations` is silent:
* this is our own UI's decoration riding on a document that is otherwise exactly
* what the author meant, so rejecting it would be hostile. It runs on the WIRE
* door only ({@link ViewMetadataSchema}) — {@link defineViewItem} and the other
* authoring doors keep rejecting the key by name, which is the whole point of
* the split.
*
* Nothing is lost at rest: `saveMetaItem` persists the ORIGINAL body, so the
* console still reads its own ids back.
*
* Returns the SAME reference when there is nothing to strip, so the common path
* allocates nothing. Non-object inputs pass through — the schema owns those.
*/
export function stripViewConsoleDecorations(body: unknown): unknown {
return stripRowDecorations(body, false, 0);
}
/** `string` / `number` / `an array of 3` / `null` … — the word the refusal uses. */
function describeFilterValue(value: unknown): string {
if (value === null) return 'null';
if (value === undefined) return 'no value';
if (Array.isArray(value)) return `an array of ${value.length}`;
return `a ${typeof value}`;
}
/**
* A short, bounded rendering of the offending value.
*
* Bounded for the reason the runtime twin's `shapePreview` is: the value can be
* arbitrarily large, and the message is for a human reading a refusal, not a
* dump.
*/
function previewFilterValue(value: unknown): string {
if (value === undefined) return '(omitted)';
let text: string;
try {
text = JSON.stringify(value) ?? String(value);
} catch {
text = String(value);
}
return text.length > 40 ? `${text.slice(0, 39)}…` : text;
}
/**
* [#6227] `value` must have the shape the rule's OPERATOR can execute.
*
* ## The two-stage failure this closes
*
* `{ field: 'stage', operator: 'not_in', value: 'won' }` — a set operator with a
* scalar comparand — was a spec-VALID `ViewFilterRule`: `value` declared
* `string | number | boolean | null | (string | number)[]` with no coupling to
* `operator`, so every operator accepted every shape. The view published cleanly
* and then failed at QUERY time, where #5869 / PR #6209 had already closed the
* runtime half: `assertListComparandShapes` (`@objectstack/objectql`,
* `filter-comparand-shape.ts`) refuses the lowered `{ stage: { $nin: 'won' } }`
* with a named 400 `INVALID_FILTER`. Correct refusal, wrong moment — the author
* is gone by then, and before #6209 the same shape was a 500. That file's own
* module docblock names this schema as the reachable authoring source of the
* defect.
*
* ## Why this mirrors the runtime gate EXACTLY, and refuses to go further
*
* The checks below are `assertListComparandShapes`' three constraints, one for
* one: `$in`/`$nin` must be an array, `$between` must be a 2-array. Nothing else
* is judged here, deliberately — #5685 already ruled on the opposite error, where
* `FieldOperatorsSchema` declared `$gt` as `number | Date | FieldReference` while
* every first-party producer put an ISO STRING there; the schema was ruled the
* wrong side and widened to match the runtime. A publish-time gate refusing more
* than the query path refuses would re-create that mismatch pointing the other
* way, and would reject stored metadata that executes correctly today.
* Specifically NOT refused, because the runtime does not refuse them:
*
* - **`in: []` / `not_in: []`.** An empty list is a legitimate declared predicate
* — "matches nothing" / "matches everything" — and the runtime gate says so in
* as many words. Arity is not this check's business for membership; only "is it
* a list at all".
* - **A scalar operator carrying an array** (`equals: ['a','b']`). `equals`
* lowers to a bare `{ field: value }` deep-equality comparand
* (`convertComparison`), which every backend answers.
* - **A string operator carrying a number** (`contains: 5`). Lowers to
* `$contains: 5`; no backend refuses it.
* - **A unary operator carrying a value** (`is_empty: ''`). The null predicates
* take their direction from the operator NAME — `convertComparison` maps them
* to `{ $null: true|false }` and ignores the value position entirely — and the
* ObjectUI client deliberately sends a truthy PLACEHOLDER value for both
* `isnull` and `isnotnull`. Refusing it would break a live first-party producer
* to enforce nothing.
*
* ## Why `superRefine` and not `z.discriminatedUnion` (measured, not assumed)
*
* 1. **`z.discriminatedUnion` cannot read this discriminator — it does not
* construct.** `operator` is `z.preprocess(normalizeFilterOperator, z.enum(…))`
* — the alias fold that lets a stored `notIn` / `nin` / `gt` parse. Zod 4
* extracts a discriminator's literal values from the option's own def, and a
* preprocess wrapper hides them: building the union throws
* `Invalid discriminated union option at index "0"` before any parse happens.
* The alias fold is load-bearing ({@link VIEW_FILTER_OPERATOR_ALIASES} exists
* for stored metadata) and is not negotiable to buy a union.
* 2. **A refinement adds no JSON-Schema structure.** Measured with
* `z.toJSONSchema` before and after: byte-identical output. `ui/ViewFilterRule`
* is a PUBLISHED def whose authorable key set is a ratchet of exactly three
* entries (`authorable-surface/ui.json`). A union fans that one def into N
* branches re-declaring the same three keys per branch — the phantom
* liveness-worklist inflation #7042 measured and refused for
* `ViewContainerWireSchema` one screen down — and ObjectUI's SchemaForm would
* stop rendering the single operator dropdown it renders today.
* 3. **Error quality points at the defect.** A refinement emits ONE issue at path
* `['value']` naming the operator, the received shape and the expected one. A
* union emits every branch's failure and leads with the discriminator, i.e. it
* blames `operator` for a defect that is in `value`.
*
* In Zod 4 a refinement lives INSIDE the schema rather than wrapping it in a
* `ZodEffects`, so `.shape` and the `ZodObject` class survive (measured) and
* every carrier — `z.array(ViewFilterRuleSchema)` on `ListView.filter`, a tab
* filter, `Page.filterBy`, a related-list filter and a lookup picker filter —
* keeps working untouched.
*
* ## The wording is the runtime's wording (#5240)
*
* The leading sentence is kept verbatim from `nonListComparandError` /
* `malformedRangeComparandError` so one condition keeps one wording across the
* two moments it can be reported. The TAIL deliberately differs: the runtime's
* closing fact is "the filter was NOT applied", which is false here — nothing
* ran, the metadata is being refused — so this one prescribes the fix instead.
*/
function checkViewFilterRuleValueShape(
rule: { field?: unknown; operator?: unknown; value?: unknown },
ctx: z.RefinementCtx,
): void {
// `operator` is read POST-parse, so it is already folded to canonical by
// `normalizeFilterOperator`: a stored `notIn` is `not_in` here, and this check
// never has to know the alias table.
const operator = rule.operator as ViewFilterOperator;
const value = rule.value;
const field = typeof rule.field === 'string' ? rule.field : '<field>';
const isList = (VIEW_FILTER_LIST_VALUE_OPERATORS as readonly string[]).includes(operator);
const isPair = (VIEW_FILTER_PAIR_VALUE_OPERATORS as readonly string[]).includes(operator);
if (isList) {
if (Array.isArray(value)) return;
ctx.addIssue({
code: 'custom',
path: ['value'],
message:
`Operator "${operator}" on field "${field}" requires an ARRAY of values. `
+ `Received ${describeFilterValue(value)} (${previewFilterValue(value)}). `
+ `"${operator}" tests membership of a list — write `
+ `${value === undefined ? '["…"]' : previewFilterValue([value])} for a single value, `
+ `or use ${operator === 'in' ? '"equals"' : '"not_equals"'} to compare against it. `
+ `An empty list [] is allowed and is a real predicate. This is refused at authoring `
+ `time because the query path refuses it too (400 INVALID_FILTER).`,
});
return;
}
if (!isPair) return;
if (Array.isArray(value) && value.length === 2) return;
ctx.addIssue({
code: 'custom',
path: ['value'],
message:
`Operator "${operator}" on field "${field}" requires a [min, max] value array. `
+ `Received ${describeFilterValue(value)} (${previewFilterValue(value)}). `
+ `A range needs exactly two bounds, in order. This is refused at authoring time `
+ `because the query path refuses it too (400 INVALID_FILTER).`,
});
}
/**
* View Filter Rule Schema
* Standardized filter condition used in list views, tabs, and page-level filters.
* Uses a declarative array-of-objects format: [{ field, operator, value }].
*
* ⚠️ [#5074] CLOSED — this is the authoring shape, and it rejects the console's
* row `id`. #5114 had reopened it as a provisional hotfix, explicitly pending
* this split; that hotfix is now retired rather than left standing.
*
* **Why the reopen was needed, and why it no longer is.** The filter builder
* objectui renders stamps `id: crypto.randomUUID()` on every row it creates
* (`components/src/custom/filter-builder.tsx:228`; stamped again when a stored
* filter is read back into the builder —
* `plugin-view/src/config/view-config-utils.ts:146`/`:160`). `saveMetaItem`
* validates the PUT body and then persists the AUTHORED body verbatim, so that
* `id` is on the wire and in the store. Closed *without* a wire route, this
* shape turned every filter write carrying one into a 422 — measured on all
* three paths, including the flattened personalization overlay that is the body
* the console actually PUTs.
*
* The mechanism is the part worth carrying, and it is why a top-level reopen
* could never have rescued this block: **`.strip()` does not recurse**, any more
* than `.strict()` does. `ViewMetadataSchema` re-opens its wire members' TOP
* level only, so a nested block closed here is still reached through those
* members at full strictness. Same finding as `ListView.sort` at #4001 批 18
* (#5070), one block over.
*
* `id` is still NOT declared here. It is a React list key, not protocol:
* declaring it would put a UI artifact on the authorable surface and tell an AI
* author to generate a UUID for a filter rule — a `??` fallback wearing a
* schema. Instead the WIRE door removes it before validating, via the declared
* {@link VIEW_CONSOLE_ROW_DECORATIONS} vocabulary and
* {@link stripViewConsoleDecorations} — the write-path mirror of
* `stripReadDecorations`, and the piece that makes the wire opening
* recursive-effective where a `.strip()` cannot reach. So the authoring surface
* stays exactly three keys and the console's own writes still parse.
*
* Recorded in three places: this JSDoc, `view-filter-rule-wire-id.test.ts`, and
* the `ui/` row of `docs/audits/2026-07-unknown-key-strictness-ledger.md`.
*
* @example
* ```ts
* filter: [
* { field: 'status', operator: 'equals', value: 'active' },
* { field: 'close_date', operator: 'after', value: '2024-01-01' },
* { field: 'archived_at', operator: 'is_empty' },
* ]
* ```
*/
export const ViewFilterRuleSchema = lazySchema(() => strictObject({
surface: 'this filter rule',
history: VIEW_HISTORY,
guidance: {
id: VIEW_CONSOLE_ROW_ID_GUIDANCE,
},
}, {
/** Field name to filter on */
field: z.string().describe('Field name to filter on'),
/**
* Filter operator (canonical vocabulary). Legacy shorthand/camelCase
* spellings (`eq`, `gt`, `isNull`, …) are accepted and normalized to
* canonical on parse.
*/
operator: z.preprocess(normalizeFilterOperator, z.enum(VIEW_FILTER_OPERATORS))
.describe('Filter operator'),
/**
* Filter value (optional for unary operators like is_empty, is_null).
*
* The accepted SHAPE is coupled to `operator` by
* {@link checkViewFilterRuleValueShape} (#6227).
*/
value: z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(z.union([z.string(), z.number()]))])
.optional().describe(
'Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an '
+ 'array (any length, including []), `between` takes exactly [min, max], every other '
+ 'operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / '
+ 'is_not_null) take their direction from the operator name and ignore this key.',
),
}).superRefine(checkViewFilterRuleValueShape).describe('View filter rule'));
export type ViewFilterRule = z.input<typeof ViewFilterRuleSchema>;
/** Post-parse shape of {@link ViewFilterRule} — defaults applied, transforms run (ADR-0122). */
export type ViewFilterRuleParsed = z.infer<typeof ViewFilterRuleSchema>;
/**
* Column Summary Function Schema
* Aggregation function for column footer (Airtable-style column summaries)
*
* On a GROUPED list view the same declaration is the per-group HEADER summary
* (#14556, ruling A on objectui#7189: grouping is server-side, so a group's
* numbers are properties of the query, not of the fetched page). The header
* is one aggregate query in the query AST's own vocabulary — `AggregationFunction`
* (`data/query.zod.ts`), the vocabulary datasets already use, one and not two
* (objectui#4576) — and this enum maps onto it in
* `view-grouping-query.ts` (`COLUMN_SUMMARY_AGGREGATION`):
*
* * `count` → a fieldless `count` (`COUNT(*)`, every row of the group — the
* group count itself), `count_unique` → `count_distinct`, and
* `sum` / `avg` / `min` / `max` → the same name; `none` declares nothing.
* * `count_filled` / `count_empty` / `percent_filled` / `percent_empty` map
* by DERIVATION (seat ruling on fork i, contract review of #14556): one
* `{ function: 'count', field }` node — `COUNT(field)`, the non-null count
* — rides the header row as `count_<field>`, and the four are computed
* from it and the group count by `deriveColumnSummary`: `count_filled` =
* `count_<field>`, `count_empty` = `count − count_<field>`,
* `percent_filled` = `count_<field> / count` (0 when the count is 0),
* `percent_empty` = `1 − percent_filled`. "Empty" is the SERVER's meaning
* on every face — the stored value is `null` (`aggregation-conformance`);
* the footer's client-side reading, which also treats `''` and `[]` as
* empty, is objectui's to converge under "one vocabulary".
*
* ⛔ Adding a member here without deciding its row in
* `COLUMN_SUMMARY_AGGREGATION` is a type error by construction, so the table
* cannot widen silently; a member whose row says "no counterpart" is refused
* loudly by `compileListViewGroupQuery` (`NOT_IMPLEMENTED` / 501, with the
* path of the summary) — none is in that state today.
*/
export const ColumnSummarySchema = lazySchema(() => z.enum([
'none',
'count',
'count_empty',
'count_filled',
'count_unique',
'percent_empty',
'percent_filled',
'sum',
'avg',
'min',
'max',
]).describe(
// The tracking card for the open mapping question is named in the JSDoc
// above; `.describe()` prose reaches readers who cannot resolve an issue id.
'Aggregation function for the column footer summary — and, on a grouped list view, the per-group '
+ 'header summary (server-side): count (COUNT(*), the group count), count_unique '
+ '(count_distinct), sum, avg, min, max map onto the query AST\'s AggregationFunction; '
+ 'count_filled, count_empty, percent_filled, percent_empty derive from one COUNT(field) node (the '
+ 'non-null count) and the group count — count_filled = COUNT(field), count_empty = count − COUNT(field), '
+ 'percent_filled = COUNT(field) / count (0 when count is 0), percent_empty = 1 − percent_filled. '
+ 'Server-side "empty" is null on every face; the footer\'s client-side reading of empty strings and '
+ 'empty arrays as empty is the renderer\'s to converge',
));
/**
* Column Summary Configuration Schema
*
* The object form of `ListColumn.summary`. Use it when the footer aggregates a
* field OTHER than the column's own — e.g. an `amount` column whose footer sums
* `amount_in_base_currency`. The shorthand (`summary: 'sum'`) stays the common
* case and always aggregates the column's own `field`.
*
* `type` reuses `ColumnSummarySchema`, so both forms share one aggregation
* vocabulary and cannot drift apart.
*/
export const ColumnSummaryConfigSchema = lazySchema(() => strictObject({
surface: 'this column summary configuration',
history: VIEW_HISTORY,
}, {
type: ColumnSummarySchema.describe('Aggregation function'),
field: z.string().optional().describe('Field to aggregate (defaults to the column field)'),
}).describe('Column footer summary configuration'));
/**
* Column Prefix Configuration Schema (Airtable-style compound cells)
*
* Renders a second field's value inline before the cell value — e.g. a status
* badge in front of the record name — so a list can carry two signals in one
* column without spending a second column on it.
*/
export const ColumnPrefixSchema = lazySchema(() => strictObject({
surface: 'this column prefix',
history: VIEW_HISTORY,
}, {
field: z.string().describe('Field whose value renders before the cell value'),
type: z.enum(['badge', 'text']).default('text').describe('How the prefix value is rendered'),
}).describe('Compound-cell prefix configuration'));
/**
* List Column Configuration Schema
* Detailed configuration for individual list view columns
*/
export const ListColumnSchema = lazySchema(() => strictObject({
surface: 'this list column',
history: VIEW_HISTORY,
}, {
field: z.string().describe('Field name (snake_case)'),
label: I18nLabelSchema.optional().describe('Display label override'),
width: z.number().positive().optional().describe('Column width in pixels'),
align: z.enum(['left', 'center', 'right']).optional().describe('Text alignment'),
hidden: z.boolean().optional().describe('Hide column by default'),
sortable: z.boolean().optional().describe('Allow sorting by this column'),
resizable: z.boolean().optional().describe('Allow resizing this column'),
wrap: z.boolean().optional().describe('Allow text wrapping'),
type: z.string().optional().describe('Renderer type override (e.g., "currency", "date")'),
/** Pinning (Airtable-style frozen columns) */
pinned: z.enum(['left', 'right']).optional().describe('Pin/freeze column to left or right side'),
/** Column Footer Summary (Airtable-style aggregation) */
summary: z.union([ColumnSummarySchema, ColumnSummaryConfigSchema]).optional()
.describe('Footer aggregation for this column — the function alone, or { type, field } to aggregate another field'),
/** Compound cell (Airtable-style): render another field inline before the value */
prefix: ColumnPrefixSchema.optional().describe('Field rendered inline before this cell value'),
/** Interaction */
link: z.boolean().optional().describe('Functions as the primary navigation link (triggers View navigation)'),
action: z.string().optional().describe('Registered Action ID to execute when clicked'),
}));
/**
* List View Selection Configuration
*/
export const SelectionConfigSchema = lazySchema(() => strictObject({
surface: 'this selection configuration',
history: VIEW_HISTORY,
}, {
type: z.enum(['none', 'single', 'multiple']).default('none').describe('Selection mode'),
}));
/**
* List View Pagination Configuration
*/
export const PaginationConfigSchema = lazySchema(() => strictObject({
surface: 'this pagination configuration',
history: VIEW_HISTORY,
}, {
pageSize: z.number().int().positive().default(25).describe('Number of records per page'),
pageSizeOptions: z.array(z.number().int().positive()).optional().describe('Available page size options'),
}));
/**
* Row Height / Density Schema (Airtable-style)
* Controls the visual density of rows in a list view.
*/
export const RowHeightSchema = lazySchema(() => z.enum([
'compact', // Minimal padding, single line
'short', // Reduced padding
'medium', // Default padding
'tall', // Extra padding, multi-line preview
'extra_tall', // Maximum padding, rich content preview
]).describe('Row height / density setting for list view'));
/**
* Grouping Field Configuration
* Defines a single grouping level for record grouping.
*
* `field` is one `groupBy` column of the group header query
* ({@link GroupingConfigSchema}); the header row carries its RAW STORED value
* under the field's own name — a lookup's group key is the referenced id, the
* empty group's key is `null`. `order` and `collapsed` are presentation:
* `EngineAggregateOptions` carries no `orderBy`, so the consumer sorts the
* header rows (a set the size of the group count) and folds/unfolds them.
*/
export const GroupingFieldSchema = lazySchema(() => strictObject({
surface: 'this grouping field',
history: VIEW_HISTORY,
}, {
field: z.string().describe('Field name to group by — one `groupBy` column of the group header query; the header row carries its raw stored value (null for the empty group)'),
order: z.enum(['asc', 'desc']).default('asc').describe('Group sort order — applied by the consumer over the header rows (the aggregate query carries no orderBy)'),
collapsed: z.boolean().default(false).describe('Collapse groups by default (presentation only)'),
}));
/**
* Grouping Configuration Schema (Airtable-style)
* Supports multi-level grouping for grid/gallery views.
*
* ## Grouping is SERVER-SIDE (#14556)
*
* Maintainer ruling A on objectui#7189 (2026-09-02): *the set of groups and
* every number in a group header (the count and any per-group aggregation)
* are properties of the query, not of the fetched page; rows inside a group
* are paged.* Grouping the rows of one fetched window — what a grouped grid
* did before this contract — rendered two headers (86, 14) or five
* (31/31/30/7/1) for the same 186 rows in five units depending on row order,
* and left the rows past the first window unreachable. That is the interim
* state, not the contract.
*
* What the platform returns for a grouped list view, in the vocabulary the
* query AST already declares (seat ruling: reuse, no new query shape):
*
* 1. **The group keys and every header number — ONE aggregate query**
* (`EngineAggregateOptions`, executed by `IDataEngine.aggregate`):
* `groupBy` = `fields[].field` in nesting order (multi-level grouping is
* a multi-column `groupBy`), `aggregations` = a `count` node (the group's
* TOTAL row count, alias `count`) plus the view's declared column
* summaries (`ListColumn.summary`, mapped onto `AggregationFunction` — see
* {@link ColumnSummarySchema}), `where` = the view's composed filter.
* One header row per group, keyed by the grouped fields' own names.
* 2. **The rows inside a group — the EXISTING paged `find`**
* (`EngineQueryOptions`, `IDataEngine.find`) with the group's key
* predicate AND-ed into the same view filter, `limit` / `offset` per
* group (`$top` / `$skip` on the wire).
* 3. No new engine verb, no new envelope.
*
* The checkable form of this contract is `view-grouping-query.ts` —
* `compileListViewGroupQuery` (1) and `compileListViewGroupRowsQuery` (2),
* pinned on the 186-row fixture: 86/61/31/7/1 regardless of row order.
*
* ## Known limits of the shape, recorded
*
* * **Group keys are scalar-valued.** A header row carries, under each
* grouped field, one stored value — a lookup's referenced id, a select
* value, a number, a boolean, `null` for the empty group. A date /
* datetime grouping field groups per DISTINCT STORED INSTANT: there is
* no `dateGranularity` on a grouping field (the query AST's bucketed
* `groupBy` member form is not exposed here), so "by month" is not a
* list-view grouping today.
* * **Header cardinality is unbounded.** The header query answers one row
* per group, and `EngineAggregateOptions` carries neither `orderBy` nor
* `limit` — the existing door returns the whole grouped set and slices
* `limit` after aggregation. A high-cardinality grouping field therefore
* returns as many header rows as it has distinct values; bounding that is
* `orderBy` + `limit` on the aggregate verb, an engine-contract card of
* its own, never a change to `order`'s meaning.
*
* ## The door, and the follow-ons
*
* Both queries ride the data endpoint's EXISTING door: `POST
* /data/:object/query` (`packages/rest/src/rest-server.ts`) → `protocol.findData`
* (`packages/metadata-protocol/src/protocol.ts`), which routes a body carrying
* `groupBy` / `aggregations` to `engine.aggregate` and answers `{ object,
* records, total, hasMore }`; `client.data.query()` posts there and the RPC
* face declares `method: 'aggregate'`. No new route, no new wire shape.
* Follow-ons, in order: the platform half of #14556 pins that door on the
* compiled queries (the 186-row fixture through the route, on driver-sql and
* on the in-memory tier), then objectui#7189 (`plugin-grid` consumes the
* header rows and stops grouping the page).
*/
export const GroupingConfigSchema = lazySchema(() => strictObject({
surface: 'this grouping configuration',
history: VIEW_HISTORY,
}, {
fields: z.array(GroupingFieldSchema).min(1).describe('Fields to group by, in nesting order — the first entry is the outermost group and each later entry nests one level deeper (at least one field); the same order as the group header query\'s `groupBy`'),
}).describe(
'Record grouping configuration — SERVER-SIDE: the set of groups and every number in a group '
+ 'header (the count and the per-column summaries) are properties of the query, not of the fetched page, '
+ 'answered by one aggregate query (`groupBy` = the fields in nesting order, `count` + the mapped column '
+ 'summaries, the view filter); rows inside a group are paged by the existing find with the group key '
+ 'AND-ed into the view filter. Compiled by `compileListViewGroupQuery` / `compileListViewGroupRowsQuery`',
));
/**
* Gallery View Configuration (Airtable-style)
* Configures card layout for gallery/card views.
*/
export const GalleryConfigSchema = lazySchema(() => strictObject({
surface: 'this gallery configuration',
history: VIEW_HISTORY,
}, {
coverField: z.string().optional().describe('Attachment/image field to display as card cover'),
coverFit: z.enum(['cover', 'contain']).default('cover').describe('Image fit mode for card cover'),
cardSize: z.enum(['small', 'medium', 'large']).default('medium').describe('Card size in gallery view'),
titleField: z.string().optional().describe('Field to display as card title'),
visibleFields: z.array(z.string()).optional().describe('Fields to display on card body'),
}).describe('Gallery/card view configuration'));
/**
* Timeline View Configuration (Airtable-style)
* Configures timeline/chronological views.
*/
export const TimelineConfigSchema = lazySchema(() => strictObject({
surface: 'this timeline configuration',
history: VIEW_HISTORY,
}, {
startDateField: z.string().describe('Field for timeline item start date'),
endDateField: z.string().optional().describe('Field for timeline item end date'),
titleField: z.string().describe('Field to display as timeline item title'),
groupByField: z.string().optional().describe('Field to group timeline rows'),
colorField: z.string().optional().describe('Field to derive each item color from (it names a field, not a color): the option color declared on that field for the record value, else the value itself when it already is a color literal (hex, rgb() or hsl()), else the timeline default marker color'),
scale: z.enum(['hour', 'day', 'week', 'month', 'quarter', 'year']).default('week').describe('Default timeline scale'),
}).describe('Timeline view configuration'));
/**
* View Sharing Configuration (Airtable-style)
* Defines who can see and modify a view.
*/
export const ViewSharingSchema = lazySchema(() => strictObject({
surface: 'this view sharing',
history: VIEW_HISTORY,
}, {
type: z.enum(['personal', 'collaborative']).default('collaborative').describe('View ownership type'),
lockedBy: z.string().optional().describe('User who locked the view configuration'),
}).describe('View sharing and access configuration'));
/**
* Row Color Configuration (Airtable-style)
* Defines how rows are colored based on field values.
*/
export const RowColorConfigSchema = lazySchema(() => strictObject({
surface: 'this row color configuration',
history: VIEW_HISTORY,
}, {
field: z.string().describe('Field whose value is looked up in the `colors` map below to pick a row colour (typically a select/status field). The map is what does the colouring — with no `colors`, no row is ever coloured, whatever this field holds. Author-time diagnostic `view/row-color-without-colors` reports that combination.'),
colors: z.record(z.string(), z.string()).optional().describe('Map of field value to color (hex/token)'),
}).describe('Row color configuration based on field values'));
/**
* Visualization Type Schema
* Whitelist of visualization types the user can switch between.
* Maps to Airtable's "Visualizations" setting in Appearance panel.
*/