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:
Steven RYDELL
2026-08-01 17:54:08 +02:00
parent 4a6340e83f
commit 101b24b361
6 changed files with 145 additions and 17 deletions
+15 -8
View File
@@ -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, unknown>, 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,
+2
View File
@@ -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
+19 -5
View File
@@ -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],
},
},
};
+25
View File
@@ -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;
}
-4
View File
@@ -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 {