mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 16:14:07 +00:00
refactor: let each part of the local store expose its own calls to the page
This commit is contained in:
@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { DEFAULT_SCOPE, type StoreEvent } from '../area';
|
||||
import { DEFAULT_SCOPE, type StoreContext, type StoreEvent } from '../area';
|
||||
import { AccountEngine } from '../engine/account-engine';
|
||||
import { runChangesLoop } from '../engine/changes';
|
||||
import { TransportError, type MethodCall } from '../engine/transport';
|
||||
@@ -320,6 +320,33 @@ describe('when the server is busy or gone', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('calls into an area', () => {
|
||||
const noteArea = {
|
||||
name: 'note',
|
||||
migrations: [{ id: 9999, name: 'note', sql: 'CREATE TABLE note (id INTEGER PRIMARY KEY, text TEXT)' }],
|
||||
methods: {},
|
||||
syncers: [],
|
||||
calls: {
|
||||
'note.add': (ctx: StoreContext, params: { text: string }) => ctx.db.run('INSERT INTO note (text) VALUES (?)', [params.text]).lastInsertRowid,
|
||||
},
|
||||
};
|
||||
|
||||
it('reaches the area and runs its migration', async () => {
|
||||
const engine = new AccountEngine({
|
||||
account: 'a@fake.test', db: openMemoryStore(), session: FAKE_SESSION, transport: new FakeJmap(), areas: [mailArea, noteArea], scope: EVERYTHING,
|
||||
});
|
||||
expect(await engine.areaCall('note.add', { text: 'hello' })).toBe(1);
|
||||
await expect(engine.areaCall('note.nothing', {})).rejects.toThrow('Unknown store call');
|
||||
});
|
||||
|
||||
it('refuses two areas defining the same name', () => {
|
||||
expect(() => new AccountEngine({
|
||||
account: 'a@fake.test', db: openMemoryStore(), session: FAKE_SESSION, transport: new FakeJmap(),
|
||||
areas: [noteArea, { ...noteArea, name: 'other', migrations: [] }], scope: EVERYTHING,
|
||||
})).toThrow('Two store areas define note.add');
|
||||
});
|
||||
});
|
||||
|
||||
describe('answers from the store', () => {
|
||||
async function seeded() {
|
||||
const server = new FakeJmap();
|
||||
|
||||
@@ -93,9 +93,17 @@ export interface MethodContext extends StoreContext {
|
||||
|
||||
export type MethodHandler = (ctx: MethodContext, args: Record<string, unknown>, call: MethodCall) => MethodResult | Promise<MethodResult>;
|
||||
|
||||
/**
|
||||
* A call from the page that isn't JMAP: outbox rows, usage, clearing. Reached
|
||||
* from any tab with `store.area(account, 'outbox.list', params)`. Names are
|
||||
* unique across areas and start with the area's name.
|
||||
*/
|
||||
export type AreaCall = (ctx: StoreContext, params: never) => unknown | Promise<unknown>;
|
||||
|
||||
export interface StoreArea {
|
||||
name: string;
|
||||
migrations: readonly Migration[];
|
||||
methods: Readonly<Record<string, MethodHandler>>;
|
||||
syncers: readonly Syncer[];
|
||||
calls?: Readonly<Record<string, AreaCall>>;
|
||||
}
|
||||
|
||||
@@ -258,4 +258,8 @@ export class StoreClient {
|
||||
status(account: string): Promise<StoreStatus | null> {
|
||||
return this.rpc('status', { account });
|
||||
}
|
||||
|
||||
area<T = unknown>(account: string, name: string, params?: unknown): Promise<T> {
|
||||
return this.rpc('area', { account, name, params }) as Promise<T>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* the UI. Nothing here touches the DOM, so tests run it on node:sqlite.
|
||||
*/
|
||||
|
||||
import { MethodError, type MethodHandler, type StoreArea, type StoreEvent, type StoreScope, type SyncContext, type SyncStateRow, type Syncer } from '../area';
|
||||
import { MethodError, type AreaCall, type MethodHandler, type StoreArea, type StoreContext, type StoreEvent, type StoreScope, type SyncContext, type SyncStateRow, type Syncer } from '../area';
|
||||
import { migrate, type StorageAdapter } from '../storage';
|
||||
import { answerRequest, buildMethodTable } from './responder';
|
||||
import { TransportError, type JmapResponse, type JmapTransport, type MethodCall, type StoreSession } from './transport';
|
||||
@@ -35,6 +35,7 @@ export class AccountEngine {
|
||||
private readonly transport: JmapTransport;
|
||||
private readonly syncers: readonly Syncer[];
|
||||
private readonly methods: ReadonlyMap<string, MethodHandler>;
|
||||
private readonly calls = new Map<string, AreaCall>();
|
||||
private readonly emitter: (event: StoreEvent) => void;
|
||||
private readonly now: () => number;
|
||||
private readonly sleep: (ms: number) => Promise<void>;
|
||||
@@ -52,6 +53,12 @@ export class AccountEngine {
|
||||
this.transport = options.transport;
|
||||
this.syncers = options.areas.flatMap((area) => area.syncers);
|
||||
this.methods = buildMethodTable(options.areas);
|
||||
for (const area of options.areas) {
|
||||
for (const [name, call] of Object.entries(area.calls ?? {})) {
|
||||
if (this.calls.has(name)) throw new Error(`Two store areas define ${name}`);
|
||||
this.calls.set(name, call);
|
||||
}
|
||||
}
|
||||
this.emitter = options.emit ?? (() => {});
|
||||
this.now = options.now ?? Date.now;
|
||||
this.sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
||||
@@ -195,18 +202,31 @@ export class AccountEngine {
|
||||
};
|
||||
}
|
||||
|
||||
private storeContext(): StoreContext {
|
||||
return {
|
||||
account: this.account,
|
||||
db: this.db,
|
||||
session: this.session,
|
||||
scope: this.scope,
|
||||
now: this.now,
|
||||
emit: this.emitter,
|
||||
};
|
||||
}
|
||||
|
||||
/** A non-JMAP call into one of the areas. */
|
||||
async areaCall(name: string, params: unknown): Promise<unknown> {
|
||||
const call = this.calls.get(name);
|
||||
if (!call) throw new Error(`Unknown store call ${name}`);
|
||||
return call(this.storeContext(), params as never);
|
||||
}
|
||||
|
||||
/** An answer from the device. Null when nothing in the request could be answered. */
|
||||
async answer(calls: MethodCall[]): Promise<JmapResponse | null> {
|
||||
if (this.closed) throw new MethodError('serverUnavailable', 'The local store is closed');
|
||||
const primary = this.session.primaryAccounts['urn:ietf:params:jmap:mail'] ?? Object.keys(this.session.accounts)[0];
|
||||
return answerRequest(
|
||||
{
|
||||
account: this.account,
|
||||
db: this.db,
|
||||
session: this.session,
|
||||
scope: this.scope,
|
||||
now: this.now,
|
||||
emit: this.emitter,
|
||||
...this.storeContext(),
|
||||
defaultJmap: primary,
|
||||
invertedKeywordSort: this.invertedKeywordSort,
|
||||
},
|
||||
|
||||
@@ -149,6 +149,12 @@ export class StoreHost {
|
||||
const { account } = params as Parameters<StoreRpc['status']>[0];
|
||||
return this.status(account);
|
||||
}
|
||||
case 'area': {
|
||||
const { account, name, params: callParams } = params as Parameters<StoreRpc['area']>[0];
|
||||
const open = this.accounts.get(account);
|
||||
if (!open) throw new Error('The local store is not open for this account');
|
||||
return open.engine.areaCall(name, callParams);
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown store call ${String(method)}`);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface StoreRpc {
|
||||
noteStateChange(params: { account: string; changed: Record<string, Record<string, string>> }): void;
|
||||
setOnline(params: { online: boolean }): void;
|
||||
status(params: { account: string }): StoreStatus | null;
|
||||
/** A non-JMAP call into an area of the store. */
|
||||
area(params: { account: string; name: string; params: unknown }): unknown;
|
||||
}
|
||||
|
||||
export interface StoreStatus {
|
||||
|
||||
Reference in New Issue
Block a user