From 101b24b36178fd5af07f5953b941795644b15296 Mon Sep 17 00:00:00 2001 From: Steven RYDELL Date: Sat, 1 Aug 2026 17:54:08 +0200 Subject: [PATCH] fix(schema): track client-side schema deviations, restore schema.ts fidelity src/types/schema.ts had drifted from the official schema contract: a `clientOnly` field had been added to `FilterEnum` to support client-side log filtering. Restored it to match upstream exactly and moved the deviation-only type into a new intersection type in src/lib/schemaDeviationTypes.ts instead. Audited the codebase for every place the UI does something the official schema doesn't support and tagged each with `// SCHEMA-DEVIATION: `, documented in the new SCHEMA_DEVIATIONS.md registry (what, why, and the ideal server-side fix): - log-client-filters: Level/Event filters on Log Entries, applied client-side because x:Log/query rejects them as JMAP filters. - account-quota-usage-column: synthetic quotaUsage column on the Accounts list. - mailbox-client-hierarchy-sort: full-fetch + client-side sort to reconstruct mailbox parent/child hierarchy. - webapp-enabled-column-fallback: synthetic "Enabled" column label for x:Application when the schema list doesn't define one. Other viewName/objectName special cases (x:OtpAuth, x:Expression, x:Rate, x:Action, x:Trace, CustomComponent/*) were checked against upstream and are pre-existing architecture, not fork deviations. --- SCHEMA_DEVIATIONS.md | 84 ++++++++++++++++++++++++++++ src/components/lists/DynamicList.tsx | 23 +++++--- src/lib/accountColumns.ts | 2 + src/lib/logFilters.ts | 24 ++++++-- src/lib/schemaDeviationTypes.ts | 25 +++++++++ src/types/schema.ts | 4 -- 6 files changed, 145 insertions(+), 17 deletions(-) create mode 100644 SCHEMA_DEVIATIONS.md create mode 100644 src/lib/schemaDeviationTypes.ts diff --git a/SCHEMA_DEVIATIONS.md b/SCHEMA_DEVIATIONS.md new file mode 100644 index 0000000..cee2485 --- /dev/null +++ b/SCHEMA_DEVIATIONS.md @@ -0,0 +1,84 @@ +# Schema deviations + +Stalwart WebUI is schema-driven: the server's JSON schema is the single +source of truth for what forms, fields, filters, and columns exist. This +fork tries to stay aligned with that philosophy (see +[stalwartlabs/webui#discussion](https://github.com/stalwartlabs/webui) and +the maintainer's note on why exceptions belong server-side, not in the UI). + +Everything in this file is a deliberate exception: a place where the UI +does something the schema doesn't (yet) describe, because the equivalent +server-side capability doesn't exist in [stalwartlabs/stalwart](https://github.com/stalwartlabs/stalwart) +or [stalwartlabs/webui](https://github.com/stalwartlabs/webui). Each entry +is tagged in code with `// SCHEMA-DEVIATION: ` so they're greppable +(`grep -rn "SCHEMA-DEVIATION" src/`). + +The goal is **not** to remove these — they're real functionality this fork +wants to keep — but to track them separately from schema-driven code, so +it's always clear which is which, and so each one can be dropped the day +the server (or upstream webui) grows the equivalent native capability. + +## `src/types/schema.ts` is never touched for a deviation + +`src/types/schema.ts` mirrors the server's schema contract exactly and +must stay aligned with the official webui/server types — it is **never** +edited to accommodate a deviation, even to add an extra optional field. + +If a deviation needs to carry extra data on an otherwise-official schema +shape (e.g. a flag consumed only by the deviation's own code), the +augmented type lives in the deviation's own module or in +[`src/lib/schemaDeviationTypes.ts`](src/lib/schemaDeviationTypes.ts), as +an intersection with the official type (`OfficialType & { extra?: ... }`), +and is imported only where the deviation is actually used. `schema.ts` +itself stays byte-for-byte alignable with upstream's version of the file. + +## Status legend + +- 🟡 **Workaround** — client-only, would be removed if the server supported it natively. +- 🔵 **Upstream tracked** — an issue has been filed upstream; link included. + +## Deviations + +### `log-client-filters` 🟡 + +- **Where**: [`src/lib/logFilters.ts`](src/lib/logFilters.ts), type augmentation in [`src/lib/schemaDeviationTypes.ts`](src/lib/schemaDeviationTypes.ts) +- **What**: injects `level` and `event` as filterable columns on the `x:Log` list, applied entirely client-side (`clientOnly` flag consumed by `DynamicList`). The `clientOnly` flag is declared as `ClientOnlyFilterEnum` (an intersection type), not on the official `FilterEnum` in `schema.ts`. +- **Why**: Stalwart's JMAP `x:Log/query` returns `unsupportedFilter` for both properties today, even though they're returned per row. +- **Ideal fix**: `stalwartlabs/stalwart` accepts `level`/`event` as real query filters; the schema then advertises them normally and `logFilters.ts` + the `ClientOnlyFilterEnum` augmentation are deleted. + +### `account-quota-usage-column` 🟡 + +- **Where**: [`src/lib/accountColumns.ts`](src/lib/accountColumns.ts) +- **What**: adds a synthetic `quotaUsage` column to the `x:Account/User` list that isn't a real schema property — `DynamicList` resolves it from the `usedDiskQuota` + `quotas.maxDiskQuota` pair and formats it specially. Also re-adds `roles` (a real property, just not in the list's default columns). +- **Why**: the Accounts list schema doesn't expose usage/role as list columns, only as detail-view fields. +- **Ideal fix**: the server's `x:Account/User` list schema includes `roles` and a computed usage/quota column natively; this file is deleted. + +### `mailbox-client-hierarchy-sort` 🟡 + +- **Where**: [`src/components/lists/DynamicList.tsx`](src/components/lists/DynamicList.tsx) — `sortMailboxesByHierarchy` and the `isMailboxList` branch +- **What**: for the `Mailbox` list, fetches the *entire* result set (bypassing normal server pagination) and sorts it client-side so each parent mailbox is immediately followed by its children, with indentation depth tracked in React state. +- **Why**: the server returns mailboxes in whatever order the query produces, not grouped by parent/child, and a mailbox's parent can land on a different page than the mailbox itself, so hierarchy can't be reconstructed one page at a time. +- **Ideal fix**: the server offers a native tree/hierarchical ordering (or a `sort` that groups by ancestry) for `Mailbox/query`; the client-side full-fetch-and-sort is deleted in favor of normal paginated queries. + +### `webapp-enabled-column-fallback` 🟡 + +- **Where**: [`src/components/lists/DynamicList.tsx`](src/components/lists/DynamicList.tsx) — `displayColumns` in the `isWebApplications` branch +- **What**: reordering the `x:Application` list's real schema columns (Description first, Enabled second) is not a deviation, but if the schema's `list.columns` doesn't include an `enabled` column at all, a fallback column definition with a hardcoded label is fabricated client-side so the toggle still renders. +- **Why**: the `x:Application` list schema is not guaranteed to expose `enabled` as a list column, even though it's a real object property (fetched separately via `properties.push('enabled')`). +- **Ideal fix**: the server's `x:Application` list schema always includes `enabled` as a real column; the fallback branch is deleted (only the reordering logic remains, which is not a deviation). + +## Not a deviation (for reference) + +A few other `viewName === '...'` / `objectName === '...'` checks exist in +`DynamicList.tsx`, `MainContent.tsx`, `Sidebar.tsx`, `layout.ts`, and +`FieldWidget.tsx` (e.g. `x:OtpAuth`, `x:Expression`, `x:Rate`, `x:Action`, +`x:Trace`, `CustomComponent/Dashboard` and other `CustomComponent/*` +pages, the `x:Application` column reordering itself, the active-WebApp +info card). These are **not** tracked here: they render real schema data +with a custom widget or extra display, the same pattern already used +upstream for special object types — they don't fabricate data or bypass +the server's filtering/pagination. Verified against `upstream/main` for +each: the object/view names above already drive special-cased rendering +there too, except `x:Application`/`Mailbox`/`x:Log`/`x:Account/User` +which are fork-only and covered by the entries above (or explicitly +noted as presentation-only, e.g. the Web Applications column reorder). diff --git a/src/components/lists/DynamicList.tsx b/src/components/lists/DynamicList.tsx index d8bfa62..fc62e3e 100644 --- a/src/components/lists/DynamicList.tsx +++ b/src/components/lists/DynamicList.tsx @@ -76,6 +76,7 @@ import { import type { Schema, Field, MassAction, ItemAction, Filter as FilterDef } from '@/types/schema'; import type { JmapSetResponse, JmapSetError } from '@/types/jmap'; import type { ResolvedSchema } from '@/lib/schemaResolver'; +import { isClientOnlyFilterEnum } from '@/lib/schemaDeviationTypes'; const PAGE_SIZE = 25; const MAX_REPORTED_ERRORS = 3; @@ -87,7 +88,7 @@ const ENUM_COMBOBOX_THRESHOLD = 15; const REFRESH_COOLDOWN_MS = 5000; function isClientOnlyFilter(f: FilterDef): boolean { - return f.type === 'enum' && f.clientOnly === true; + return f.type === 'enum' && isClientOnlyFilterEnum(f); } function parseSetResponse(raw: [string, Record, string][]): JmapSetResponse | null { @@ -187,6 +188,8 @@ function formatNumber(value: unknown): string { return value.toLocaleString(); } +// SCHEMA-DEVIATION: mailbox-client-hierarchy-sort (see SCHEMA_DEVIATIONS.md) +// // Orders mailboxes so each parent is immediately followed by its // descendants (siblings alphabetical), and records each row's depth. // Requires the full set (not just one page) since a mailbox's parent @@ -502,8 +505,10 @@ export function DynamicList({ viewName }: DynamicListProps) { if (!isWebApplications) return columns; // For Web Applications, present Description first and Enabled second - // to match the layout of other tables such as Domains. If the schema - // does not expose an Enabled column, add a synthetic one. + // to match the layout of other tables such as Domains. Reordering real + // schema columns is fine, but the synthetic fallback below (when the + // schema doesn't list an Enabled column at all) is a tracked deviation. + // SCHEMA-DEVIATION: webapp-enabled-column-fallback (see SCHEMA_DEVIATIONS.md) const ordered = ['description', 'enabled']; const rest = columns.filter((c) => !ordered.includes(c.name)); const descriptionCol = columns.find((c) => c.name === 'description'); @@ -670,11 +675,13 @@ export function DynamicList({ viewName }: DynamicListProps) { if (activeClientFilters.length > 0 || isMailboxList) { // No server-side pagination possible once a client-only filter is - // active: fetch every server-matching row up front, narrow it in - // the browser, then paginate the in-memory result locally. Mailbox - // hierarchy needs this too — a mailbox's parent can land on a - // different server page than the mailbox itself, so the full set - // is required to place each row under its parent correctly. + // active (SCHEMA-DEVIATION: log-client-filters): fetch every + // server-matching row up front, narrow it in the browser, then + // paginate the in-memory result locally. Mailbox hierarchy needs + // this too (SCHEMA-DEVIATION: mailbox-client-hierarchy-sort) — a + // mailbox's parent can land on a different server page than the + // mailbox itself, so the full set is required to place each row + // under its parent correctly. const { list: fullList } = await jmapQueryAllAndGet( obj.objectName, accountId, diff --git a/src/lib/accountColumns.ts b/src/lib/accountColumns.ts index 0622713..293855d 100644 --- a/src/lib/accountColumns.ts +++ b/src/lib/accountColumns.ts @@ -7,6 +7,8 @@ import type { Schema } from '@/types/schema'; /** + * SCHEMA-DEVIATION: account-quota-usage-column (see SCHEMA_DEVIATIONS.md) + * * The Accounts list only shows Email/Full Name/Created At, hiding role and * storage usage that otherwise require opening each account individually. * `createdAt` is dropped to make room; `roles` is a real property so it diff --git a/src/lib/logFilters.ts b/src/lib/logFilters.ts index 35fe299..3946e2f 100644 --- a/src/lib/logFilters.ts +++ b/src/lib/logFilters.ts @@ -5,8 +5,11 @@ */ import type { Schema } from '@/types/schema'; +import type { ClientOnlyFilterEnum } from './schemaDeviationTypes'; /** + * SCHEMA-DEVIATION: log-client-filters (see SCHEMA_DEVIATIONS.md) + * * The Stalwart JMAP backend rejects `level`/`event` as filter conditions on * `x:Log/query` (`unsupportedFilter`), even though both properties are * already returned per row. Until the backend adds real support, these two @@ -18,17 +21,28 @@ export function withClientLogFilters(schema: Schema): Schema { const logList = schema.lists['x:Log']; if (!logList || !schema.enums['TracingLevel'] || !schema.enums['EventType']) return schema; + const levelFilter: ClientOnlyFilterEnum = { + type: 'enum', + field: 'level', + enumName: 'TracingLevel', + label: 'Level', + clientOnly: true, + }; + const eventFilter: ClientOnlyFilterEnum = { + type: 'enum', + field: 'event', + enumName: 'EventType', + label: 'Event', + clientOnly: true, + }; + return { ...schema, lists: { ...schema.lists, 'x:Log': { ...logList, - filters: [ - ...(logList.filters ?? []), - { type: 'enum', field: 'level', enumName: 'TracingLevel', label: 'Level', clientOnly: true }, - { type: 'enum', field: 'event', enumName: 'EventType', label: 'Event', clientOnly: true }, - ], + filters: [...(logList.filters ?? []), levelFilter, eventFilter], }, }, }; diff --git a/src/lib/schemaDeviationTypes.ts b/src/lib/schemaDeviationTypes.ts new file mode 100644 index 0000000..9cd7350 --- /dev/null +++ b/src/lib/schemaDeviationTypes.ts @@ -0,0 +1,25 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +import type { FilterEnum } from '@/types/schema'; + +/** + * Type augmentations for tracked schema deviations (see SCHEMA_DEVIATIONS.md). + * + * `src/types/schema.ts` mirrors the server's schema contract and must stay + * aligned with the official webui/server types — it is never edited to + * accommodate a deviation. When a deviation needs to carry extra data on an + * otherwise-official schema shape, the augmented type lives here instead, + * as an intersection with the official type, and is imported only by the + * deviation's own code. + */ + +// SCHEMA-DEVIATION: log-client-filters (see SCHEMA_DEVIATIONS.md) +export type ClientOnlyFilterEnum = FilterEnum & { clientOnly?: boolean }; + +export function isClientOnlyFilterEnum(f: FilterEnum): f is ClientOnlyFilterEnum { + return (f as ClientOnlyFilterEnum).clientOnly === true; +} diff --git a/src/types/schema.ts b/src/types/schema.ts index 20dae49..958b7e3 100644 --- a/src/types/schema.ts +++ b/src/types/schema.ts @@ -303,10 +303,6 @@ export interface FilterEnum { field: string; enumName: string; label: string; - /** Applied client-side after fetch instead of sent to the server as a JMAP - * filter condition. Used for properties the backend query engine doesn't - * (yet) support filtering on, even though it returns them per row. */ - clientOnly?: boolean; } export interface FilterInteger {