Apply saved settings on the server after a save
ci / build (pull_request) Successful in 1m13s
ci / publish (pull_request) Skipped

A registry write is stored at once, but the running server only picks up
most settings when it rebuilds its configuration, which it does on a write
for directories and the default authentication alone. Everything else, a
delivery schedule for one, sat unapplied until someone ran Management >
Actions > Reload > Server settings by hand.

Every x:<Type>/set now goes past a listener in the JMAP client. A write that
changed a settings object queues the reload action it needs, and once no
write has been in flight for 600 ms the queued actions go out together in
one x:Action/set. A bulk edit or a page that saves several objects in a row
costs one reload, not one per object; a save still in flight holds the
reload back however long it takes.

Which action a type needs lives in lib/settingsApply.ts. Certificates, lookup
stores and blocked IPs have their own reload actions, and the full settings
reload doesn't rebuild them. Directories and authentication, data read live
or kept current by cache invalidation (accounts, domains, DKIM keys, tenants,
roles, lists and the like), operations and records, and stores, which a
reload never reopens, get none. A type the list doesn't know is reloaded: an
unneeded reload costs a second, a missing one leaves a setting unapplied. If
the server later applies these writes by itself, this becomes one extra,
harmless reload per burst of saves.

Applying straight after a save is safe because a reload is all or nothing:
the new configuration replaces the running one only when every settings
object builds. When one doesn't, the server keeps what it had and names the
object and the problem. That now shows as a banner above the page, "Saved,
but the server couldn't apply the settings: <reason>", naming the object with
a link to it, and an Apply now button. It stays until a reload succeeds or it
is dismissed. A failure that is already known is not retried on unrelated
saves, only on Apply now or a save that needs a reload.

On success a "Saved and applied" toast replaces the form's own "Saved
successfully" and "Created successfully" for settings objects, so a save
shows one message, not two.

New strings, in src/i18n/en.json (the only catalogue) under settingsApply:
applied, applyNow, dismiss, failed, failedObject, noAnswer, notConfirmed,
openObject, stillRunning.
This commit is contained in:
2026-09-24 11:16:09 -07:00
parent dbf911e0a6
commit 28c49b4056
10 changed files with 796 additions and 9 deletions
+7
View File
@@ -63,6 +63,7 @@ import { logFormChange } from '@/lib/debug';
import { FieldWidget } from '@/components/forms/FieldWidget'; import { FieldWidget } from '@/components/forms/FieldWidget';
import { DnsConnectCard } from '@/features/dns/DnsConnectCard'; import { DnsConnectCard } from '@/features/dns/DnsConnectCard';
import { isSieveScriptField } from '@/lib/sievepad'; import { isSieveScriptField } from '@/lib/sievepad';
import { reloadActionFor } from '@/lib/settingsApply';
import type { Field, Fields, Form, FormField, Schema } from '@/types/schema'; import type { Field, Fields, Form, FormField, Schema } from '@/types/schema';
import type { JmapSetResponse, JmapSetError, JmapMethodCall } from '@/types/jmap'; import type { JmapSetResponse, JmapSetError, JmapMethodCall } from '@/types/jmap';
@@ -529,10 +530,13 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
setCreatedObjectId(newId); setCreatedObjectId(newId);
setServerCreatedProps(extraProps); setServerCreatedProps(extraProps);
} else { } else {
// inbuxa: a settings object says "Saved and applied" once the server has it.
if (!reloadActionFor(obj.objectName)) {
toast({ toast({
title: t('form.createdSuccess', 'Created successfully'), title: t('form.createdSuccess', 'Created successfully'),
variant: 'success', variant: 'success',
}); });
}
setOriginalData({ ...formData }); setOriginalData({ ...formData });
navigate(`/${section}/${viewName}`); navigate(`/${section}/${viewName}`);
} }
@@ -616,10 +620,13 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
const setResult = setResponse[1] as unknown as JmapSetResponse; const setResult = setResponse[1] as unknown as JmapSetResponse;
if (setResult.updated && updateId in setResult.updated) { if (setResult.updated && updateId in setResult.updated) {
// inbuxa: a settings object says "Saved and applied" once the server has it.
if (!reloadActionFor(obj.objectName)) {
toast({ toast({
title: t('form.savedSuccess', 'Saved successfully'), title: t('form.savedSuccess', 'Saved successfully'),
variant: 'success', variant: 'success',
}); });
}
if (isSingleton) { if (isSingleton) {
const getResponses = await jmapGet(obj.objectName, accountId, ['singleton'], fetchProperties); const getResponses = await jmapGet(obj.objectName, accountId, ['singleton'], fetchProperties);
@@ -0,0 +1,95 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* inbuxa: shown when saved settings are stored but the server couldn't apply
* them. It stays until they are applied or it is dismissed, names the object
* the server couldn't build and links to it, and offers to try again.
*/
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { AlertTriangle, Loader2, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { humanize } from '@/lib/humanize';
import { resolveList } from '@/lib/schemaResolver';
import { useSchemaStore } from '@/stores/schemaStore';
import { useSettingsApplyStore } from '@/stores/settingsApplyStore';
export function SettingsApplyBanner() {
const { t } = useTranslation();
const failure = useSettingsApplyStore((s) => s.failure);
const applying = useSettingsApplyStore((s) => s.applying);
const applyNow = useSettingsApplyStore((s) => s.applyNow);
const dismiss = useSettingsApplyStore((s) => s.dismiss);
const schema = useSchemaStore((s) => s.schema);
const viewToSection = useSchemaStore((s) => s.viewToSection);
if (!failure) return null;
let objectLabel: string | null = null;
let objectLink: string | null = null;
if (failure.object) {
const objectName = `x:${failure.object.object}`;
const objectType = schema?.objects[objectName];
const list = schema ? resolveList(schema, objectName, objectName) : null;
objectLabel = list?.singularName ?? humanize(failure.object.object);
const section = viewToSection[objectName];
if (section) {
objectLink =
objectType?.type === 'singleton' || failure.object.id === 'singleton'
? `/${section}/${objectName}`
: `/${section}/${objectName}/${encodeURIComponent(failure.object.id)}`;
}
}
return (
<div
role="alert"
className="mb-4 flex flex-wrap items-start gap-x-3 gap-y-2 rounded-xl border border-destructive/40 bg-destructive/5 px-4 py-3 text-sm"
>
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
<div className="min-w-0 flex-1 space-y-1">
<p className="font-medium">
{t('settingsApply.failed', "Saved, but the server couldn't apply the settings: {{reason}}", {
reason: failure.message,
})}
</p>
{objectLabel && (
<p className="text-muted-foreground">
{t('settingsApply.failedObject', 'The problem is in {{object}}.', { object: objectLabel })}{' '}
{objectLink && (
<Link to={objectLink} className="font-medium text-primary hover:underline">
{t('settingsApply.openObject', 'Open it')}
</Link>
)}
</p>
)}
<p className="text-muted-foreground">
{t(
'settingsApply.stillRunning',
'The server keeps running on the settings it had. Your changes are saved and apply once this is fixed.',
)}
</p>
</div>
<div className="flex items-center gap-2">
<Button size="sm" onClick={() => void applyNow()} disabled={applying}>
{applying && <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />}
{t('settingsApply.applyNow', 'Apply now')}
</Button>
<Button
size="icon"
variant="ghost"
className="h-8 w-8"
onClick={dismiss}
aria-label={t('settingsApply.dismiss', 'Dismiss')}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
);
}
+11
View File
@@ -345,6 +345,17 @@
"periodValue": "Period value" "periodValue": "Period value"
}, },
"sections": "Sections", "sections": "Sections",
"settingsApply": {
"applied": "Saved and applied",
"applyNow": "Apply now",
"dismiss": "Dismiss",
"failed": "Saved, but the server couldn't apply the settings: {{reason}}",
"failedObject": "The problem is in {{object}}.",
"noAnswer": "The server did not answer.",
"notConfirmed": "The server did not confirm the reload.",
"openObject": "Open it",
"stillRunning": "The server keeps running on the settings it had. Your changes are saved and apply once this is fixed."
},
"sievepad": { "sievepad": {
"debug": "Debug", "debug": "Debug",
"defaultName": "Sieve script", "defaultName": "Sieve script",
+119
View File
@@ -0,0 +1,119 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, it, expect } from 'vitest';
import {
describeApplyFailure,
describeRequestFailure,
reloadActionFor,
reloadActionsFor,
writesRegistry,
writtenRegistryTypes,
} from './settingsApply';
import type { JmapMethodResponse } from '@/types/jmap';
describe('reloadActionFor', () => {
it('reloads settings objects', () => {
expect(reloadActionFor('x:MtaDeliverySchedule')).toBe('ReloadSettings');
expect(reloadActionFor('x:SpamPyzor')).toBe('ReloadSettings');
expect(reloadActionFor('x:NetworkListener')).toBe('ReloadSettings');
});
it('reloads a type it has never heard of', () => {
expect(reloadActionFor('x:SomethingNew')).toBe('ReloadSettings');
});
it('uses the narrower action where one exists', () => {
expect(reloadActionFor('x:Certificate')).toBe('ReloadTlsCertificates');
expect(reloadActionFor('x:StoreLookup')).toBe('ReloadLookupStores');
expect(reloadActionFor('x:MemoryLookupKeyValue')).toBe('ReloadLookupStores');
expect(reloadActionFor('x:BlockedIp')).toBe('ReloadBlockedIps');
});
it('skips what the server reloads on write', () => {
expect(reloadActionFor('x:Directory')).toBeNull();
expect(reloadActionFor('x:Authentication')).toBeNull();
});
it('skips data read live, operations and stores', () => {
for (const type of ['Account', 'Domain', 'DkimSignature', 'Tenant', 'Action', 'QueuedMessage', 'DataStore']) {
expect(reloadActionFor(`x:${type}`)).toBeNull();
}
});
it('ignores anything outside the registry', () => {
expect(reloadActionFor('Email')).toBeNull();
expect(reloadActionFor('inbuxa:ProtocolPolicy')).toBeNull();
});
});
describe('reloadActionsFor', () => {
it('collapses duplicates and puts settings last', () => {
expect(reloadActionsFor(['x:MtaRoute', 'x:Certificate', 'x:MtaDeliverySchedule', 'x:Account'])).toEqual([
'ReloadTlsCertificates',
'ReloadSettings',
]);
});
});
describe('registry writes', () => {
it('recognizes an x: set call among others', () => {
expect(writesRegistry([['x:MtaRoute/get', {}, '0']])).toBe(false);
expect(
writesRegistry([
['Blob/upload', {}, 'b'],
['x:MtaRoute/set', {}, '0'],
]),
).toBe(true);
});
it('reports only the types a response changed', () => {
const responses: JmapMethodResponse[] = [
['Blob/upload', { created: { b: {} } }, 'b'],
['x:MtaRoute/set', { created: null, updated: { a: null }, destroyed: null }, '0'],
['x:SpamPyzor/set', { created: {}, updated: {}, destroyed: [], notUpdated: { singleton: {} } }, '1'],
['x:Certificate/set', { destroyed: ['c1'] }, '2'],
['error', { type: 'serverFail' }, '3'],
];
expect(writtenRegistryTypes(responses)).toEqual(['x:MtaRoute', 'x:Certificate']);
});
});
describe('describeApplyFailure', () => {
it('keeps the server message and the object it named', () => {
expect(
describeApplyFailure({
type: 'validationFailed',
description: 'Failed to resolve Pyzor host',
objectId: { object: 'SpamPyzor', id: 'singleton' },
}),
).toEqual({ message: 'Failed to resolve Pyzor host', object: { object: 'SpamPyzor', id: 'singleton' } });
});
it('spells out validation errors when there is no description', () => {
const failure = describeApplyFailure({
type: 'validationFailed',
objectId: { object: 'MtaRoute', id: 'b' },
validationErrors: [{ type: 'Required', property: 'address' }],
});
expect(failure.message).toBe('address: This field is required.');
expect(failure.object).toEqual({ object: 'MtaRoute', id: 'b' });
});
it('falls back to the error type', () => {
expect(describeApplyFailure({ type: 'forbidden' }).message).toBe(
'You do not have permission to perform this action.',
);
});
});
describe('describeRequestFailure', () => {
it('reads thrown errors and method errors', () => {
expect(describeRequestFailure(new Error('Network down')).message).toBe('Network down');
expect(describeRequestFailure({ type: 'forbidden', description: 'No' }).message).toBe('No');
expect(describeRequestFailure(undefined).message).toBe('The server did not answer.');
});
});
+174
View File
@@ -0,0 +1,174 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* inbuxa: which saved registry objects the running server has to be told to
* apply, and how to read its answer when it can't.
*
* A write to the registry is stored at once, but most settings only take
* effect when the server rebuilds its configuration from the registry: the
* x:Action ReloadSettings action, which also carries the change to every
* node of a cluster. The server does that by itself on a write for a few
* types only (directories and the default authentication). Everything else
* waited for someone to open Management > Actions and reload by hand.
*
* A reload is all or nothing: the new configuration replaces the running one
* only when every settings object builds, so applying straight after a save
* can't leave the server half configured. When something doesn't build, the
* server keeps what it had and names the object and the problem.
*/
import i18n from '@/i18n';
import { friendlySetError, validationErrorMessage } from '@/lib/jmapErrors';
import type { JmapMethodCall, JmapMethodResponse, JmapObjectRef, JmapSetError } from '@/types/jmap';
export type ReloadAction = 'ReloadSettings' | 'ReloadTlsCertificates' | 'ReloadLookupStores' | 'ReloadBlockedIps';
/** The order the actions run in when more than one is due. */
export const RELOAD_ORDER: readonly ReloadAction[] = [
'ReloadLookupStores',
'ReloadTlsCertificates',
'ReloadBlockedIps',
'ReloadSettings',
];
/** Types whose own reload action applies them; the server rebuilds nothing else for these. */
const OWN_ACTION: Record<string, ReloadAction> = {
Certificate: 'ReloadTlsCertificates',
StoreLookup: 'ReloadLookupStores',
HttpLookup: 'ReloadLookupStores',
MemoryLookupKey: 'ReloadLookupStores',
MemoryLookupKeyValue: 'ReloadLookupStores',
BlockedIp: 'ReloadBlockedIps',
};
/** Types that need no reload after a write. Anything not listed here is reloaded. */
const NOTHING_TO_APPLY = new Set<string>([
// The server reloads these itself on every write, here and on every node.
'Directory',
'Authentication',
// Read from the registry when used, or kept current by cache invalidation.
'Account',
'AccountPassword',
'AccountSettings',
'ApiKey',
'AppPassword',
'Domain',
'DkimSignature',
'MailingList',
'MaskedEmail',
'OAuthClient',
'PublicKey',
'Role',
'Tenant',
// Operations, records and telemetry rather than settings.
'Action',
'ArchivedItem',
'ArfExternalReport',
'Bootstrap',
'ClusterNode',
'DmarcExternalReport',
'DmarcInternalReport',
'Log',
'Metric',
'QueuedMessage',
'SpamTrainingSample',
'Task',
'TlsExternalReport',
'TlsInternalReport',
'Trace',
// Stores are opened once at startup and a reload keeps the ones it has, so
// saying "applied" would be untrue. They take effect on a restart.
'BlobStore',
'Coordinator',
'DataStore',
'InMemoryStore',
'SearchStore',
// Applications are unpacked by their own manager, which no reload reaches.
'Application',
]);
/**
* The action that applies a write to `objectName` (an `x:` registry name), or
* null when the write needs none. Types this list doesn't know are reloaded:
* an unneeded reload costs a second, a missing one leaves a setting unapplied.
*/
export function reloadActionFor(objectName: string): ReloadAction | null {
if (!objectName.startsWith('x:')) return null;
const type = objectName.slice(2);
if (NOTHING_TO_APPLY.has(type)) return null;
return OWN_ACTION[type] ?? 'ReloadSettings';
}
const REGISTRY_SET = /^(x:[A-Za-z0-9]+)\/set$/;
/** Whether a request writes to the registry: any `x:<Type>/set` call. */
export function writesRegistry(methodCalls: JmapMethodCall[]): boolean {
return methodCalls.some(([name]) => REGISTRY_SET.test(name));
}
/** The registry types a response says were created, changed or destroyed. */
export function writtenRegistryTypes(methodResponses: JmapMethodResponse[]): string[] {
const types = new Set<string>();
for (const [name, result] of methodResponses) {
const match = REGISTRY_SET.exec(name);
if (!match || !result) continue;
const created = result.created as Record<string, unknown> | null | undefined;
const updated = result.updated as Record<string, unknown> | null | undefined;
const destroyed = result.destroyed as unknown[] | null | undefined;
if (
(created && Object.keys(created).length > 0) ||
(updated && Object.keys(updated).length > 0) ||
(destroyed && destroyed.length > 0)
) {
types.add(match[1]);
}
}
return [...types];
}
/** The actions due for a set of written types, in the order they run. */
export function reloadActionsFor(objectNames: Iterable<string>): ReloadAction[] {
const due = new Set<ReloadAction>();
for (const name of objectNames) {
const action = reloadActionFor(name);
if (action) due.add(action);
}
return RELOAD_ORDER.filter((a) => due.has(a));
}
export interface ApplyFailure {
/** What went wrong, in the server's words where it gave any. */
message: string;
/** The settings object that didn't build, when the server named one. */
object?: JmapObjectRef;
}
/** Reads a failed reload action into something to show an administrator. */
export function describeApplyFailure(err: JmapSetError): ApplyFailure {
let message = err.description?.trim() ?? '';
if (!message && err.validationErrors && err.validationErrors.length > 0) {
message = err.validationErrors
.map((ve) => (ve.property ? `${ve.property}: ${validationErrorMessage(ve)}` : validationErrorMessage(ve)))
.join('; ');
}
if (!message) message = friendlySetError(err);
const object = typeof err.objectId === 'object' && err.objectId?.object ? err.objectId : undefined;
return object ? { message, object } : { message };
}
/** A failure that came back as a method error or a failed request rather than a set error. */
export function describeRequestFailure(err: unknown): ApplyFailure {
if (err instanceof Error && err.message) return { message: err.message };
if (err && typeof err === 'object') {
const e = err as { type?: unknown; description?: unknown };
if (typeof e.description === 'string' && e.description) return { message: e.description };
if (typeof e.type === 'string' && e.type) {
return { message: i18n.t('jmapErrors.unexpected', 'Unexpected error ({{type}}).', { type: e.type }) };
}
}
return { message: i18n.t('settingsApply.noAnswer', 'The server did not answer.') };
}
+2
View File
@@ -22,6 +22,7 @@ import { TopBar } from '@/components/layout/TopBar';
import { Sidebar } from '@/components/layout/Sidebar'; import { Sidebar } from '@/components/layout/Sidebar';
import { SectionNav } from '@/components/layout/SectionNav'; import { SectionNav } from '@/components/layout/SectionNav';
import { MainContent } from '@/components/layout/MainContent'; import { MainContent } from '@/components/layout/MainContent';
import { SettingsApplyBanner } from '@/components/layout/SettingsApplyBanner';
import { ErrorBoundary } from '@/components/layout/ErrorBoundary'; import { ErrorBoundary } from '@/components/layout/ErrorBoundary';
import { LoadingFallback } from '@/components/common/LoadingFallback'; import { LoadingFallback } from '@/components/common/LoadingFallback';
import { import {
@@ -320,6 +321,7 @@ export default function AdminPanel() {
!useSectionNav && sidebarOpen && (sidebarCollapsed ? 'md:ml-[4.5rem]' : 'md:ml-64'), !useSectionNav && sidebarOpen && (sidebarCollapsed ? 'md:ml-[4.5rem]' : 'md:ml-64'),
)} )}
> >
<SettingsApplyBanner />
<ErrorBoundary key={activeAccountId ?? 'none'}> <ErrorBoundary key={activeAccountId ?? 'none'}>
<MainContent viewName={viewName} id={id} section={section} /> <MainContent viewName={viewName} id={id} section={section} />
</ErrorBoundary> </ErrorBoundary>
+35
View File
@@ -12,6 +12,7 @@ import { apiFetch } from '@/services/api';
import { logJmapExchange } from '@/lib/debug'; import { logJmapExchange } from '@/lib/debug';
import type { JmapMethodCall, JmapMethodResponse, JmapQueryResponse, JmapResponse } from '@/types/jmap'; import type { JmapMethodCall, JmapMethodResponse, JmapQueryResponse, JmapResponse } from '@/types/jmap';
import type { Schema } from '@/types/schema'; import type { Schema } from '@/types/schema';
import { writesRegistry, writtenRegistryTypes } from '@/lib/settingsApply';
const JMAP_USING = [ const JMAP_USING = [
'urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:core',
@@ -35,10 +36,44 @@ export function getAccountId(objectType: string): string {
return activeAccountId; return activeAccountId;
} }
/**
* inbuxa: told about every registry write, so that saved settings can be
* applied on the server (settingsApplyStore). `started` comes before the
* request goes out and `finished` after it settles, with the types it changed.
*/
export interface RegistryWriteListener {
started(): void;
finished(objectNames: string[]): void;
}
let registryWriteListener: RegistryWriteListener | null = null;
export function setRegistryWriteListener(listener: RegistryWriteListener | null) {
registryWriteListener = listener;
}
export async function jmapRequest( export async function jmapRequest(
methodCalls: JmapMethodCall[], methodCalls: JmapMethodCall[],
signal?: AbortSignal, signal?: AbortSignal,
extraUsing: string[] = [], extraUsing: string[] = [],
): Promise<JmapMethodResponse[]> {
const listener = writesRegistry(methodCalls) ? registryWriteListener : null;
if (!listener) return sendJmapRequest(methodCalls, signal, extraUsing);
listener.started();
let written: string[] = [];
try {
const responses = await sendJmapRequest(methodCalls, signal, extraUsing);
written = writtenRegistryTypes(responses);
return responses;
} finally {
listener.finished(written);
}
}
async function sendJmapRequest(
methodCalls: JmapMethodCall[],
signal?: AbortSignal,
extraUsing: string[] = [],
): Promise<JmapMethodResponse[]> { ): Promise<JmapMethodResponse[]> {
const { apiUrl } = useAuthStore.getState(); const { apiUrl } = useAuthStore.getState();
let path = apiUrl || '/jmap'; let path = apiUrl || '/jmap';
+167
View File
@@ -0,0 +1,167 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import type { RegistryWriteListener } from '@/services/jmap/client';
import type { JmapMethodCall, JmapMethodResponse } from '@/types/jmap';
const mocks = vi.hoisted(() => ({
jmapRequest: vi.fn<(calls: JmapMethodCall[]) => Promise<JmapMethodResponse[]>>(),
listener: null as RegistryWriteListener | null,
toast: vi.fn(),
}));
vi.mock('@/services/jmap/client', () => ({
getAccountId: () => 'admin',
jmapRequest: mocks.jmapRequest,
setRegistryWriteListener: (l: RegistryWriteListener | null) => {
mocks.listener = l;
},
}));
vi.mock('@/hooks/use-toast', () => ({ toast: mocks.toast }));
import { APPLY_DELAY_MS, resetSettingsApplyForTests, useSettingsApplyStore } from './settingsApplyStore';
function save(...types: string[]) {
mocks.listener!.started();
mocks.listener!.finished(types);
}
function reloadCreates(call: JmapMethodCall): unknown[] {
expect(call[0]).toBe('x:Action/set');
return Object.values((call[1] as { create: Record<string, unknown> }).create);
}
function answer(created: string[], notCreated: Record<string, unknown> = {}): JmapMethodResponse[] {
return [['x:Action/set', { created: Object.fromEntries(created.map((k) => [k, { id: k }])), notCreated }, 'reload']];
}
describe('settingsApplyStore', () => {
beforeEach(() => {
vi.useFakeTimers();
mocks.jmapRequest.mockReset();
mocks.toast.mockReset();
resetSettingsApplyForTests();
});
afterEach(() => {
vi.useRealTimers();
});
it('registers with the JMAP client', () => {
expect(mocks.listener).not.toBeNull();
});
it('applies a settings save once writes settle, and says so', async () => {
mocks.jmapRequest.mockResolvedValue(answer(['reload-0']));
save('x:MtaDeliverySchedule');
expect(mocks.jmapRequest).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS);
expect(mocks.jmapRequest).toHaveBeenCalledTimes(1);
expect(reloadCreates(mocks.jmapRequest.mock.calls[0][0][0])).toEqual([{ '@type': 'ReloadSettings' }]);
expect(mocks.toast).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Saved and applied', variant: 'success' }),
);
expect(useSettingsApplyStore.getState()).toMatchObject({ pending: [], applying: false, failure: null });
});
it('sends one reload for a burst of saves', async () => {
mocks.jmapRequest.mockResolvedValue(answer(['reload-0', 'reload-1']));
save('x:MtaRoute');
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS / 2);
save('x:MtaRoute');
// A save still in flight holds the reload back however long it takes.
mocks.listener!.started();
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS * 5);
expect(mocks.jmapRequest).not.toHaveBeenCalled();
mocks.listener!.finished(['x:Certificate']);
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS);
expect(mocks.jmapRequest).toHaveBeenCalledTimes(1);
expect(reloadCreates(mocks.jmapRequest.mock.calls[0][0][0])).toEqual([
{ '@type': 'ReloadTlsCertificates' },
{ '@type': 'ReloadSettings' },
]);
});
it('does nothing for writes the server applies itself', async () => {
save('x:Directory', 'x:Account', 'x:Domain');
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS * 2);
expect(mocks.jmapRequest).not.toHaveBeenCalled();
});
it('keeps a failed reload with the object and message, and retries on Apply now', async () => {
mocks.jmapRequest.mockResolvedValueOnce(
answer([], {
'reload-0': {
type: 'validationFailed',
description: 'Failed to resolve Pyzor host',
objectId: { object: 'SpamPyzor', id: 'singleton' },
},
}),
);
save('x:MtaDeliverySchedule');
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS);
expect(mocks.toast).not.toHaveBeenCalled();
expect(useSettingsApplyStore.getState()).toMatchObject({
pending: ['ReloadSettings'],
failure: { message: 'Failed to resolve Pyzor host', object: { object: 'SpamPyzor', id: 'singleton' } },
});
// An unrelated save doesn't rerun a reload that is known to fail.
save('x:Account');
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS * 2);
expect(mocks.jmapRequest).toHaveBeenCalledTimes(1);
mocks.jmapRequest.mockResolvedValueOnce(answer(['reload-0']));
await useSettingsApplyStore.getState().applyNow();
expect(mocks.jmapRequest).toHaveBeenCalledTimes(2);
expect(useSettingsApplyStore.getState()).toMatchObject({ pending: [], failure: null });
expect(mocks.toast).toHaveBeenCalledWith(expect.objectContaining({ title: 'Saved and applied' }));
});
it('tries again by itself when a later save needs a reload', async () => {
mocks.jmapRequest.mockResolvedValueOnce(answer([], { 'reload-0': { type: 'validationFailed', description: 'x' } }));
save('x:SpamPyzor');
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS);
expect(useSettingsApplyStore.getState().failure).not.toBeNull();
mocks.jmapRequest.mockResolvedValueOnce(answer(['reload-0']));
save('x:SpamPyzor');
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS);
expect(mocks.jmapRequest).toHaveBeenCalledTimes(2);
expect(useSettingsApplyStore.getState().failure).toBeNull();
});
it('reports a request that fails outright', async () => {
mocks.jmapRequest.mockRejectedValueOnce(new Error('Network down'));
save('x:MtaRoute');
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS);
expect(useSettingsApplyStore.getState()).toMatchObject({
pending: ['ReloadSettings'],
failure: { message: 'Network down' },
});
});
it('reports a method error', async () => {
mocks.jmapRequest.mockResolvedValueOnce([['error', { type: 'forbidden', description: 'Not allowed' }, 'reload']]);
save('x:MtaRoute');
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS);
expect(useSettingsApplyStore.getState().failure).toEqual({ message: 'Not allowed' });
});
it('dismissing hides the failure but keeps the reload queued', async () => {
mocks.jmapRequest.mockResolvedValueOnce(answer([], { 'reload-0': { type: 'validationFailed', description: 'x' } }));
save('x:MtaRoute');
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS);
useSettingsApplyStore.getState().dismiss();
expect(useSettingsApplyStore.getState()).toMatchObject({ failure: null, pending: ['ReloadSettings'] });
});
});
+168
View File
@@ -0,0 +1,168 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* inbuxa: applies saved settings on the running server.
*
* Every registry write that needs it queues a reload action (see
* lib/settingsApply). Once no write has been in flight for APPLY_DELAY_MS the
* queued actions go out together in one x:Action/set, so a bulk edit, or a
* page that saves several objects in a row, costs one reload rather than one
* per object. A reload that fails stays queued and shows in
* SettingsApplyBanner until it is applied, by "Apply now" or by the next save.
*
* If the server one day applies these writes by itself, the reload sent here
* is a second, harmless one: one per burst of saves, never one per object.
*/
import { create } from 'zustand';
import i18n from '@/i18n';
import { toast } from '@/hooks/use-toast';
import { getAccountId, jmapRequest, setRegistryWriteListener } from '@/services/jmap/client';
import {
RELOAD_ORDER,
describeApplyFailure,
describeRequestFailure,
reloadActionsFor,
type ApplyFailure,
type ReloadAction,
} from '@/lib/settingsApply';
import type { JmapSetError } from '@/types/jmap';
export const APPLY_DELAY_MS = 600;
interface SettingsApplyState {
/** Actions waiting to be sent: queued by saves, or left over from a failed attempt. */
pending: ReloadAction[];
applying: boolean;
/** Why the last attempt didn't apply, until one does. */
failure: ApplyFailure | null;
/** Queues the actions the written types need, and applies them once writes settle. */
noteWrites: (objectNames: string[]) => void;
/** Sends whatever is queued now, without waiting. */
applyNow: () => Promise<void>;
/** Hides the failure. What failed stays queued for the next save. */
dismiss: () => void;
}
let timer: ReturnType<typeof setTimeout> | null = null;
let writesInFlight = 0;
let applyAgain = false;
// Whether a save has queued something since the last attempt. After a failed
// reload, only a save that needs one tries again by itself; editing an
// account, say, doesn't rerun a reload that is known to fail.
let freshlyQueued = false;
function merge(a: ReloadAction[], b: ReloadAction[]): ReloadAction[] {
const all = new Set([...a, ...b]);
return RELOAD_ORDER.filter((x) => all.has(x));
}
function schedule() {
if (timer) clearTimeout(timer);
timer = null;
const { pending, failure } = useSettingsApplyStore.getState();
if (pending.length === 0 || writesInFlight > 0) return;
if (failure && !freshlyQueued) return;
timer = setTimeout(() => {
timer = null;
void useSettingsApplyStore.getState().applyNow();
}, APPLY_DELAY_MS);
}
export const useSettingsApplyStore = create<SettingsApplyState>()((set, get) => ({
pending: [],
applying: false,
failure: null,
noteWrites: (objectNames) => {
const due = reloadActionsFor(objectNames);
if (due.length > 0) {
freshlyQueued = true;
set({ pending: merge(get().pending, due) });
}
schedule();
},
applyNow: async () => {
if (timer) clearTimeout(timer);
timer = null;
if (get().applying) {
// A save landed while the last reload was out: go again once it's back.
applyAgain = true;
return;
}
const actions = get().pending;
if (actions.length === 0) return;
freshlyQueued = false;
set({ applying: true, pending: [] });
let notApplied: ReloadAction[] = [];
let failure: ApplyFailure | null = null;
try {
const create: Record<string, Record<string, unknown>> = {};
actions.forEach((action, i) => {
create[`reload-${i}`] = { '@type': action };
});
const responses = await jmapRequest([
['x:Action/set', { accountId: getAccountId('x:Action'), create }, 'reload'],
]);
const [name, result] = responses[responses.length - 1] ?? [];
if (name !== 'x:Action/set' || !result) {
notApplied = actions;
failure = describeRequestFailure(result);
} else {
const created = (result.created ?? {}) as Record<string, unknown>;
const notCreated = (result.notCreated ?? {}) as Record<string, JmapSetError>;
for (const [i, action] of actions.entries()) {
const key = `reload-${i}`;
if (key in created) continue;
notApplied.push(action);
failure ??= notCreated[key]
? describeApplyFailure(notCreated[key])
: { message: i18n.t('settingsApply.notConfirmed', 'The server did not confirm the reload.') };
}
}
} catch (err) {
notApplied = actions;
failure = describeRequestFailure(err);
}
set({ applying: false, pending: merge(get().pending, notApplied), failure });
if (!failure) {
toast({ title: i18n.t('settingsApply.applied', 'Saved and applied'), variant: 'success' });
}
if (applyAgain) {
applyAgain = false;
schedule();
}
},
dismiss: () => set({ failure: null }),
}));
setRegistryWriteListener({
started() {
writesInFlight++;
if (timer) clearTimeout(timer);
timer = null;
},
finished(objectNames) {
writesInFlight = Math.max(0, writesInFlight - 1);
useSettingsApplyStore.getState().noteWrites(objectNames);
},
});
/** Test hook: forget timers and counters between cases. */
export function resetSettingsApplyForTests() {
if (timer) clearTimeout(timer);
timer = null;
writesInFlight = 0;
applyAgain = false;
freshlyQueued = false;
useSettingsApplyStore.setState({ pending: [], applying: false, failure: null });
}
+10 -1
View File
@@ -1,7 +1,10 @@
/* /*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* SPDX-FileCopyrightText: 2026 Coffey Labs
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
export interface JmapRequest { export interface JmapRequest {
@@ -52,11 +55,17 @@ export interface JmapSetError {
description?: string; description?: string;
properties?: string[]; properties?: string[];
existingId?: string; existingId?: string;
objectId?: string; objectId?: string | JmapObjectRef;
linkedObjects?: string[]; linkedObjects?: string[];
validationErrors?: ValidationError[]; validationErrors?: ValidationError[];
} }
/** A registry object as the server names it in an error: its type without `x:`, and its id. */
export interface JmapObjectRef {
object: string;
id: string;
}
export interface ValidationError { export interface ValidationError {
type: 'Invalid' | 'Required' | 'MaxLength' | 'MinLength' | 'MaxValue' | 'MinValue'; type: 'Invalid' | 'Required' | 'MaxLength' | 'MinLength' | 'MaxValue' | 'MinValue';
property: string; property: string;