diff --git a/lib/store/__tests__/mail-sync.test.ts b/lib/store/__tests__/mail-sync.test.ts index dd8b70728..80e1c69fe 100644 --- a/lib/store/__tests__/mail-sync.test.ts +++ b/lib/store/__tests__/mail-sync.test.ts @@ -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(); diff --git a/lib/store/area.ts b/lib/store/area.ts index fa757e37d..fceed110e 100644 --- a/lib/store/area.ts +++ b/lib/store/area.ts @@ -93,9 +93,17 @@ export interface MethodContext extends StoreContext { export type MethodHandler = (ctx: MethodContext, args: Record, call: MethodCall) => MethodResult | Promise; +/** + * 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; + export interface StoreArea { name: string; migrations: readonly Migration[]; methods: Readonly>; syncers: readonly Syncer[]; + calls?: Readonly>; } diff --git a/lib/store/client/store-client.ts b/lib/store/client/store-client.ts index 2b47961a6..5ae621748 100644 --- a/lib/store/client/store-client.ts +++ b/lib/store/client/store-client.ts @@ -258,4 +258,8 @@ export class StoreClient { status(account: string): Promise { return this.rpc('status', { account }); } + + area(account: string, name: string, params?: unknown): Promise { + return this.rpc('area', { account, name, params }) as Promise; + } } diff --git a/lib/store/engine/account-engine.ts b/lib/store/engine/account-engine.ts index 3b460e3a0..6281afc71 100644 --- a/lib/store/engine/account-engine.ts +++ b/lib/store/engine/account-engine.ts @@ -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; + private readonly calls = new Map(); private readonly emitter: (event: StoreEvent) => void; private readonly now: () => number; private readonly sleep: (ms: number) => Promise; @@ -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 { + 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 { 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, }, diff --git a/lib/store/worker/host.ts b/lib/store/worker/host.ts index 25e63e270..636c231c9 100644 --- a/lib/store/worker/host.ts +++ b/lib/store/worker/host.ts @@ -149,6 +149,12 @@ export class StoreHost { const { account } = params as Parameters[0]; return this.status(account); } + case 'area': { + const { account, name, params: callParams } = params as Parameters[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)}`); } diff --git a/lib/store/worker/protocol.ts b/lib/store/worker/protocol.ts index 8b3257648..ed5a08f96 100644 --- a/lib/store/worker/protocol.ts +++ b/lib/store/worker/protocol.ts @@ -26,6 +26,8 @@ export interface StoreRpc { noteStateChange(params: { account: string; changed: Record> }): 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 {