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: <id>`, 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.
This commit is contained in:
@@ -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: <id>` 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).
|
||||||
@@ -76,6 +76,7 @@ import {
|
|||||||
import type { Schema, Field, MassAction, ItemAction, Filter as FilterDef } from '@/types/schema';
|
import type { Schema, Field, MassAction, ItemAction, Filter as FilterDef } from '@/types/schema';
|
||||||
import type { JmapSetResponse, JmapSetError } from '@/types/jmap';
|
import type { JmapSetResponse, JmapSetError } from '@/types/jmap';
|
||||||
import type { ResolvedSchema } from '@/lib/schemaResolver';
|
import type { ResolvedSchema } from '@/lib/schemaResolver';
|
||||||
|
import { isClientOnlyFilterEnum } from '@/lib/schemaDeviationTypes';
|
||||||
|
|
||||||
const PAGE_SIZE = 25;
|
const PAGE_SIZE = 25;
|
||||||
const MAX_REPORTED_ERRORS = 3;
|
const MAX_REPORTED_ERRORS = 3;
|
||||||
@@ -87,7 +88,7 @@ const ENUM_COMBOBOX_THRESHOLD = 15;
|
|||||||
const REFRESH_COOLDOWN_MS = 5000;
|
const REFRESH_COOLDOWN_MS = 5000;
|
||||||
|
|
||||||
function isClientOnlyFilter(f: FilterDef): boolean {
|
function isClientOnlyFilter(f: FilterDef): boolean {
|
||||||
return f.type === 'enum' && f.clientOnly === true;
|
return f.type === 'enum' && isClientOnlyFilterEnum(f);
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseSetResponse(raw: [string, Record<string, unknown>, string][]): JmapSetResponse | null {
|
function parseSetResponse(raw: [string, Record<string, unknown>, string][]): JmapSetResponse | null {
|
||||||
@@ -187,6 +188,8 @@ function formatNumber(value: unknown): string {
|
|||||||
return value.toLocaleString();
|
return value.toLocaleString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SCHEMA-DEVIATION: mailbox-client-hierarchy-sort (see SCHEMA_DEVIATIONS.md)
|
||||||
|
//
|
||||||
// Orders mailboxes so each parent is immediately followed by its
|
// Orders mailboxes so each parent is immediately followed by its
|
||||||
// descendants (siblings alphabetical), and records each row's depth.
|
// descendants (siblings alphabetical), and records each row's depth.
|
||||||
// Requires the full set (not just one page) since a mailbox's parent
|
// 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;
|
if (!isWebApplications) return columns;
|
||||||
|
|
||||||
// For Web Applications, present Description first and Enabled second
|
// For Web Applications, present Description first and Enabled second
|
||||||
// to match the layout of other tables such as Domains. If the schema
|
// to match the layout of other tables such as Domains. Reordering real
|
||||||
// does not expose an Enabled column, add a synthetic one.
|
// 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 ordered = ['description', 'enabled'];
|
||||||
const rest = columns.filter((c) => !ordered.includes(c.name));
|
const rest = columns.filter((c) => !ordered.includes(c.name));
|
||||||
const descriptionCol = columns.find((c) => c.name === 'description');
|
const descriptionCol = columns.find((c) => c.name === 'description');
|
||||||
@@ -670,11 +675,13 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
|
|
||||||
if (activeClientFilters.length > 0 || isMailboxList) {
|
if (activeClientFilters.length > 0 || isMailboxList) {
|
||||||
// No server-side pagination possible once a client-only filter is
|
// No server-side pagination possible once a client-only filter is
|
||||||
// active: fetch every server-matching row up front, narrow it in
|
// active (SCHEMA-DEVIATION: log-client-filters): fetch every
|
||||||
// the browser, then paginate the in-memory result locally. Mailbox
|
// server-matching row up front, narrow it in the browser, then
|
||||||
// hierarchy needs this too — a mailbox's parent can land on a
|
// paginate the in-memory result locally. Mailbox hierarchy needs
|
||||||
// different server page than the mailbox itself, so the full set
|
// this too (SCHEMA-DEVIATION: mailbox-client-hierarchy-sort) — a
|
||||||
// is required to place each row under its parent correctly.
|
// 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(
|
const { list: fullList } = await jmapQueryAllAndGet(
|
||||||
obj.objectName,
|
obj.objectName,
|
||||||
accountId,
|
accountId,
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
import type { Schema } from '@/types/schema';
|
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
|
* The Accounts list only shows Email/Full Name/Created At, hiding role and
|
||||||
* storage usage that otherwise require opening each account individually.
|
* storage usage that otherwise require opening each account individually.
|
||||||
* `createdAt` is dropped to make room; `roles` is a real property so it
|
* `createdAt` is dropped to make room; `roles` is a real property so it
|
||||||
|
|||||||
+19
-5
@@ -5,8 +5,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Schema } from '@/types/schema';
|
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
|
* The Stalwart JMAP backend rejects `level`/`event` as filter conditions on
|
||||||
* `x:Log/query` (`unsupportedFilter`), even though both properties are
|
* `x:Log/query` (`unsupportedFilter`), even though both properties are
|
||||||
* already returned per row. Until the backend adds real support, these two
|
* 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'];
|
const logList = schema.lists['x:Log'];
|
||||||
if (!logList || !schema.enums['TracingLevel'] || !schema.enums['EventType']) return schema;
|
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 {
|
return {
|
||||||
...schema,
|
...schema,
|
||||||
lists: {
|
lists: {
|
||||||
...schema.lists,
|
...schema.lists,
|
||||||
'x:Log': {
|
'x:Log': {
|
||||||
...logList,
|
...logList,
|
||||||
filters: [
|
filters: [...(logList.filters ?? []), levelFilter, eventFilter],
|
||||||
...(logList.filters ?? []),
|
|
||||||
{ type: 'enum', field: 'level', enumName: 'TracingLevel', label: 'Level', clientOnly: true },
|
|
||||||
{ type: 'enum', field: 'event', enumName: 'EventType', label: 'Event', clientOnly: true },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
@@ -303,10 +303,6 @@ export interface FilterEnum {
|
|||||||
field: string;
|
field: string;
|
||||||
enumName: string;
|
enumName: string;
|
||||||
label: 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 {
|
export interface FilterInteger {
|
||||||
|
|||||||
Reference in New Issue
Block a user