chore: merge contacts and files into the settings branch

This commit is contained in:
Linus Rath
2026-11-03 09:22:04 +01:00
30 changed files with 4509 additions and 16 deletions
+35 -8
View File
@@ -21,6 +21,7 @@ import { isTaskLikeObject, type ScannedCalendarObject } from "@/lib/calendar-com
import { DEFAULT_CALENDAR_COMPONENTS, mkCalendarCollection, newCalendarCollectionName } from "@/lib/webdav/calendar-collection";
import { sanitizeDisplayName, splitMailbox } from "@/lib/rfc5322-mailbox";
import { decodeFileNodeName } from "./filenode-name";
import { PartialAccountsError } from "./partial-accounts";
import { fileNodeKind, fromDraftFileNode, toDraftFileNodeCreate } from "./filenode-kind";
import { fileNodeDepth } from "./filenode-depth";
import { contactFromWire, contactToWire } from "./contact-wire";
@@ -7419,10 +7420,16 @@ export class JMAPClient implements IJMAPClient {
}
}
/**
* Throws PartialAccountsError when an account couldn't answer, with what the
* others gave, so a failure doesn't read as that account having no books.
*/
async getAllAddressBooks(): Promise<AddressBook[]> {
const failed: string[] = [];
let failure: unknown;
const allBooks: AddressBook[] = [];
const primaryId = this.getContactsAccountId();
try {
const allBooks: AddressBook[] = [];
const primaryId = this.getContactsAccountId();
const accountIds = this.getContactCapableAccountIds();
for (const accountId of accountIds) {
@@ -7445,17 +7452,23 @@ export class JMAPClient implements IJMAPClient {
isShared: !isPrimary,
}));
allBooks.push(...books);
} else if (response.methodResponses?.[0]?.[0] === "error") {
const refused = response.methodResponses[0][1];
throw new Error(refused?.description || refused?.type || "AddressBook/get failed");
}
} catch (error) {
console.error(`Failed to fetch address books for account ${accountId}:`, error);
failed.push(accountId);
failure ??= error;
}
}
return allBooks;
if (failed.length === 0) return allBooks;
} catch (error) {
console.error('Failed to fetch all address books:', error);
return this.getAddressBooks();
}
throw new PartialAccountsError(allBooks, failed, primaryId, failure);
}
async createAddressBook(name: string): Promise<AddressBook> {
@@ -8322,6 +8335,8 @@ export class JMAPClient implements IJMAPClient {
], this.contactUsing());
const queryResult = response.methodResponses?.[0];
// A refused call isn't an empty address book: the caller keeps what it has.
if (queryResult?.[0] === "error") throw new Error(queryResult[1]?.description || queryResult[1]?.type || "ContactCard/query failed");
if (queryResult?.[0] !== "ContactCard/query") break;
const ids: string[] = queryResult[1].ids || [];
@@ -8344,8 +8359,10 @@ export class JMAPClient implements IJMAPClient {
["ContactCard/get", { accountId, ids: chunk }, "g"],
], this.contactUsing());
if (response.methodResponses?.[0]?.[0] === "ContactCard/get") {
const list = (response.methodResponses[0][1].list || []) as ContactCard[];
const getResult = response.methodResponses?.[0];
if (getResult?.[0] === "error") throw new Error(getResult[1]?.description || getResult[1]?.type || "ContactCard/get failed");
if (getResult?.[0] === "ContactCard/get") {
const list = (getResult[1].list || []) as ContactCard[];
allContacts.push(...list.map(contactFromWire));
}
}
@@ -8365,10 +8382,17 @@ export class JMAPClient implements IJMAPClient {
}
}
/**
* Throws PartialAccountsError when an account couldn't answer, with what the
* others gave, so a failure doesn't read as that account having no cards.
*/
async getAllContacts(): Promise<ContactCard[]> {
const failed: string[] = [];
let failure: unknown;
let allContacts: ContactCard[] = [];
const primaryId = this.getContactsAccountId();
try {
const allContacts: ContactCard[] = [];
const primaryId = this.getContactsAccountId();
allContacts = [];
const accountIds = this.getContactCapableAccountIds();
for (const accountId of accountIds) {
@@ -8391,14 +8415,17 @@ export class JMAPClient implements IJMAPClient {
allContacts.push(...contacts);
} catch (error) {
console.error(`Failed to fetch contacts for account ${accountId}:`, error);
failed.push(accountId);
failure ??= error;
}
}
return allContacts;
if (failed.length === 0) return allContacts;
} catch (error) {
console.error('Failed to fetch all contacts:', error);
return this.getContacts();
}
throw new PartialAccountsError(allContacts, failed, primaryId, failure);
}
async getContact(contactId: string, accountId?: string): Promise<ContactCard | null> {
+27
View File
@@ -0,0 +1,27 @@
/**
* A listing across JMAP accounts where at least one account couldn't answer.
* It carries what the others gave. An account that failed isn't an account
* with nothing in it, so the caller keeps what it had for that one.
*/
export class PartialAccountsError<T> extends Error {
constructor(
readonly items: T[],
readonly failedAccountIds: string[],
readonly primaryAccountId: string,
cause: unknown,
) {
super(cause instanceof Error ? cause.message : 'An account could not answer');
this.name = 'PartialAccountsError';
}
}
/** By name: `instanceof` fails across Next's bundle copies. */
export function partialAccounts<T>(error: unknown): PartialAccountsError<T> | null {
return error instanceof Error && error.name === 'PartialAccountsError' ? error as PartialAccountsError<T> : null;
}
/** What to show after a partial listing: the answers, and what's held for the accounts that failed. */
export function withFailedAccountsKept<T extends { accountId?: string }>(partial: PartialAccountsError<T>, held: readonly T[]): T[] {
const failed = new Set(partial.failedAccountIds);
return [...partial.items, ...held.filter((item) => failed.has(item.accountId ?? partial.primaryAccountId))];
}
+136
View File
@@ -0,0 +1,136 @@
// @vitest-environment node
/**
* The real JMAPClient reading the store's answers: what the client's own code
* makes of them is the contract, not the store's idea of the shape.
*/
import { afterEach, describe, expect, it } from 'vitest';
import { JMAPClient } from '@/lib/jmap/client';
import { setOfflineHooks, type OfflineHooks } from '@/lib/jmap/offline-hooks';
import { partialAccounts } from '@/lib/jmap/partial-accounts';
import type { AddressBook, ContactCard } from '@/lib/jmap/types';
import { DEFAULT_SCOPE } from '../area';
import { contactsArea } from '../contacts';
import { AccountEngine } from '../engine/account-engine';
import { JMAP_ACCOUNT } from './helpers/fake-jmap';
import { CONTACTS_SESSION, FakeContacts, SHARED_ACCOUNT } from './helpers/fake-contacts';
import { openMemoryStore } from './helpers/node-adapter';
afterEach(() => setOfflineHooks(null));
async function offlineClient(server: FakeContacts): Promise<{ client: JMAPClient; engine: AccountEngine }> {
const engine = new AccountEngine({
account: 'a@fake.test',
db: openMemoryStore(),
session: CONTACTS_SESSION,
transport: server,
areas: [contactsArea],
scope: DEFAULT_SCOPE,
sleep: async () => {},
});
await engine.requestSync();
engine.setOnline(false);
const hooks: Partial<OfflineHooks> = {
skipNetwork: () => true,
answer: (_client, calls) => engine.answer(calls),
record: () => {},
noteNetwork: () => {},
};
setOfflineHooks(hooks as OfflineHooks);
const client = JMAPClient.fromOfflineSession('https://fake.test', 'a@example.org', {
...CONTACTS_SESSION,
capabilities: { ...CONTACTS_SESSION.capabilities, 'urn:ietf:params:jmap:core': { maxObjectsInGet: 2 } },
});
return { client, engine };
}
function seeded(): FakeContacts {
const server = new FakeContacts();
for (const full of ['Anna Bauer', 'Bernd Graf', 'Carla Müller']) server.addCard({ name: { full } });
server.addBook({ id: 'teambook', name: 'Team' }, SHARED_ACCOUNT);
server.addCard({ id: 'c1', name: { full: 'Dora Team' }, addressBookIds: { teambook: true } }, SHARED_ACCOUNT);
return server;
}
describe('the client over the store', () => {
it('reports a store that has nothing yet as a failure, not as no contacts', async () => {
const engine = new AccountEngine({
account: 'a@fake.test', db: openMemoryStore(), session: CONTACTS_SESSION, transport: new FakeContacts(),
areas: [contactsArea], scope: DEFAULT_SCOPE, sleep: async () => {},
});
// Answered per call, with one more call the store can answer: the error isn't hidden by a null answer.
const perCall: Partial<OfflineHooks> = {
skipNetwork: () => true,
answer: async (_client, calls) => (await engine.answer(calls)) ?? {
methodResponses: calls.map(([, , id]) => ['error', { type: 'serverUnavailable' }, id]),
},
record: () => {},
noteNetwork: () => {},
};
setOfflineHooks(perCall as OfflineHooks);
const client = JMAPClient.fromOfflineSession('https://fake.test', 'a@example.org', CONTACTS_SESSION);
await expect(client.getAllContacts()).rejects.toThrow();
await expect(client.getAllAddressBooks()).rejects.toThrow();
});
it('lists all contacts and books across accounts, paging in its own batch size', async () => {
const { client } = await offlineClient(seeded());
const contacts = await client.getAllContacts();
expect(contacts.map((c) => c.name?.full).sort()).toEqual(['Anna Bauer', 'Bernd Graf', 'Carla Müller', 'Dora Team']);
const shared = contacts.find((c) => c.name?.full === 'Dora Team');
expect(shared).toMatchObject({ id: `${SHARED_ACCOUNT}:c1`, originalId: 'c1', isShared: true });
const books = await client.getAllAddressBooks();
expect(books.map((b) => b.id).sort()).toEqual(['book1', `${SHARED_ACCOUNT}:teambook`]);
});
it('says which account failed, with what the others gave', async () => {
const { client, engine } = await offlineClient(seeded());
const failPrimary: Partial<OfflineHooks> = {
skipNetwork: () => true,
answer: async (_client, calls) => (calls[0][1].accountId === JMAP_ACCOUNT
? { methodResponses: calls.map(([, , id]) => ['error', { type: 'serverUnavailable' }, id]) }
: engine.answer(calls)),
record: () => {},
noteNetwork: () => {},
};
setOfflineHooks(failPrimary as OfflineHooks);
const contacts = await client.getAllContacts().catch((error: unknown) => error);
const partial = partialAccounts<ContactCard>(contacts);
expect(partial?.failedAccountIds).toEqual([JMAP_ACCOUNT]);
expect(partial?.primaryAccountId).toBe(JMAP_ACCOUNT);
expect(partial?.items.map((c) => c.name?.full)).toEqual(['Dora Team']);
const books = partialAccounts<AddressBook>(await client.getAllAddressBooks().catch((error: unknown) => error));
expect(books?.failedAccountIds).toEqual([JMAP_ACCOUNT]);
expect(books?.items.map((b) => b.originalId)).toEqual(['teambook']);
});
it('searches with its own filter', async () => {
const { client } = await offlineClient(seeded());
expect((await client.searchContacts('müll')).map((c) => c.name?.full)).toEqual(['Carla Müller']);
expect((await client.searchContacts('team')).map((c) => c.originalId)).toEqual(['c1']);
});
it('creates, edits and deletes through its own methods with the stand-in id', async () => {
const { client, engine } = await offlineClient(seeded());
const created = await client.createContact({ name: { full: 'Neue Person' }, emails: { e: { address: 'neu@example.org' } } });
expect(created.id).toBeTruthy();
expect(created.addressBookIds).toEqual({ book1: true });
await client.updateContact(created.id, { name: { full: 'Neue Person B' } });
expect((await client.getContact(created.id))?.name?.full).toBe('Neue Person B');
expect((await client.searchContacts('person b')).map((c) => c.id)).toEqual([created.id]);
await client.deleteContact(created.id);
expect(await client.getContact(created.id)).toBeNull();
expect(await engine.areaCall('contacts.pending', undefined)).toBe(0);
});
it('throws the server-style error for an edit to a card it does not have', async () => {
const { client } = await offlineClient(seeded());
await expect(client.updateContact('nope', { name: { full: 'X' } })).rejects.toThrow();
await expect(client.deleteContact('nope')).rejects.toThrow();
});
it('reads an account the store has as the primary contacts account', async () => {
const { client } = await offlineClient(seeded());
expect(client.getContactsAccountId()).toBe(JMAP_ACCOUNT);
expect((await client.getContacts('book1')).length).toBe(3);
});
});
+88
View File
@@ -0,0 +1,88 @@
// @vitest-environment node
/**
* The real JMAPClient listing files from the store, in each way a server says
* what a node is, plus what it does with a write.
*/
import { afterEach, describe, expect, it } from 'vitest';
import { JMAPClient } from '@/lib/jmap/client';
import { fileNodeKind } from '@/lib/jmap/filenode-kind';
import { setOfflineHooks, type OfflineHooks } from '@/lib/jmap/offline-hooks';
import { DEFAULT_SCOPE } from '../area';
import { AccountEngine } from '../engine/account-engine';
import { filesArea } from '../files';
import { FakeFiles, FILES, FILES_SESSION, SHARED_FILES_ACCOUNT, type FakeFilesOptions } from './helpers/fake-files';
import { openMemoryStore } from './helpers/node-adapter';
afterEach(() => setOfflineHooks(null));
async function offlineClient(server: FakeFiles): Promise<JMAPClient> {
const engine = new AccountEngine({
account: 'a@fake.test',
db: openMemoryStore(),
session: FILES_SESSION,
transport: server,
areas: [filesArea],
scope: DEFAULT_SCOPE,
sleep: async () => {},
});
await engine.requestSync();
engine.setOnline(false);
setOfflineHooks({
skipNetwork: () => true,
answer: (_client, calls) => engine.answer(calls),
record: () => {},
noteNetwork: () => {},
} as Partial<OfflineHooks> as OfflineHooks);
return JMAPClient.fromOfflineSession('https://fake.test', 'a@example.org', {
...FILES_SESSION,
capabilities: { ...FILES_SESSION.capabilities, 'urn:ietf:params:jmap:core': { maxObjectsInGet: 500 } },
});
}
describe('file listing offline', () => {
it.each<[string, FakeFilesOptions]>([
['nodeType (0.16.6 and later, 1.0 as tested)', { style: 'nodeType' }],
['blobId null only (0.16.0-5)', { style: 'blob' }],
['@type and mediaType (1.0.0)', { style: 'draft' }],
])('tells folders from files: %s', async (_label, options) => {
const server = new FakeFiles(options);
const folder = server.addFolder('Docs');
server.addFile('note.txt', folder.id);
const client = await offlineClient(server);
expect(client.supportsFiles()).toBe(true);
const nodes = await client.listAllFileNodes();
const byName = new Map(nodes.map((n) => [n.name, n]));
expect(fileNodeKind(byName.get('Docs') as never)).toBe('directory');
expect(fileNodeKind(byName.get('note.txt') as never)).toBe('file');
expect(byName.get('note.txt')).toMatchObject({ parentId: folder.id, type: 'text/plain', size: 10 });
expect((await client.listFileNodes(folder.id)).map((n) => n.name)).toEqual(['note.txt']);
expect((await client.listFileNodes(null)).map((n) => n.name)).toEqual(['Docs']);
});
it('lists an account larger than one /get, ending its paging cleanly', async () => {
const server = new FakeFiles({ getAll: 'refuse' });
const folder = server.addFolder('Docs');
for (let i = 0; i < 700; i += 1) server.addFile(`f${i}`, folder.id);
const client = await offlineClient(server);
expect(await client.listAllFileNodes()).toHaveLength(701);
});
it('lists shared accounts with namespaced ids', async () => {
const server = new FakeFiles();
server.addFolder('Mine');
const shared = server.addFolder('Theirs', null, undefined, SHARED_FILES_ACCOUNT);
const client = await offlineClient(server);
const nodes = await client.listAllFileNodesAcrossAccounts();
expect(nodes.find((n) => n.name === 'Theirs')).toMatchObject({ id: `${SHARED_FILES_ACCOUNT}:${shared.id}`, isShared: true });
expect(nodes.find((n) => n.name === 'Mine')).toMatchObject({ isShared: false });
});
it('fails a write with an error rather than losing it', async () => {
const server = new FakeFiles();
const client = await offlineClient(server);
await expect(client.createFileDirectory('New', null)).rejects.toThrow();
expect(FILES in FILES_SESSION.capabilities).toBe(true);
expect(server.calls.some(([n]) => n === 'FileNode/set')).toBe(false);
});
});
File diff suppressed because it is too large Load Diff
+535
View File
@@ -0,0 +1,535 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest';
import { DEFAULT_SCOPE, type StoreEvent, type StoreScope } from '../area';
import { AccountEngine } from '../engine/account-engine';
import { TransportError } from '../engine/transport';
import { fileBlob, FILE_CACHE_CAP_BYTES, filesArea } from '../files';
import type { StorageAdapter } from '../storage';
import { JMAP_ACCOUNT } from './helpers/fake-jmap';
import { FakeFiles, FILES_SESSION, SHARED_FILES_ACCOUNT } from './helpers/fake-files';
import { openMemoryStore } from './helpers/node-adapter';
const NOW = Date.UTC(2026, 10, 3);
/* eslint-disable @typescript-eslint/no-explicit-any -- answers are shaped like the server's */
interface Rig {
db: StorageAdapter;
engine: AccountEngine;
clock: { at: number };
events: StoreEvent[];
}
function rigFor(server: FakeFiles, scope: Partial<StoreScope> = {}): Rig {
const db = openMemoryStore();
const clock = { at: NOW };
const events: StoreEvent[] = [];
const engine = new AccountEngine({
account: 'a@fake.test',
db,
session: FILES_SESSION,
transport: server,
areas: [filesArea],
scope: { ...DEFAULT_SCOPE, ...scope },
emit: (event) => events.push(event),
now: () => clock.at,
sleep: async () => {},
});
return { db, engine, clock, events };
}
async function synced(server: FakeFiles, scope: Partial<StoreScope> = {}): Promise<Rig> {
const rig = rigFor(server, scope);
await rig.engine.requestSync();
return rig;
}
async function call(engine: AccountEngine, name: string, args: Record<string, unknown> = {}): Promise<any> {
const response = await engine.answer([[name, { accountId: JMAP_ACCOUNT, ...args }, '0']]);
if (!response) throw new Error('nothing answered');
const [answered, result] = response.methodResponses[0];
if (answered === 'error') throw new Error(result.type);
return result;
}
const ref = (id: string, jmap = JMAP_ACCOUNT) => ({ jmap, id });
function names(rows: Array<{ name?: unknown }>): unknown[] {
return rows.map((row) => row.name).sort();
}
describe('file metadata sync', () => {
it('stores small accounts from one FileNode/get', async () => {
const server = new FakeFiles();
const folder = server.addFolder('Docs');
server.addFile('a.txt', folder.id);
const { engine } = await synced(server);
expect(server.calls.filter(([n]) => n === 'FileNode/query')).toHaveLength(0);
expect(names((await call(engine, 'FileNode/get', { ids: null })).list)).toEqual(['Docs', 'a.txt']);
});
it.each([
['0.16 cuts the list at 500', { getAll: 'cut' as const }],
['1.0 refuses a list over 500', { getAll: 'refuse' as const }],
])('pages past the /get limit: %s', async (_label, options) => {
const server = new FakeFiles(options);
const folder = server.addFolder('Docs');
for (let i = 0; i < 1234; i += 1) server.addFile(`f${i}.txt`, folder.id);
const { db } = await synced(server);
expect(Number(db.get<{ n: number }>('SELECT COUNT(*) AS n FROM file_node')?.n)).toBe(1235);
for (const [name, args] of server.calls) {
if (name === 'FileNode/get' && Array.isArray(args.ids)) expect(args.ids.length).toBeLessThanOrEqual(500);
}
});
it('finds folders the query leaves out on old servers, by the parents of what it did find', async () => {
const server = new FakeFiles({ queryOmitsFolders: true, style: 'blob' });
for (let i = 0; i < 600; i += 1) server.addFile(`early${i}.txt`);
const top = server.addFolder('Top');
const inner = server.addFolder('Inner', top.id);
server.addFile('deep.txt', inner.id);
const { db } = await synced(server);
const kinds = db.all<{ name: string; kind: string }>("SELECT name, kind FROM file_node WHERE name IN ('Top', 'Inner', 'deep.txt') ORDER BY name");
expect(kinds).toEqual([{ name: 'Inner', kind: 'directory' }, { name: 'Top', kind: 'directory' }, { name: 'deep.txt', kind: 'file' }]);
});
it('continues a stopped full sync from its cursor', async () => {
const server = new FakeFiles({ getAll: 'refuse' });
for (let i = 0; i < 6000; i += 1) server.addFile(`f${i}`);
const rig = rigFor(server);
let gets = 0;
server.beforeRequest = () => {
gets += 1;
// State capture, the refused list, a query page of 5000 and two requests of gets: then the network goes.
if (gets === 6) server.offline = true;
};
await rig.engine.requestSync();
expect(Number(rig.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM file_node')?.n)).toBe(5000);
server.offline = false;
server.beforeRequest = null;
server.calls.length = 0;
await rig.engine.requestSync();
expect(Number(rig.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM file_node')?.n)).toBe(6000);
expect(server.calls.find(([n]) => n === 'FileNode/query')?.[1].position).toBe(5000);
expect(server.calls.filter(([n, a]) => n === 'FileNode/get' && a.ids === null && a.accountId === JMAP_ACCOUNT)).toHaveLength(0);
});
it('follows changes through FileNode/changes', async () => {
const server = new FakeFiles();
const a = server.addFile('a.txt');
const b = server.addFile('b.txt');
const { engine } = await synced(server);
server.calls.length = 0;
server.renameNode(a.id, 'renamed.txt');
server.removeNode(b.id);
server.addFolder('New');
await engine.requestSync();
expect(server.calls.some(([n]) => n === 'FileNode/changes')).toBe(true);
expect(server.calls.some(([n, args]) => n === 'FileNode/get' && args.ids === null)).toBe(false);
expect(names((await call(engine, 'FileNode/get', { ids: null })).list)).toEqual(['New', 'renamed.txt']);
});
it('starts over when the server can no longer give changes, and keeps a pin on a file still there', async () => {
const server = new FakeFiles();
const gone = server.addFile('gone.txt');
const kept = server.addFile('kept.txt');
const { db, engine } = await synced(server);
await engine.areaCall('files.pin', ref(kept.id));
await engine.requestSync();
server.removeNode(gone.id);
server.account().coll.truncatedBelow = server.account().coll.log.length;
await engine.requestSync();
expect(db.all('SELECT id FROM file_node').map((r) => r.id)).toEqual([kept.id]);
expect(db.get<{ pinned: number }>('SELECT pinned FROM file_node WHERE id = ?', [kept.id])?.pinned).toBe(1);
});
it('keeps accounts apart, even with the same ids', async () => {
const server = new FakeFiles();
server.addFile('own.txt', null, 5, { id: 'same' });
server.addFile('team.txt', null, 5, { id: 'same', blobId: 'team-blob' }, SHARED_FILES_ACCOUNT);
const { engine } = await synced(server);
expect(names((await call(engine, 'FileNode/get', { ids: ['same'] })).list)).toEqual(['own.txt']);
expect(names((await call(engine, 'FileNode/get', { ids: ['same'], accountId: SHARED_FILES_ACCOUNT })).list)).toEqual(['team.txt']);
});
});
describe('file answers', () => {
it('says so instead of an empty list when nothing was read yet', async () => {
const { engine } = rigFor(new FakeFiles());
expect(await engine.answer([['FileNode/get', { accountId: JMAP_ACCOUNT, ids: null }, '0']])).toBeNull();
});
it('gives the last server state and only asked-for properties', async () => {
const server = new FakeFiles();
const file = server.addFile('a.txt');
const { engine } = await synced(server);
const got = await call(engine, 'FileNode/get', { ids: [file.id, 'nope'], properties: ['name', 'parentId'] });
expect(got.state).toBe(server.account().coll.state());
expect(got.list).toEqual([{ id: file.id, name: 'a.txt', parentId: null }]);
expect(got.notFound).toEqual(['nope']);
});
it('answers a query by parent, also at the root, which the real server refuses to take', async () => {
const server = new FakeFiles();
const folder = server.addFolder('Docs');
const inside = server.addFile('in.txt', folder.id);
const root = server.addFile('root.txt');
const { engine } = await synced(server);
expect((await call(engine, 'FileNode/query', { filter: { parentId: folder.id } })).ids).toEqual([inside.id]);
expect((await call(engine, 'FileNode/query', { filter: { parentId: null } })).ids).toEqual([folder.id, root.id]);
});
it('pages and sorts a query', async () => {
const server = new FakeFiles();
server.addFile('b', null, 30);
server.addFile('a', null, 10);
server.addFile('c', null, 20);
const { engine } = await synced(server);
const page = await call(engine, 'FileNode/query', { filter: {}, position: 1, limit: 1, calculateTotal: true });
expect(page).toMatchObject({ position: 1, total: 3, ids: [expect.any(String)] });
const bySize = await call(engine, 'FileNode/query', { filter: {}, sort: [{ property: 'size', isAscending: false }] });
const list = (await call(engine, 'FileNode/get', { ids: bySize.ids })).list;
expect(list.map((n: { name: string }) => n.name)).toEqual(['b', 'c', 'a']);
});
it('refuses what it cannot read, for that call only', async () => {
const server = new FakeFiles();
server.addFile('a');
const { engine } = await synced(server);
const response = await engine.answer([
['FileNode/query', { accountId: JMAP_ACCOUNT, filter: { hasFoo: true } }, '0'],
['FileNode/get', { accountId: JMAP_ACCOUNT, ids: null }, '1'],
]);
expect(response?.methodResponses[0][1].type).toBe('unsupportedFilter');
expect(response?.methodResponses[1][0]).toBe('FileNode/get');
});
it('answers FileNode/set with a clear per-call error, not silence', async () => {
const server = new FakeFiles();
server.addFile('a');
const { engine } = await synced(server);
const response = await engine.answer([
['FileNode/set', { accountId: JMAP_ACCOUNT, create: { n0: { name: 'x' } } }, '0'],
['FileNode/get', { accountId: JMAP_ACCOUNT, ids: null }, '1'],
]);
expect(response?.methodResponses[0]).toEqual(['error', { type: 'serverUnavailable', description: 'FileNode/set needs the server' }, '0']);
expect(response?.methodResponses[1][0]).toBe('FileNode/get');
expect(server.calls.some(([n]) => n === 'FileNode/set')).toBe(false);
});
});
describe('pinned files', () => {
it('fetches a pinned file and gives it back byte for byte, past the chunk size', async () => {
const server = new FakeFiles();
const content = new Uint8Array(3 * 1024 * 1024 + 17).map((_, i) => i % 251);
const file = server.addFile('big.bin', null, content);
const { db, engine } = await synced(server);
expect(server.downloads).toEqual([]);
expect(await engine.areaCall('files.pin', ref(file.id))).toEqual({ kept: false });
await engine.requestSync();
expect(server.downloads).toEqual([{ jmap: JMAP_ACCOUNT, blobId: file.blobId }]);
expect(Number(db.get<{ n: number }>('SELECT COUNT(*) AS n FROM file_blob_chunk')?.n)).toBe(4);
const read = await engine.areaCall('files.read', ref(file.id)) as any;
expect(read.name).toBe('big.bin');
expect(read.type).toBe('text/plain');
expect(Buffer.compare(read.data, content)).toBe(0);
expect(Buffer.compare(fileBlob({ db }, JMAP_ACCOUNT, file.blobId as string)?.data as Uint8Array, content)).toBe(0);
expect(await engine.areaCall('files.blob', { jmap: JMAP_ACCOUNT, blobId: file.blobId })).toMatchObject({ size: content.length });
// Already kept: not fetched again.
await engine.requestSync();
expect(server.downloads).toHaveLength(1);
expect(await engine.areaCall('files.pin', ref(file.id))).toEqual({ kept: true });
});
it('names a file with a %-encoded name from 0.16 WebDAV the way the client shows it', async () => {
const server = new FakeFiles();
const file = server.addFile('My%20Report.pdf');
const { engine } = await synced(server);
await engine.areaCall('files.pin', ref(file.id));
await engine.requestSync();
expect((await engine.areaCall('files.read', ref(file.id)) as any).name).toBe('My Report.pdf');
expect((await engine.areaCall('files.kept', undefined) as any).files[0].name).toBe('My Report.pdf');
// The stored node stays as the server sent it. The client decodes it once.
expect((await call(engine, 'FileNode/get', { ids: [file.id] })).list[0].name).toBe('My%20Report.pdf');
});
it('gives nothing for a file not kept', async () => {
const server = new FakeFiles();
const file = server.addFile('a.txt');
const { db, engine } = await synced(server);
expect(await engine.areaCall('files.read', ref(file.id))).toBeNull();
expect(fileBlob({ db }, JMAP_ACCOUNT, file.blobId as string)).toBeNull();
});
it('refuses to pin a folder or an unknown id', async () => {
const server = new FakeFiles();
const folder = server.addFolder('Docs');
const { engine } = await synced(server);
await expect(engine.areaCall('files.pin', ref(folder.id))).rejects.toThrow('Only files');
await expect(engine.areaCall('files.pin', ref('nope'))).rejects.toThrow();
});
it('downloads one at a time', async () => {
const server = new FakeFiles();
const files = [server.addFile('a'), server.addFile('b'), server.addFile('c')];
const { engine } = await synced(server);
for (const file of files) await engine.areaCall('files.pin', ref(file.id));
await engine.requestSync();
expect(server.downloads).toHaveLength(3);
expect(server.mostAtOnce).toBe(1);
});
it('fetches a pinned file over the one-file limit, and skips an opened one', async () => {
const server = new FakeFiles();
const pinned = server.addFile('pinned.bin', null, 100);
const opened = server.addFile('opened.bin', null, 100);
const { engine } = await synced(server, { maxAttachmentBytes: 50 });
await engine.areaCall('files.pin', ref(pinned.id));
await engine.areaCall('files.noteOpened', ref(opened.id));
await engine.requestSync();
expect(server.downloads.map((d) => d.blobId)).toEqual([pinned.blobId]);
const status = await engine.areaCall('files.kept', undefined) as any;
const row = (name: string) => status.files.find((f: { name: string }) => f.name === name);
expect(row('pinned.bin')).toMatchObject({ pinned: true, kept: true, error: null });
expect(row('opened.bin')).toMatchObject({ pinned: false, kept: false, error: 'tooLarge' });
});
it('does not take pins past the total limit, and says so', async () => {
const server = new FakeFiles();
const a = server.addFile('a', null, 60);
const b = server.addFile('b', null, 60);
const { engine, clock } = await synced(server, { maxTotalBytes: 100 });
await engine.areaCall('files.pin', ref(a.id));
clock.at += 1;
await engine.areaCall('files.pin', ref(b.id));
await engine.requestSync();
expect(server.downloads).toHaveLength(1);
const status = await engine.areaCall('files.kept', undefined) as any;
expect(status.files.filter((f: { kept: boolean }) => f.kept)).toHaveLength(1);
expect(status.files.filter((f: { error: string | null }) => f.error === 'overLimit')).toHaveLength(1);
});
it('keeps nothing when the scope forbids it, and drops what was kept', async () => {
const server = new FakeFiles();
const file = server.addFile('a');
const rig = await synced(server);
await rig.engine.areaCall('files.pin', ref(file.id));
await rig.engine.requestSync();
expect(await rig.engine.areaCall('files.read', ref(file.id))).not.toBeNull();
rig.engine.setScope({ ...DEFAULT_SCOPE, attachments: false });
await rig.engine.requestSync();
expect(await rig.engine.areaCall('files.read', ref(file.id))).toBeNull();
expect(Number(rig.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM file_blob_chunk')?.n)).toBe(0);
expect((await rig.engine.areaCall('files.kept', undefined) as any).files[0]).toMatchObject({ pinned: true, kept: false, error: 'notAllowed' });
rig.engine.setScope(DEFAULT_SCOPE);
await rig.engine.requestSync();
expect(await rig.engine.areaCall('files.read', ref(file.id))).not.toBeNull();
});
it('goes by the real size when the metadata has it wrong', async () => {
const server = new FakeFiles();
const opened = server.addFile('opened', null, new Uint8Array(100), { size: 1 });
const { engine } = await synced(server, { maxAttachmentBytes: 50 });
await engine.areaCall('files.noteOpened', ref(opened.id));
await engine.requestSync();
expect(server.downloads).toHaveLength(1);
expect(await engine.areaCall('files.read', ref(opened.id))).toBeNull();
expect((await engine.areaCall('files.kept', undefined) as any).files[0].error).toBe('tooLarge');
});
it('does not store a file unpinned while it was downloading', async () => {
const server = new FakeFiles();
const file = server.addFile('a');
const { engine } = await synced(server);
await engine.areaCall('files.pin', ref(file.id));
const original = server.download.bind(server);
server.download = async (jmap, blobId) => {
await engine.areaCall('files.unpin', ref(file.id));
return original(jmap, blobId);
};
await engine.requestSync();
expect(server.downloads).toHaveLength(1);
expect(await engine.areaCall('files.read', ref(file.id))).toBeNull();
});
it('lets a never-opened file go on unpin, and keeps an opened one as recent', async () => {
const server = new FakeFiles();
const never = server.addFile('never');
const opened = server.addFile('opened');
const { engine } = await synced(server);
await engine.areaCall('files.pin', ref(never.id));
await engine.areaCall('files.pin', ref(opened.id));
await engine.areaCall('files.noteOpened', ref(opened.id));
await engine.requestSync();
await engine.areaCall('files.unpin', ref(never.id));
await engine.areaCall('files.unpin', ref(opened.id));
expect(await engine.areaCall('files.read', ref(never.id))).toBeNull();
expect(await engine.areaCall('files.read', ref(opened.id))).not.toBeNull();
const status = await engine.areaCall('files.kept', undefined) as any;
expect(status.files.map((f: { name: string; pinned: boolean }) => [f.name, f.pinned])).toEqual([['opened', false]]);
});
it('drops the old content and fetches the new one when a pinned file changes', async () => {
const server = new FakeFiles();
const file = server.addFile('a.txt', null, new Uint8Array([1, 2, 3]));
const { db, engine } = await synced(server);
await engine.areaCall('files.pin', ref(file.id));
await engine.requestSync();
const old = file.blobId as string;
const fresh = server.replaceContent(file.id, new Uint8Array([9, 9]));
await engine.requestSync();
expect(fileBlob({ db }, JMAP_ACCOUNT, old)).toBeNull();
expect([...(fileBlob({ db }, JMAP_ACCOUNT, fresh)?.data ?? [])]).toEqual([9, 9]);
expect(Array.from((await engine.areaCall('files.read', ref(file.id)) as any).data)).toEqual([9, 9]);
});
it('drops the content when the file is removed on the server, unless another node shares it', async () => {
const server = new FakeFiles();
const a = server.addFile('a', null, 5, { blobId: 'shared-blob' });
const b = server.addFile('b', null, 5, { blobId: 'shared-blob' });
const { db, engine } = await synced(server);
await engine.areaCall('files.pin', ref(a.id));
await engine.areaCall('files.pin', ref(b.id));
await engine.requestSync();
expect(server.downloads).toHaveLength(1);
server.removeNode(a.id);
await engine.requestSync();
expect(fileBlob({ db }, JMAP_ACCOUNT, 'shared-blob')).not.toBeNull();
server.removeNode(b.id);
await engine.requestSync();
expect(fileBlob({ db }, JMAP_ACCOUNT, 'shared-blob')).toBeNull();
expect(Number(db.get<{ n: number }>('SELECT COUNT(*) AS n FROM file_blob_chunk')?.n)).toBe(0);
});
it('carries on past one bad file, and hands a network failure to the engine', async () => {
const server = new FakeFiles();
const bad = server.addFile('bad');
const good = server.addFile('good');
server.blobs.delete(bad.blobId as string);
const { engine, clock } = await synced(server);
await engine.areaCall('files.pin', ref(bad.id));
clock.at += 1;
await engine.areaCall('files.pin', ref(good.id));
await engine.requestSync();
expect(await engine.areaCall('files.read', ref(good.id))).not.toBeNull();
const status = await engine.areaCall('files.kept', undefined) as any;
expect(status.files.find((f: { name: string }) => f.name === 'bad').error).toBe('download');
});
it('hands a network failure to the engine, which ends the pass, and goes on next time', async () => {
const server = new FakeFiles();
const file = server.addFile('a');
const rig = await synced(server);
await rig.engine.areaCall('files.pin', ref(file.id));
const original = server.download.bind(server);
server.download = async () => {
throw new TransportError('network', 'Failed to fetch');
};
const syncedBefore = rig.events.filter((e) => e.type === 'synced').length;
await rig.engine.requestSync();
expect(rig.events.some((e) => e.type === 'error')).toBe(false);
expect(rig.events.filter((e) => e.type === 'synced').length).toBe(syncedBefore);
expect((await rig.engine.areaCall('files.kept', undefined) as any).files[0]).toMatchObject({ kept: false, error: null });
server.download = original;
await rig.engine.requestSync();
expect(await rig.engine.areaCall('files.read', ref(file.id))).not.toBeNull();
});
});
describe('recently opened files', () => {
it('keeps an opened file without a pin', async () => {
const server = new FakeFiles();
const file = server.addFile('a');
const { engine } = await synced(server);
expect(await engine.areaCall('files.noteOpened', ref(file.id))).toEqual({ kept: false });
await engine.requestSync();
expect(await engine.areaCall('files.read', ref(file.id))).not.toBeNull();
expect(await engine.areaCall('files.noteOpened', ref(file.id))).toEqual({ kept: true });
});
it('pushes out the oldest unpinned first, and never a pinned one', async () => {
const server = new FakeFiles();
const files = Object.fromEntries(['old', 'pinnedOldest', 'mid', 'new', 'newest'].map((name) => [name, server.addFile(name, null, 10)]));
const rig = await synced(server, { maxTotalBytes: 30 });
const open = async (name: string) => {
rig.clock.at += 1000;
await rig.engine.areaCall('files.noteOpened', ref(files[name].id));
await rig.engine.requestSync();
};
await open('pinnedOldest');
await rig.engine.areaCall('files.pin', ref(files.pinnedOldest.id));
await open('old');
await open('mid');
const kept = async () => (await rig.engine.areaCall('files.kept', undefined) as any).files
.filter((f: { kept: boolean }) => f.kept).map((f: { name: string }) => f.name).sort();
expect(await kept()).toEqual(['mid', 'old', 'pinnedOldest']);
await open('new');
expect(await kept()).toEqual(['mid', 'new', 'pinnedOldest']);
await open('mid');
await open('newest');
expect(await kept()).toEqual(['mid', 'newest', 'pinnedOldest']);
expect((await rig.engine.areaCall('files.kept', undefined) as any).bytes).toBe(30);
// A pinned file was never pushed out and had to come back.
expect(server.downloads.filter((d) => d.blobId === files.pinnedOldest.blobId)).toHaveLength(1);
// Pushed out means forgotten: the next pass doesn't fetch it straight back.
expect(server.downloads.filter((d) => d.blobId === files.old.blobId)).toHaveLength(1);
const count = server.downloads.length;
await rig.engine.requestSync();
expect(server.downloads).toHaveLength(count);
expect((await rig.engine.areaCall('files.kept', undefined) as any).files.map((f: { name: string }) => f.name)).not.toContain('old');
});
it('pushes out recent files for a pin, and keeps a pin past the cap', async () => {
const server = new FakeFiles();
const recent = server.addFile('recent', null, 20);
const pin = server.addFile('pin', null, 20);
const rig = await synced(server, { maxTotalBytes: 25 });
await rig.engine.areaCall('files.noteOpened', ref(recent.id));
await rig.engine.requestSync();
rig.clock.at += 1000;
await rig.engine.areaCall('files.pin', ref(pin.id));
await rig.engine.requestSync();
expect(await rig.engine.areaCall('files.read', ref(recent.id))).toBeNull();
expect(await rig.engine.areaCall('files.read', ref(pin.id))).not.toBeNull();
});
it('keeps recently opened files under 500 MB whatever the total limit is', async () => {
const server = new FakeFiles();
const file = server.addFile('new', null, 10);
const rig = await synced(server, { maxTotalBytes: 2 * FILE_CACHE_CAP_BYTES });
// As if an older file already took all of it: only the row counts here.
rig.db.run('INSERT INTO file_blob (jmap, blob_id, type, size, kept_at) VALUES (?, ?, ?, ?, ?)', [JMAP_ACCOUNT, 'huge', 'x', FILE_CACHE_CAP_BYTES, NOW - 1000]);
rig.db.run("INSERT INTO file_node (jmap, id, name, kind, blob_id, size, json, opened_at) VALUES (?, 'hugenode', 'huge', 'file', 'huge', ?, '{}', ?)", [JMAP_ACCOUNT, FILE_CACHE_CAP_BYTES, NOW - 1000]);
await rig.engine.areaCall('files.noteOpened', ref(file.id));
await rig.engine.requestSync();
expect(fileBlob({ db: rig.db }, JMAP_ACCOUNT, 'huge')).toBeNull();
expect(await rig.engine.areaCall('files.read', ref(file.id))).not.toBeNull();
expect((await rig.engine.areaCall('files.kept', undefined) as any).capBytes).toBe(FILE_CACHE_CAP_BYTES);
});
it('drops an old opened file that does not fit rather than a newer one', async () => {
const server = new FakeFiles();
const a = server.addFile('a', null, 20);
const b = server.addFile('b', null, 20);
const rig = await synced(server, { maxTotalBytes: 25 });
await rig.engine.areaCall('files.noteOpened', ref(a.id));
rig.clock.at += 1000;
await rig.engine.areaCall('files.noteOpened', ref(b.id));
await rig.engine.requestSync();
expect(await rig.engine.areaCall('files.read', ref(b.id))).not.toBeNull();
expect(await rig.engine.areaCall('files.read', ref(a.id))).toBeNull();
expect(server.downloads.map((d) => d.blobId)).toEqual([b.blobId, a.blobId]);
const count = server.downloads.length;
await rig.engine.requestSync();
expect(server.downloads).toHaveLength(count);
});
});
/* eslint-enable @typescript-eslint/no-explicit-any */
@@ -0,0 +1,268 @@
/**
* FakeJmap with contacts, as measured on both Stalwarts (2026-11-03): uid is a
* query filter, a second card with a uid is refused with invalidProperties,
* unknown ids give per-record notFound, AddressBook state doesn't move when
* cards change, `ContactCard/get` with ids null stops at 500.
*/
import { TransportError, type JmapResponse, type MethodCall, type StoreSession } from '../../engine/transport';
import { Collection, FAKE_SESSION, FakeJmap, JMAP_ACCOUNT } from './fake-jmap';
export const CONTACTS = 'urn:ietf:params:jmap:contacts';
export const SHARED_ACCOUNT = 'shared1';
export const MAIL_ONLY_ACCOUNT = 'mailonly';
export const CONTACTS_SESSION: StoreSession = {
...FAKE_SESSION,
primaryAccounts: { ...FAKE_SESSION.primaryAccounts, [CONTACTS]: JMAP_ACCOUNT },
accounts: {
[JMAP_ACCOUNT]: { name: 'a@example.org', isPersonal: true, accountCapabilities: { [CONTACTS]: {} } },
[SHARED_ACCOUNT]: { name: 'team@example.org', isPersonal: false, accountCapabilities: { [CONTACTS]: {} } },
[MAIL_ONLY_ACCOUNT]: { name: 'list@example.org', isPersonal: false, accountCapabilities: { 'urn:ietf:params:jmap:mail': {} } },
},
capabilities: { [CONTACTS]: {} },
};
type Obj = Record<string, unknown> & { id: string };
/** The /changes rules of the base fake, for another collection. */
export function changesFor(coll: Collection, args: Record<string, unknown>): Record<string, unknown> {
const since = String(args.sinceState);
if (!/^s\d+$/.test(since)) throw Object.assign(new Error('bad state'), { jmapError: 'invalidArguments' });
const from = Number(since.slice(1));
if (from < coll.truncatedBelow || from > coll.log.length) {
throw Object.assign(new Error('log truncated'), { jmapError: 'cannotCalculateChanges' });
}
const maxChanges = Math.min(Number(args.maxChanges ?? 5000) || 5000, 5000);
return { accountId: args.accountId, oldState: since, ...coll.changes(since, maxChanges) };
}
function storedMedia(card: Obj): void {
const media = card.media as Record<string, Record<string, unknown>> | undefined;
for (const [key, entry] of Object.entries(media ?? {})) {
if (typeof entry.uri !== 'string' || !entry.uri.startsWith('data:')) continue;
const { uri, ...rest } = entry;
(media as Record<string, unknown>)[key] = { ...rest, blobId: `stored-${uri.length}-${key}` };
}
}
function applyServerPatch(card: Obj, patch: Record<string, unknown>): void {
for (const [path, value] of Object.entries(patch)) {
const steps = path.split('/');
let node = card as Record<string, unknown>;
for (const step of steps.slice(0, -1)) node = node[step] as Record<string, unknown>;
const last = steps[steps.length - 1];
if (value === null) delete node[last];
else node[last] = value;
}
}
class ContactAccount {
readonly books = new Map<string, Obj>();
readonly cards = new Map<string, Obj>();
readonly coll = { AddressBook: new Collection(), ContactCard: new Collection() };
}
export class FakeContacts extends FakeJmap {
private readonly contactAccounts = new Map<string, ContactAccount>();
private nextCard = 1;
/** Set to apply the next ContactCard/set and then lose its response. */
loseNextSet = false;
/** Card ids (or creation ids) whose next set is refused with this error. */
readonly refuse = new Map<string, { type: string; description?: string; properties?: string[] }>();
/** A server that lets two cards have one uid, as nothing in the RFC forbids it. */
allowDuplicateUids = false;
/** Stalwart 1.0 stores a data: URI in `media` as a blob: `uri` goes, `blobId` comes (measured). */
mediaAsBlobs = false;
/** Set to apply the next ContactCard/set, answer it, and then lose the network. */
offlineAfterSet = false;
/** Set to lose the network before the next ContactCard/set reaches the server. */
offlineBeforeSet = false;
/** The most ids ContactCard/get and calls a request take, as a session can say. */
maxObjectsInGet = 500;
/** Answers a ContactCard/set call as a whole, instead of handling it. */
failSet: ((args: Record<string, unknown>) => { jmapError: string } | { http: number } | null) | null = null;
constructor(books: string[] = ['book1']) {
super();
for (const id of books) this.addBook({ id, name: id, isDefault: id === books[0] });
}
account(id: string = JMAP_ACCOUNT): ContactAccount {
let found = this.contactAccounts.get(id);
if (!found) {
found = new ContactAccount();
this.contactAccounts.set(id, found);
}
return found;
}
addBook(book: Obj, accountId: string = JMAP_ACCOUNT): void {
const account = this.account(accountId);
account.books.set(book.id, book);
account.coll.AddressBook.push('created', book.id);
}
addCard(partial: Partial<Obj> = {}, accountId: string = JMAP_ACCOUNT): Obj {
const account = this.account(accountId);
const n = this.nextCard++;
const card: Obj = {
'@type': 'Card',
version: '1.0',
id: `c${n}`,
uid: `urn:uuid:card-${n}`,
addressBookIds: { [[...account.books.keys()][0] ?? 'book1']: true },
kind: 'individual',
name: { full: `Person ${n}` },
...partial,
};
account.cards.set(card.id, card);
account.coll.ContactCard.push('created', card.id);
return card;
}
editCard(id: string, patch: Record<string, unknown>, accountId: string = JMAP_ACCOUNT): void {
const account = this.account(accountId);
applyServerPatch(account.cards.get(id) as Obj, patch);
account.coll.ContactCard.push('updated', id);
}
removeCard(id: string, accountId: string = JMAP_ACCOUNT): void {
const account = this.account(accountId);
account.cards.delete(id);
account.coll.ContactCard.push('destroyed', id);
}
private contactSet(account: ContactAccount, args: Record<string, unknown>): Record<string, unknown> {
const result: Record<string, unknown> = { accountId: args.accountId, oldState: account.coll.ContactCard.state() };
const created: Record<string, unknown> = {};
const notCreated: Record<string, unknown> = {};
const updated: Record<string, unknown> = {};
const notUpdated: Record<string, unknown> = {};
const destroyed: string[] = [];
const notDestroyed: Record<string, unknown> = {};
for (const [cid, raw] of Object.entries((args.create ?? {}) as Record<string, Record<string, unknown>>)) {
const refused = this.refuse.get(cid) ?? this.refuse.get(String(raw.uid));
const clash = [...account.cards.values()].find((card) => card.uid === raw.uid);
if (refused) {
notCreated[cid] = refused;
} else if (clash && !this.allowDuplicateUids) {
notCreated[cid] = { type: 'invalidProperties', description: `Contact with UID ${String(raw.uid)} already exists with id ${clash.id}.`, properties: ['uid'] };
} else {
const id = `c${this.nextCard++}`;
const stored: Obj = { '@type': 'Card', version: '1.0', ...structuredClone(raw), id };
if (this.mediaAsBlobs) storedMedia(stored);
account.cards.set(id, stored);
account.coll.ContactCard.push('created', id);
created[cid] = { id };
}
}
for (const [id, patch] of Object.entries((args.update ?? {}) as Record<string, Record<string, unknown>>)) {
const card = account.cards.get(id);
const refused = this.refuse.get(id);
if (refused) notUpdated[id] = refused;
else if (!card) notUpdated[id] = { type: 'notFound' };
else {
applyServerPatch(card, structuredClone(patch));
if (this.mediaAsBlobs) storedMedia(card);
account.coll.ContactCard.push('updated', id);
updated[id] = null;
}
}
for (const id of (args.destroy ?? []) as string[]) {
const refused = this.refuse.get(id);
if (refused) notDestroyed[id] = refused;
else if (!account.cards.has(id)) notDestroyed[id] = { type: 'notFound' };
else {
account.cards.delete(id);
account.coll.ContactCard.push('destroyed', id);
destroyed.push(id);
}
}
for (const [key, value] of Object.entries({ created, notCreated, updated, notUpdated, notDestroyed })) {
if (Object.keys(value).length > 0) result[key] = value;
}
if (destroyed.length > 0) result.destroyed = destroyed;
result.newState = account.coll.ContactCard.state();
return result;
}
protected override handle(name: string, args: Record<string, unknown>): Record<string, unknown> {
const account = this.account(String(args.accountId));
switch (name) {
case 'AddressBook/get': {
const ids = args.ids as string[] | null | undefined;
const all = [...account.books.values()];
return {
accountId: args.accountId,
state: account.coll.AddressBook.state(),
list: ids ? all.filter((b) => ids.includes(b.id)) : all,
notFound: ids ? ids.filter((id) => !account.books.has(id)) : [],
};
}
case 'AddressBook/changes':
return changesFor(account.coll.AddressBook, args);
case 'ContactCard/changes':
return changesFor(account.coll.ContactCard, args);
case 'ContactCard/query': {
const filter = (args.filter ?? {}) as { uid?: string; inAddressBook?: string };
let list = [...account.cards.values()];
if (filter.uid) list = list.filter((c) => c.uid === filter.uid);
if (filter.inAddressBook) list = list.filter((c) => (c.addressBookIds as Record<string, boolean>)[filter.inAddressBook as string]);
let position = Number(args.position ?? 0);
if (args.anchor !== undefined) {
const at = list.findIndex((c) => c.id === args.anchor);
if (at < 0) throw Object.assign(new Error('anchorNotFound'), { jmapError: 'anchorNotFound' });
position = at + Number(args.anchorOffset ?? 0);
}
const limit = Number(args.limit ?? 5000) || 5000;
return {
accountId: args.accountId,
queryState: account.coll.ContactCard.state(),
canCalculateChanges: true,
position,
ids: list.slice(position, position + Math.min(limit, 5000)).map((c) => c.id),
...(args.calculateTotal ? { total: list.length } : {}),
};
}
case 'ContactCard/get': {
const ids = args.ids as string[] | null | undefined;
if (ids && ids.length > this.maxObjectsInGet) throw Object.assign(new Error('requestTooLarge'), { jmapError: 'requestTooLarge' });
const chosen = ids ?? [...account.cards.keys()].slice(0, this.maxObjectsInGet);
return {
accountId: args.accountId,
state: account.coll.ContactCard.state(),
list: chosen.filter((id) => account.cards.has(id)).map((id) => structuredClone(account.cards.get(id))),
notFound: chosen.filter((id) => !account.cards.has(id)),
};
}
case 'ContactCard/set': {
const failure = this.failSet?.(args);
if (failure && 'http' in failure) throw new TransportError('http', `JMAP request failed: ${failure.http}`, failure.http, 'urn:ietf:params:jmap:error:limit');
if (failure) throw Object.assign(new Error(failure.jmapError), failure);
return this.contactSet(account, args);
}
default:
return super.handle(name, args);
}
}
override async request(calls: MethodCall[], using?: string[]): Promise<JmapResponse> {
// The real server fails the whole request for a capability it doesn't list.
void using;
if (this.offlineBeforeSet && calls.some(([name]) => name === 'ContactCard/set')) {
this.offlineBeforeSet = false;
throw new TransportError('network', 'Failed to fetch');
}
const response = await super.request(calls);
if (this.offlineAfterSet && calls.some(([name]) => name === 'ContactCard/set')) {
this.offlineAfterSet = false;
this.offline = true;
}
if (this.loseNextSet && calls.some(([name]) => name === 'ContactCard/set')) {
this.loseNextSet = false;
throw new TransportError('network', 'Connection dropped after the server took the request');
}
return response;
}
}
+192
View File
@@ -0,0 +1,192 @@
/**
* FakeJmap with files, as measured on both Stalwarts (2026-11-03) and as the
* client's comments record: `filter: {parentId: null}` is an HTTP 400 that
* fails the whole request, a folder has null blobId, type and size. What differs
* between versions is set per server:
* - `style` is how a node says what it is: `nodeType` (0.16.6 and later, 1.0 as
* tested), `blob` (0.16.0-5: nothing but blobId null), `draft` (1.0.0: `@type`
* and `mediaType`).
* - `getAll`: ids null past 500 is silently cut on 0.16 and refused on 1.0.
* - `queryOmitsFolders`: FileNode/query before 0.16.6.
*/
import { TransportError, type StoreSession } from '../../engine/transport';
import { changesFor } from './fake-contacts';
import { Collection, FAKE_SESSION, FakeJmap, JMAP_ACCOUNT } from './fake-jmap';
export const FILES = 'urn:ietf:params:jmap:filenode';
export const SHARED_FILES_ACCOUNT = 'shared1';
export const FILES_SESSION: StoreSession = {
...FAKE_SESSION,
primaryAccounts: { ...FAKE_SESSION.primaryAccounts, [FILES]: JMAP_ACCOUNT },
accounts: {
[JMAP_ACCOUNT]: { name: 'a@example.org', isPersonal: true, accountCapabilities: { [FILES]: {} } },
[SHARED_FILES_ACCOUNT]: { name: 'team@example.org', isPersonal: false, accountCapabilities: { [FILES]: {} } },
},
capabilities: { [FILES]: {} },
};
export interface FakeNode {
id: string;
parentId: string | null;
name: string;
blobId: string | null;
type: string | null;
size: number | null;
modified?: string;
}
export type FolderStyle = 'nodeType' | 'blob' | 'draft';
export interface FakeFilesOptions {
style?: FolderStyle;
getAll?: 'cut' | 'refuse';
queryOmitsFolders?: boolean;
}
class FilesAccount {
readonly nodes = new Map<string, FakeNode>();
readonly coll = new Collection();
}
export class FakeFiles extends FakeJmap {
private readonly fileAccounts = new Map<string, FilesAccount>();
private readonly options: Required<FakeFilesOptions>;
private nextNode = 1;
readonly blobs = new Map<string, Uint8Array>();
/** In order, with the account. */
readonly downloads: Array<{ jmap: string; blobId: string }> = [];
downloading = 0;
mostAtOnce = 0;
constructor(options: FakeFilesOptions = {}) {
super();
this.options = { style: 'nodeType', getAll: 'cut', queryOmitsFolders: false, ...options };
}
account(id: string = JMAP_ACCOUNT): FilesAccount {
let found = this.fileAccounts.get(id);
if (!found) {
found = new FilesAccount();
this.fileAccounts.set(id, found);
}
return found;
}
addFolder(name: string, parentId: string | null = null, id?: string, accountId: string = JMAP_ACCOUNT): FakeNode {
const account = this.account(accountId);
const node: FakeNode = { id: id ?? `n${this.nextNode++}`, parentId, name, blobId: null, type: null, size: null };
account.nodes.set(node.id, node);
account.coll.push('created', node.id);
return node;
}
addFile(name: string, parentId: string | null = null, content: Uint8Array | number = 10, extra: Partial<FakeNode> = {}, accountId: string = JMAP_ACCOUNT): FakeNode {
const account = this.account(accountId);
const id = extra.id ?? `n${this.nextNode++}`;
const blobId = extra.blobId ?? `blob-${id}`;
const data = typeof content === 'number' ? new Uint8Array(content).fill(id.length) : content;
this.blobs.set(blobId, data);
const node: FakeNode = { id, parentId, name, blobId, type: 'text/plain', size: data.length, ...extra };
account.nodes.set(id, node);
account.coll.push('created', id);
return node;
}
/** A new version of the file: new blob, same node. */
replaceContent(id: string, content: Uint8Array, accountId: string = JMAP_ACCOUNT): string {
const account = this.account(accountId);
const node = account.nodes.get(id) as FakeNode;
const blobId = `${node.blobId as string}-v`;
this.blobs.set(blobId, content);
node.blobId = blobId;
node.size = content.length;
account.coll.push('updated', id);
return blobId;
}
renameNode(id: string, name: string, accountId: string = JMAP_ACCOUNT): void {
const account = this.account(accountId);
(account.nodes.get(id) as FakeNode).name = name;
account.coll.push('updated', id);
}
removeNode(id: string, accountId: string = JMAP_ACCOUNT): void {
const account = this.account(accountId);
account.nodes.delete(id);
account.coll.push('destroyed', id);
}
private wire(node: FakeNode, properties: string[] | undefined): Record<string, unknown> {
const isFolder = node.blobId === null;
const full: Record<string, unknown> = { id: node.id, parentId: node.parentId, name: node.name, blobId: node.blobId, size: node.size, created: '2026-10-01T00:00:00Z', modified: node.modified ?? '2026-10-01T00:00:00Z' };
if (this.options.style === 'draft') {
full['@type'] = isFolder ? 'Directory' : 'File';
full.mediaType = node.type;
} else {
full.type = node.type;
if (this.options.style === 'nodeType') full.nodeType = isFolder ? 'directory' : 'file';
}
if (!properties) return full;
const out: Record<string, unknown> = { id: node.id };
for (const p of properties) if (p in full) out[p] = full[p];
return out;
}
protected override handle(name: string, args: Record<string, unknown>): Record<string, unknown> {
if (!name.startsWith('FileNode/')) return super.handle(name, args);
const account = this.account(String(args.accountId));
switch (name) {
case 'FileNode/changes':
return changesFor(account.coll, args);
case 'FileNode/get': {
const ids = args.ids as string[] | null;
if (ids && ids.length > 500) throw Object.assign(new Error('requestTooLarge'), { jmapError: 'requestTooLarge' });
if (!ids && account.nodes.size > 500 && this.options.getAll === 'refuse') {
throw Object.assign(new Error('requestTooLarge'), { jmapError: 'requestTooLarge' });
}
const chosen = ids ?? [...account.nodes.keys()].slice(0, 500);
const properties = args.properties as string[] | undefined;
return {
accountId: args.accountId,
state: account.coll.state(),
list: chosen.filter((id) => account.nodes.has(id)).map((id) => this.wire(account.nodes.get(id) as FakeNode, properties)),
notFound: chosen.filter((id) => !account.nodes.has(id)),
};
}
case 'FileNode/query': {
const filter = (args.filter ?? {}) as { parentId?: string | null };
// Measured on both servers: this fails the whole request.
if (filter.parentId === null) throw new TransportError('http', 'JMAP request failed: 400', 400, 'urn:ietf:params:jmap:error:notRequest');
let list = [...account.nodes.values()];
if (this.options.queryOmitsFolders) list = list.filter((n) => n.blobId !== null);
if (filter.parentId) list = list.filter((n) => n.parentId === filter.parentId);
const position = Number(args.position ?? 0);
const limit = Math.min(Number(args.limit ?? 5000) || 5000, 5000);
return {
accountId: args.accountId,
queryState: account.coll.state(),
canCalculateChanges: true,
position,
ids: list.slice(position, position + limit).map((n) => n.id),
...(args.calculateTotal ? { total: list.length } : {}),
};
}
default:
throw Object.assign(new Error(name), { jmapError: 'unknownMethod' });
}
}
override async download(jmap: string, blobId: string): Promise<Uint8Array> {
if (this.offline) throw new TransportError('network', 'Failed to fetch');
this.downloads.push({ jmap, blobId });
this.downloading += 1;
this.mostAtOnce = Math.max(this.mostAtOnce, this.downloading);
await Promise.resolve();
this.downloading -= 1;
const data = this.blobs.get(blobId);
if (!data) throw new TransportError('http', 'Download failed: 404', 404);
return data;
}
}
+1 -1
View File
@@ -286,7 +286,7 @@ export class FakeJmap implements JmapTransport {
return { methodResponses: done };
}
async download(): Promise<Uint8Array> {
async download(_jmap: string, _blobId: string, _type: string, _name: string): Promise<Uint8Array> {
return new Uint8Array();
}
+2
View File
@@ -80,6 +80,8 @@ export interface StoreContext {
overlay<T extends { id: string }>(jmap: string, type: string, records: T[]): T[];
/** Attachments and files the other areas keep: `scope.maxTotalBytes` is for all of them together. */
keptByOthers(area: string): number;
/** Starts a sync pass, or one more after the running pass. Resolves when they're done. */
requestSync(): Promise<void>;
}
/**
+3 -1
View File
@@ -2,7 +2,9 @@
import type { StoreArea } from './area';
import { calendarArea } from './calendar';
import { contactsArea } from './contacts';
import { filesArea } from './files';
import { mailArea } from './mail';
import { outboxArea } from './outbox';
export const STORE_AREAS: readonly StoreArea[] = [mailArea, outboxArea, calendarArea];
export const STORE_AREAS: readonly StoreArea[] = [mailArea, outboxArea, calendarArea, contactsArea, filesArea];
+27
View File
@@ -0,0 +1,27 @@
import type { StoreArea } from '../area';
import { CONTACT_METHODS } from './methods';
import { listConflicts, pendingCount } from './pending';
import { CONTACT_MIGRATIONS } from './schema';
import { contactsSyncer } from './sync';
export const contactsArea: StoreArea = {
name: 'contacts',
migrations: CONTACT_MIGRATIONS,
methods: CONTACT_METHODS,
syncers: [contactsSyncer],
calls: {
/** Sends what's queued, in the next sync pass, and says what's left. */
'contacts.flush': async (ctx) => {
await ctx.requestSync();
return { pending: pendingCount(ctx.db), conflicts: listConflicts(ctx.db).length };
},
/** How many cards have edits waiting to go out. */
'contacts.pending': (ctx) => pendingCount(ctx.db),
/** Edits the server refused or that met a change there. The server's copy is the one kept. */
'contacts.conflicts': (ctx) => listConflicts(ctx.db),
/** The user has seen this one. */
'contacts.dismissConflict': (ctx, params: { id: number }) => {
ctx.db.run('DELETE FROM contact_conflict WHERE id = ?', [params.id]);
},
},
};
+213
View File
@@ -0,0 +1,213 @@
/** Contact reads and queued edits, answered from the store. */
import { MethodError, type MethodContext, type MethodHandler, type MethodResult } from '../area';
import { listObjects } from '../engine/core-schema';
import type { SqlValue } from '../storage';
import { queueCreate, queueDestroy, queueUpdate, resolveCardId, type SetError } from './pending';
import { CONTACTS_CAPABILITY, foldText } from './records';
import type { JsonObject, Patch } from './patch';
type Filter = Record<string, unknown>;
interface Sql { sql: string; params: SqlValue[] }
function jmapOf(ctx: MethodContext, args: Record<string, unknown>): string {
return (args.accountId as string | undefined) ?? ctx.session.primaryAccounts[CONTACTS_CAPABILITY] ?? ctx.defaultJmap;
}
/**
* The last state from the server. Throws until a first full pass finished, so
* an answer never passes off part of a list as the whole. A later resync
* (phase `list`) goes on answering from the copy it's refreshing.
*/
function syncedState(ctx: MethodContext, jmap: string, type: string): string | null {
const row = ctx.db.get<{ state: string | null; phase: string | null }>('SELECT state, phase FROM sync_state WHERE jmap = ? AND type = ?', [jmap, type]);
if (!row || (!row.state && row.phase !== 'list')) throw new MethodError('serverUnavailable', 'Contacts not kept yet');
return row.state;
}
function pick(object: Record<string, unknown>, properties: unknown): Record<string, unknown> {
if (!Array.isArray(properties)) return object;
const out: Record<string, unknown> = { id: object.id };
for (const property of properties as string[]) if (property in object) out[property] = object[property];
return out;
}
function quote(term: string): string {
return `"${term.replace(/"/g, '""')}"`;
}
function terms(text: string): string[] {
return foldText(text).split(/[^\p{L}\p{N}]+/u).filter(Boolean);
}
/**
* A `text` condition, one part of the client's textFilter (search-utils.ts):
* words, `word*` or a "quoted phrase". Every word has to match somewhere. The
* trigram index finds it anywhere in a name, which takes three characters.
* Shorter ones match the start of a word.
*/
function textCondition(value: string, rank: string[]): Sql {
const phrase = /^"(.+)"$/.exec(value.trim());
const words = terms(phrase ? phrase[1] : value);
if (words.length === 0) return { sql: '0', params: [] };
if (phrase) {
rank.push(quote(words.join(' ')));
return { sql: 'c.rowid IN (SELECT rowid FROM contact_fts_word WHERE contact_fts_word MATCH ?)', params: [quote(words.join(' '))] };
}
const parts: string[] = [];
const params: SqlValue[] = [];
for (const word of words) {
rank.push(`${quote(word)}*`);
if ([...word].length >= 3) {
parts.push('c.rowid IN (SELECT rowid FROM contact_fts_tri WHERE contact_fts_tri MATCH ?)');
params.push(quote(word));
} else {
parts.push('c.rowid IN (SELECT rowid FROM contact_fts_word WHERE contact_fts_word MATCH ?)');
params.push(`${quote(word)}*`);
}
}
return { sql: parts.join(' AND '), params };
}
function filterSql(filter: Filter, rank: string[]): Sql {
if (typeof filter.operator === 'string') {
const parts = (Array.isArray(filter.conditions) ? filter.conditions as Filter[] : []).map((c) => filterSql(c, rank));
const params = parts.flatMap((p) => p.params);
const joined = (joiner: string) => parts.map((p) => `(${p.sql})`).join(joiner);
if (filter.operator === 'AND') return { sql: parts.length ? joined(' AND ') : '1', params };
if (filter.operator === 'OR') return { sql: parts.length ? joined(' OR ') : '0', params };
if (filter.operator === 'NOT') return { sql: parts.length ? `NOT (${joined(' OR ')})` : '1', params };
throw new MethodError('unsupportedFilter', `Unknown operator ${String(filter.operator)}`);
}
const conditions: string[] = [];
const params: SqlValue[] = [];
for (const [key, value] of Object.entries(filter)) {
if (key === 'text') {
const text = textCondition(String(value), rank);
conditions.push(text.sql);
params.push(...text.params);
} else if (key === 'inAddressBook') {
conditions.push('EXISTS (SELECT 1 FROM contact_card_book b WHERE b.card_rowid = c.rowid AND b.book_id = ?)');
params.push(String(value));
} else {
throw new MethodError('unsupportedFilter', `${key} isn't available offline`);
}
}
return { sql: conditions.length ? conditions.join(' AND ') : '1', params };
}
const addressBookGet: MethodHandler = (ctx, args) => {
const jmap = jmapOf(ctx, args);
const state = syncedState(ctx, jmap, 'AddressBook');
const books = listObjects<Record<string, unknown>>(ctx.db, jmap, 'AddressBook');
const ids = Array.isArray(args.ids) ? args.ids as string[] : null;
const chosen = ids ? books.filter((book) => ids.includes(book.id as string)) : books;
return {
accountId: jmap,
...(state ? { state } : {}),
list: chosen.map((book) => pick(book, args.properties)),
notFound: ids ? ids.filter((id) => !books.some((book) => book.id === id)) : [],
};
};
const contactQuery: MethodHandler = (ctx, args) => {
const jmap = jmapOf(ctx, args);
syncedState(ctx, jmap, 'ContactCard');
if (args.anchor !== undefined) throw new MethodError('unsupportedFilter', 'anchor needs the server');
if (Array.isArray(args.sort) && args.sort.length > 0) throw new MethodError('unsupportedSort', 'Sorted lists need the server');
const rank: string[] = [];
const where = args.filter ? filterSql(args.filter as Filter, rank) : { sql: '1', params: [] };
const position = Math.max(0, Number(args.position ?? 0));
// limit 0 means no limit on Stalwart, and the client can't tell a local answer apart.
const limit = typeof args.limit === 'number' && args.limit > 0 ? args.limit : -1;
// Names starting with what was typed come before ones merely containing it.
const first = rank.length > 0 ? 'c.rowid IN (SELECT rowid FROM contact_fts_word WHERE contact_fts_word MATCH ?) DESC, ' : '';
const ids = ctx.db.all<{ id: string }>(
`SELECT c.id FROM contact_card c WHERE c.jmap = ? AND (${where.sql}) ORDER BY ${first}c.sort_key, c.id LIMIT ? OFFSET ?`,
[jmap, ...where.params, ...(rank.length > 0 ? [rank.join(' AND ')] : []), limit, position],
).map((row) => String(row.id));
const result: MethodResult = {
accountId: jmap,
// Local answers aren't a position in the server's log, so this is a token only.
queryState: 'local',
canCalculateChanges: false,
position,
ids,
};
if (args.calculateTotal) {
result.total = Number(ctx.db.get<{ n: number }>(
`SELECT COUNT(*) AS n FROM contact_card c WHERE c.jmap = ? AND (${where.sql})`,
[jmap, ...where.params],
)?.n ?? 0);
}
return result;
};
const contactGet: MethodHandler = (ctx, args) => {
const jmap = jmapOf(ctx, args);
const state = syncedState(ctx, jmap, 'ContactCard');
const list: Array<Record<string, unknown>> = [];
const notFound: string[] = [];
const rows = Array.isArray(args.ids)
? (args.ids as string[]).map((id) => ({ id, row: ctx.db.get<{ json: string }>('SELECT json FROM contact_card WHERE jmap = ? AND id = ?', [jmap, resolveCardId(ctx.db, jmap, id)]) }))
: ctx.db.all<{ json: string; id: string }>('SELECT json, id FROM contact_card WHERE jmap = ? ORDER BY sort_key, id', [jmap]).map((row) => ({ id: row.id, row }));
for (const { id, row } of rows) {
if (row) list.push(pick(JSON.parse(row.json) as Record<string, unknown>, args.properties));
else notFound.push(id);
}
return { accountId: jmap, ...(state ? { state } : {}), list, notFound };
};
/**
* Queues the edits. The answer leaves out oldState and newState: nothing
* reached the server, so there's no real state to name, and the client reads
* neither from ContactCard/set.
*/
const contactSet: MethodHandler = (ctx, args) => {
const jmap = jmapOf(ctx, args);
syncedState(ctx, jmap, 'ContactCard');
const created: Record<string, { id: string; uid: string }> = {};
const notCreated: Record<string, SetError> = {};
const updated: Record<string, null> = {};
const notUpdated: Record<string, SetError> = {};
const destroyed: string[] = [];
const notDestroyed: Record<string, SetError> = {};
ctx.db.transaction(() => {
for (const [cid, card] of Object.entries((args.create ?? {}) as Record<string, JsonObject>)) {
const outcome = queueCreate(ctx, jmap, card);
if ('id' in outcome) created[cid] = outcome;
else notCreated[cid] = outcome;
}
for (const [id, patch] of Object.entries((args.update ?? {}) as Record<string, Patch>)) {
const refused = queueUpdate(ctx, jmap, id, patch);
if (refused) notUpdated[id] = refused;
else updated[id] = null;
}
for (const id of (args.destroy ?? []) as string[]) {
const refused = queueDestroy(ctx, jmap, id);
if (refused) notDestroyed[id] = refused;
else destroyed.push(id);
}
});
if (Object.keys(created).length + Object.keys(updated).length + destroyed.length > 0) {
ctx.emit({ type: 'changed', account: ctx.account, collections: ['ContactCard'] });
}
const some = (name: string, value: object) => (Object.keys(value).length > 0 ? { [name]: value } : {});
return {
accountId: jmap,
...some('created', created),
...some('notCreated', notCreated),
...some('updated', updated),
...some('notUpdated', notUpdated),
...some('destroyed', destroyed),
...some('notDestroyed', notDestroyed),
};
};
export const CONTACT_METHODS: Readonly<Record<string, MethodHandler>> = {
'AddressBook/get': addressBookGet,
'ContactCard/query': contactQuery,
'ContactCard/get': contactGet,
'ContactCard/set': contactSet,
};
+109
View File
@@ -0,0 +1,109 @@
/**
* RFC 8620 §5.3 patches: keys are JSON pointers without the leading slash, a
* null value removes the property. Written so the common outbox can use the
* same rules for its per-record merge.
*/
export type JsonObject = Record<string, unknown>;
export type Patch = Record<string, unknown>;
export class InvalidPatchError extends Error {
constructor(path: string) {
super(`Can't apply a patch at ${path}`);
this.name = 'InvalidPatchError';
}
}
function steps(path: string): string[] {
return path.split('/').map((step) => step.replace(/~1/g, '/').replace(/~0/g, '~'));
}
function isObject(value: unknown): value is JsonObject {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/** A copy of the object with the patch applied. Throws when a path leads nowhere. */
export function applyPatch<T extends JsonObject>(object: T, patch: Patch): T {
const root: JsonObject = { ...object };
for (const [path, value] of Object.entries(patch)) {
const parts = steps(path);
let node = root;
for (const step of parts.slice(0, -1)) {
const next = node[step];
if (!isObject(next)) throw new InvalidPatchError(path);
const copy = { ...next };
node[step] = copy;
node = copy;
}
const last = parts[parts.length - 1];
if (value === null) delete node[last];
else node[last] = value;
}
return root as T;
}
/** One patch that does what the older one and then the newer one would do. */
export function mergePatches(older: Patch, newer: Patch): Patch {
const merged: Patch = { ...older };
for (const [path, value] of Object.entries(newer)) {
// A newer write to a whole property replaces older writes below it.
for (const key of Object.keys(merged)) {
if (key.startsWith(`${path}/`)) delete merged[key];
}
// A newer write below an older whole-property write goes into that value.
const parent = Object.keys(merged).find((key) => path.startsWith(`${key}/`));
if (parent && isObject(merged[parent])) {
const holder = applyPatch({ value: merged[parent] }, { [`value/${path.slice(parent.length + 1)}`]: value });
merged[parent] = holder.value;
} else {
merged[path] = value;
}
}
return merged;
}
/** The top-level properties a patch writes to. */
export function touchedProperties(patch: Patch): string[] {
return [...new Set(Object.keys(patch).map((path) => steps(path)[0]))];
}
function isNothing(value: unknown): boolean {
return value == null || (isObject(value) && Object.keys(value).length === 0);
}
/** Deep equality that ignores key order. Absent, null and an empty map count as the same. */
export function sameJson(a: unknown, b: unknown): boolean {
if (a === b || (isNothing(a) && isNothing(b))) return true;
if (Array.isArray(a) && Array.isArray(b)) {
return a.length === b.length && a.every((item, i) => sameJson(item, b[i]));
}
if (isObject(a) && isObject(b)) {
return [...new Set([...Object.keys(a), ...Object.keys(b)])].every((key) => sameJson(a[key], b[key]));
}
return false;
}
/** Stalwart 1.0 keeps a data: URI in `media` as a blob: `uri` goes and `blobId` comes (measured). */
function inlineKeptAsBlob(wanted: JsonObject, actual: JsonObject): boolean {
return typeof wanted.uri === 'string' && wanted.uri.startsWith('data:') && actual.uri == null && typeof actual.blobId === 'string';
}
/**
* Whether `actual` is `wanted` as far as a server can tell. A key only the
* server has is its own, unless `base` shows it was there before the edit:
* then the edit meant to remove it, and it hasn't happened yet.
*/
export function coversJson(wanted: unknown, actual: unknown, base?: unknown): boolean {
if (!isObject(wanted) || !isObject(actual)) return sameJson(wanted, actual);
const before = isObject(base) ? base : {};
const inline = inlineKeptAsBlob(wanted, actual);
for (const key of Object.keys(wanted)) {
if (inline && key === 'uri') continue;
if (!coversJson(wanted[key], actual[key], before[key])) return false;
}
for (const key of Object.keys(actual)) {
if (key in wanted || (inline && key === 'blobId')) continue;
if (before[key] != null) return false;
}
return true;
}
+573
View File
@@ -0,0 +1,573 @@
/**
* Contact edits made while the server can't be reached, and sending them.
*
* One row per card, whatever number of edits it had. The visible card in
* contact_card already has the edits on top, and sync keeps it so. Sending
* runs at the start of each sync pass, so it never overlaps with itself.
*
* What a row becomes on its way out:
* - Update: the server's card is read first. Nothing to do if it already has
* the values (a lost response). A conflict if the server changed one of the
* properties the edit touches. Otherwise the patch goes out.
* - Destroy: done if the card is gone, a conflict if it changed since.
* - Create: a request that may have reached the server looks for the card's
* uid first, so a lost response doesn't make a second card. Stalwart also
* refuses a second card with the same uid. Either way the card found is only
* ours if it holds what was sent, or an offline card with someone else's uid
* would silently turn into theirs.
*
* The local copy after sending is always the server's own read-back, never what
* was sent: Stalwart 1.0 stores a data: URI as a blob. A base built from what
* was sent would read that as someone else's change. If the network goes before
* the read-back, that counts as a lost response: the row stays, and the next
* pass finds its edit already there. `sent` is what the last request held, so
* the next pass can tell its own edit from someone else's.
*/
import type { StoreContext, SyncContext } from '../area';
import { requestLimits, type RequestLimits } from '../engine/limits';
import { JmapMethodError, responseFor, TransportError, type MethodCall } from '../engine/transport';
import type { StorageAdapter } from '../storage';
import { applyPatch, coversJson, InvalidPatchError, mergePatches, sameJson, touchedProperties, type JsonObject, type Patch } from './patch';
import { cardDisplayName, CONTACTS_USING, readCard, removeCard, writeCard, type Card } from './records';
export const STAND_IN_PREFIX = 'bulwark-local-card-';
/** What a held upload's stand-in blob id starts with (lib/offline/queue.ts). */
const LOCAL_BLOB_MARK = '"blobId":"bulwark-local-';
const SET_BATCH = 100;
/** Of the session's request limit. A bigger card would never go out. */
function maxRecordBytes(ctx: StoreContext): number {
return Math.floor(requestLimits(ctx.session).maxSizeRequest * 0.8);
}
/** Set errors that say nothing about the edit itself: it goes out again next time. */
const TRANSIENT_ERRORS = new Set(['serverFail', 'serverUnavailable', 'rateLimit']);
/** Server-kept timestamps: their changing doesn't mean someone edited the card. */
const SERVER_STAMPS = ['updated', 'created'];
export interface PendingRow {
seq: number;
jmap: string;
id: string;
op: 'create' | 'update' | 'destroy';
uid: string | null;
payload: string | null;
base: string | null;
attempted: number;
sent: string | null;
queued_at: number;
}
export interface SetError { type: string; description?: string; properties?: string[] }
export interface ContactConflict {
id: number;
jmap: string;
recordId: string;
kind: 'refused' | 'changed' | 'gone';
summary: string;
local: { op: string; payload: unknown };
server: unknown;
error: string | null;
createdAt: number;
}
export function pendingFor(db: StorageAdapter, jmap: string, id: string): PendingRow | undefined {
return db.get<PendingRow>('SELECT * FROM contact_pending WHERE jmap = ? AND id = ?', [jmap, id]);
}
function dropPending(db: StorageAdapter, jmap: string, id: string): void {
db.run('DELETE FROM contact_pending WHERE jmap = ? AND id = ?', [jmap, id]);
}
function savePending(db: StorageAdapter, ctx: { now(): number }, row: { jmap: string; id: string; op: PendingRow['op']; uid?: string | null; payload: string | null; base: string | null }): void {
db.run(
`INSERT INTO contact_pending (jmap, id, op, uid, payload, base, queued_at) VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (jmap, id) DO UPDATE SET op = excluded.op, uid = excluded.uid, payload = excluded.payload, base = excluded.base`,
[row.jmap, row.id, row.op, row.uid ?? null, row.payload, row.base, ctx.now()],
);
}
/** A stand-in id that already went through becomes the real one. */
export function resolveCardId(db: StorageAdapter, jmap: string, id: string): string {
return db.get<{ real_id: string }>('SELECT real_id FROM contact_id_map WHERE jmap = ? AND local_id = ?', [jmap, id])?.real_id ?? id;
}
/** A held upload can't be named in a card that goes out later. The client falls back to a data: URI. */
function holdsLocalBlob(value: unknown): boolean {
return JSON.stringify(value).includes(LOCAL_BLOB_MARK);
}
const TOO_LARGE_ERROR: SetError = { type: 'tooLarge', description: 'This contact is too large to save' };
const LOCAL_BLOB_ERROR: SetError = {
type: 'invalidProperties',
description: 'A photo that is not on the server yet cannot be used here',
properties: ['media'],
};
export function queueCreate(ctx: StoreContext, jmap: string, card: JsonObject): { id: string; uid: string } | SetError {
if (holdsLocalBlob(card)) return LOCAL_BLOB_ERROR;
if (Object.keys((card.addressBookIds ?? {}) as object).length === 0) {
// The client picks the default book. None known means there's nowhere to put it.
return { type: 'invalidProperties', description: 'A contact needs an address book', properties: ['addressBookIds'] };
}
const id = `${STAND_IN_PREFIX}${crypto.randomUUID()}`;
const uid = typeof card.uid === 'string' && card.uid ? card.uid : `urn:uuid:${crypto.randomUUID()}`;
const payload = JSON.stringify({ ...card, uid });
if (payload.length > maxRecordBytes(ctx)) return TOO_LARGE_ERROR;
savePending(ctx.db, ctx, { jmap, id, op: 'create', uid, payload, base: null });
writeCard(ctx.db, jmap, { '@type': 'Card', version: '1.0', ...card, uid, id });
return { id, uid };
}
export function queueUpdate(ctx: StoreContext, jmap: string, rawId: string, patch: Patch): SetError | null {
const id = resolveCardId(ctx.db, jmap, rawId);
const card = readCard(ctx.db, jmap, id);
if (!card) return { type: 'notFound' };
if ('id' in patch) return { type: 'invalidProperties', description: 'The id property is immutable.', properties: ['id'] };
if (holdsLocalBlob(patch)) return LOCAL_BLOB_ERROR;
const pending = pendingFor(ctx.db, jmap, id);
let visible: Card;
try {
visible = applyPatch(card, patch);
} catch (error) {
if (error instanceof InvalidPatchError) return { type: 'invalidPatch', description: error.message };
throw error;
}
if (pending?.op === 'create') {
const payload = JSON.stringify(applyPatch(JSON.parse(pending.payload as string) as JsonObject, patch));
if (payload.length > maxRecordBytes(ctx)) return TOO_LARGE_ERROR;
savePending(ctx.db, ctx, { jmap, id, op: 'create', uid: pending.uid, payload, base: null });
} else {
const merged = JSON.stringify(pending ? mergePatches(JSON.parse(pending.payload as string) as Patch, patch) : patch);
if (merged.length > maxRecordBytes(ctx)) return TOO_LARGE_ERROR;
savePending(ctx.db, ctx, { jmap, id, op: 'update', payload: merged, base: pending ? pending.base : JSON.stringify(card) });
}
writeCard(ctx.db, jmap, visible);
return null;
}
export function queueDestroy(ctx: StoreContext, jmap: string, rawId: string): SetError | null {
const id = resolveCardId(ctx.db, jmap, rawId);
const card = readCard(ctx.db, jmap, id);
if (!card) return { type: 'notFound' };
const pending = pendingFor(ctx.db, jmap, id);
if (pending?.op === 'create' && !pending.attempted) {
// Never reached the server, so it never has to.
dropPending(ctx.db, jmap, id);
} else if (pending?.op === 'create') {
// It may have reached the server. Sending decides, then destroys it.
savePending(ctx.db, ctx, { jmap, id, op: 'destroy', uid: pending.uid, payload: pending.payload, base: null });
} else {
savePending(ctx.db, ctx, { jmap, id, op: 'destroy', payload: null, base: pending ? pending.base : JSON.stringify(card) });
}
removeCard(ctx.db, jmap, id);
return null;
}
// ---- What sync does around queued edits ----
/** Stores a card the server sent, with whatever is queued for it on top. */
export function storeServerCard(db: StorageAdapter, jmap: string, card: Card, gen?: number): void {
const pending = pendingFor(db, jmap, card.id);
if (pending?.op === 'destroy') return;
let visible = card;
if (pending?.op === 'update') {
try {
visible = applyPatch(card, JSON.parse(pending.payload as string) as Patch);
} catch (error) {
// The patch no longer fits the card. Sending turns this into a conflict.
if (!(error instanceof InvalidPatchError)) throw error;
}
}
writeCard(db, jmap, visible, gen);
}
/** The server no longer has this card. */
export function serverDestroyed(ctx: StoreContext, jmap: string, id: string): void {
const pending = pendingFor(ctx.db, jmap, id);
if (pending?.op === 'update') recordConflict(ctx, pending, 'gone', null, null);
if (pending && pending.op !== 'create') dropPending(ctx.db, jmap, id);
removeCard(ctx.db, jmap, id);
}
function recordConflict(ctx: StoreContext, row: PendingRow, kind: ContactConflict['kind'], server: Card | null, error: SetError | null): void {
const local = row.payload ? JSON.parse(row.payload) as JsonObject : null;
const known = server ?? readCard(ctx.db, row.jmap, row.id) ?? (row.base ? JSON.parse(row.base) as JsonObject : local);
ctx.db.run(
'INSERT INTO contact_conflict (jmap, record_id, kind, summary, local, server, error, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[
row.jmap,
row.id,
kind,
cardDisplayName(known ?? {}),
JSON.stringify({ op: row.op, payload: local }),
server ? JSON.stringify(server) : null,
error ? [error.type, error.description].filter(Boolean).join(': ') : null,
ctx.now(),
],
);
}
export function listConflicts(db: StorageAdapter): ContactConflict[] {
return db.all<{ id: number; jmap: string; record_id: string; kind: ContactConflict['kind']; summary: string; local: string; server: string | null; error: string | null; created_at: number }>(
'SELECT * FROM contact_conflict ORDER BY id',
).map((row) => ({
id: Number(row.id),
jmap: row.jmap,
recordId: row.record_id,
kind: row.kind,
summary: row.summary,
local: JSON.parse(row.local) as ContactConflict['local'],
server: row.server ? JSON.parse(row.server) : null,
error: row.error,
createdAt: Number(row.created_at),
}));
}
export function pendingCount(db: StorageAdapter): number {
return Number(db.get<{ n: number }>('SELECT COUNT(*) AS n FROM contact_pending')?.n ?? 0);
}
// ---- Sending ----
function withoutStamps(card: JsonObject): JsonObject {
const copy = { ...card };
for (const key of SERVER_STAMPS) delete copy[key];
return copy;
}
async function readServerCards(ctx: SyncContext, jmap: string, ids: string[], limits: RequestLimits): Promise<Map<string, Card>> {
const cards = new Map<string, Card>();
for (let i = 0; i < ids.length; i += limits.maxObjectsInGet) {
const response = await ctx.transport.request([['ContactCard/get', { accountId: jmap, ids: ids.slice(i, i + limits.maxObjectsInGet) }, 'g']], CONTACTS_USING);
for (const card of responseFor(response, 'g')[1].list as Card[]) cards.set(card.id, card);
}
return cards;
}
/** The server's cards with these uids. */
async function cardsByUid(ctx: SyncContext, jmap: string, uids: string[], limits: RequestLimits): Promise<Map<string, Card>> {
const ids = new Map<string, string>();
for (let i = 0; i < uids.length; i += limits.maxCallsInRequest) {
const group = uids.slice(i, i + limits.maxCallsInRequest);
const calls: MethodCall[] = group.map((uid, n) => ['ContactCard/query', { accountId: jmap, filter: { uid }, limit: 1 }, `q${n}`]);
const response = await ctx.transport.request(calls, CONTACTS_USING);
group.forEach((uid, n) => {
const found = responseFor(response, `q${n}`)[1].ids as string[];
if (found.length > 0) ids.set(uid, found[0]);
});
}
const cards = await readServerCards(ctx, jmap, [...ids.values()], limits);
const byUid = new Map<string, Card>();
for (const [uid, id] of ids) {
const card = cards.get(id);
if (card) byUid.set(uid, card);
}
return byUid;
}
/** Whether a card the server has under our uid is the one we sent. */
function holdsSent(sentPayload: string, card: Card): boolean {
const sent = JSON.parse(sentPayload) as JsonObject;
return Object.keys(sent).every((key) => coversJson(sent[key], card[key]));
}
const UID_TAKEN: SetError = { type: 'invalidProperties', description: 'Another contact already has this uid', properties: ['uid'] };
/** The create went through, or had already. The stand-in row becomes the server's card. */
function settleCreated(ctx: SyncContext, sent: PendingRow, server: Card): void {
const { db } = ctx;
const { jmap, id: standIn } = sent;
const now = pendingFor(db, jmap, standIn);
db.run('INSERT OR REPLACE INTO contact_id_map (jmap, local_id, real_id) VALUES (?, ?, ?)', [jmap, standIn, server.id]);
dropPending(db, jmap, standIn);
removeCard(db, jmap, standIn);
if (now?.op === 'destroy' || !now) {
// Deleted while the create was on its way: delete the real one next.
savePending(db, ctx, { jmap, id: server.id, op: 'destroy', payload: null, base: JSON.stringify(server) });
return;
}
if (now.payload !== sent.payload) {
// Edited while the create was on its way: what differs goes out as an edit.
const before = JSON.parse(sent.payload as string) as JsonObject;
const after = JSON.parse(now.payload as string) as JsonObject;
const patch: Patch = {};
for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) {
if (!sameJson(before[key], after[key])) patch[key.replace(/~/g, '~0').replace(/\//g, '~1')] = after[key] ?? null;
}
savePending(db, ctx, { jmap, id: server.id, op: 'update', payload: JSON.stringify(patch), base: JSON.stringify(server) });
writeCard(db, jmap, applyPatch(server, patch));
return;
}
writeCard(db, jmap, server);
}
interface Decision { create: Map<string, PendingRow>; update: Map<string, PendingRow>; destroy: Map<string, PendingRow> }
/**
* Whether someone else moved one of these properties since the edit was queued.
* The server holding what our last request sent is our own doing, not a change:
* its response may have been lost.
*/
function movedOnServer(row: PendingRow, server: Card, keys: readonly string[]): boolean {
if (!row.base) return false;
const base = JSON.parse(row.base) as JsonObject;
let own: JsonObject | null = null;
if (row.sent) {
try {
own = applyPatch(base, JSON.parse(row.sent) as Patch);
} catch (error) {
if (!(error instanceof InvalidPatchError)) throw error;
}
}
return keys.some((key) => !sameJson(base[key], server[key]) && !(own && coversJson(own[key], server[key], base[key])));
}
/** What to do with one row now that the server's copy is known. Conflicts are settled here. */
function decide(ctx: SyncContext, row: PendingRow, server: Card | undefined, serverByUid: Map<string, Card>, out: Decision): void {
const { db } = ctx;
const { jmap, id } = row;
const uidHit = row.uid ? serverByUid.get(row.uid) : undefined;
if (row.op === 'create') {
if (!uidHit) {
out.create.set(id, row);
} else if (holdsSent(row.sent ?? (row.payload as string), uidHit)) {
settleCreated(ctx, { ...row, payload: row.sent ?? row.payload }, uidHit);
} else {
recordConflict(ctx, row, 'refused', null, UID_TAKEN);
dropPending(db, jmap, id);
removeCard(db, jmap, id);
}
return;
}
if (row.op === 'destroy' && id.startsWith(STAND_IN_PREFIX)) {
// A create that may have gone out, deleted since. Only a card holding what was sent is ours to delete.
dropPending(db, jmap, id);
const sent = row.sent ?? row.payload;
if (uidHit && sent && holdsSent(sent, uidHit)) {
savePending(db, ctx, { jmap, id: uidHit.id, op: 'destroy', payload: null, base: JSON.stringify(uidHit) });
}
return;
}
if (row.op === 'destroy') {
if (!server) {
dropPending(db, jmap, id);
return;
}
const stable = withoutStamps(server);
const base = row.base ? withoutStamps(JSON.parse(row.base) as JsonObject) : {};
if (movedOnServer(row, server, [...new Set([...Object.keys(stable), ...Object.keys(base)])])) {
recordConflict(ctx, row, 'changed', server, null);
dropPending(db, jmap, id);
writeCard(db, jmap, server);
} else {
out.destroy.set(id, row);
}
return;
}
if (!server) {
recordConflict(ctx, row, 'gone', null, null);
dropPending(db, jmap, id);
removeCard(db, jmap, id);
return;
}
const patch = JSON.parse(row.payload as string) as Patch;
const base = row.base ? JSON.parse(row.base) as JsonObject : {};
let applied: Card | null = null;
try {
applied = applyPatch(server, patch);
} catch (error) {
if (!(error instanceof InvalidPatchError)) throw error;
}
const touched = touchedProperties(patch);
if (applied && touched.every((key) => coversJson((applied as Card)[key], server[key], base[key]))) {
dropPending(db, jmap, id);
writeCard(db, jmap, server);
return;
}
if (!applied || movedOnServer(row, server, touched)) {
recordConflict(ctx, row, 'changed', server, null);
dropPending(db, jmap, id);
writeCard(db, jmap, server);
return;
}
out.update.set(id, row);
}
function settleUpdated(ctx: SyncContext, sent: PendingRow, server: Card | undefined): void {
const { db } = ctx;
const now = pendingFor(db, sent.jmap, sent.id);
if (!now) return;
if (now.op === 'update' && now.payload === sent.payload) {
dropPending(db, sent.jmap, sent.id);
if (server) writeCard(db, sent.jmap, server);
return;
}
// Edited again while this was on its way: that edit now builds on what the server holds.
if (!server) return;
db.run('UPDATE contact_pending SET base = ?, sent = NULL, attempted = 0 WHERE seq = ?', [JSON.stringify(server), now.seq]);
if (now.op === 'update') storeServerCard(db, sent.jmap, server);
}
function settleRefused(ctx: SyncContext, sent: PendingRow, error: SetError, server: Card | undefined): void {
const { db } = ctx;
if (TRANSIENT_ERRORS.has(error.type)) return;
const now = pendingFor(db, sent.jmap, sent.id);
if (now && (now.op !== sent.op || now.payload !== sent.payload)) {
// Edited again while this was on its way, so the refused edit is still in
// the row. It goes out once more, and a second refusal is the conflict.
db.run('UPDATE contact_pending SET sent = NULL, attempted = 0 WHERE seq = ?', [now.seq]);
return;
}
if (sent.op === 'destroy' && error.type === 'notFound') {
dropPending(db, sent.jmap, sent.id);
return;
}
const kind = error.type === 'notFound' ? 'gone' : 'refused';
recordConflict(ctx, sent, kind, server ?? null, error);
dropPending(db, sent.jmap, sent.id);
const restore = server ?? (sent.base ? JSON.parse(sent.base) as Card : null);
if (restore && kind === 'refused') writeCard(db, sent.jmap, restore);
else removeCard(db, sent.jmap, sent.id);
}
/** Takes rows up to about the size of one request. */
function nextBatch(db: StorageAdapter, jmap: string, afterSeq: number, limits: RequestLimits): PendingRow[] {
const rows = db.all<PendingRow>('SELECT * FROM contact_pending WHERE jmap = ? AND seq > ? ORDER BY seq LIMIT ?', [jmap, afterSeq, Math.min(SET_BATCH, limits.maxObjectsInSet)]);
const room = limits.maxSizeRequest / 2;
let bytes = 0;
const batch: PendingRow[] = [];
for (const row of rows) {
bytes += row.payload?.length ?? 0;
if (batch.length > 0 && bytes > room) break;
batch.push(row);
}
return batch;
}
/** One exchange with the server for these rows: read, decide, send, read back, settle. */
async function exchange(ctx: SyncContext, jmap: string, rows: readonly PendingRow[], limits: RequestLimits): Promise<void> {
const { db } = ctx;
const current = await readServerCards(ctx, jmap, rows.filter((row) => row.op !== 'create' && !row.id.startsWith(STAND_IN_PREFIX)).map((row) => row.id), limits);
const serverByUid = await cardsByUid(ctx, jmap, rows.filter((row) => row.uid && row.attempted).map((row) => row.uid as string), limits);
const out: Decision = { create: new Map(), update: new Map(), destroy: new Map() };
db.transaction(() => {
for (const { seq, id } of rows) {
// The page may have edited it while the reads were on their way.
const row = pendingFor(db, jmap, id);
if (row && row.seq === seq) decide(ctx, row, current.get(id), serverByUid, out);
}
for (const row of [...out.create.values(), ...out.update.values()]) {
db.run('UPDATE contact_pending SET attempted = 1, sent = payload WHERE seq = ?', [row.seq]);
}
});
if (out.create.size + out.update.size + out.destroy.size === 0) return;
const creationIds = new Map([...out.create.values()].map((row) => [`c${row.seq}`, row]));
const response = await ctx.transport.request([['ContactCard/set', {
accountId: jmap,
...(creationIds.size > 0 ? { create: Object.fromEntries([...creationIds].map(([cid, row]) => [cid, JSON.parse(row.payload as string)])) } : {}),
...(out.update.size > 0 ? { update: Object.fromEntries([...out.update.values()].map((row) => [row.id, JSON.parse(row.payload as string)])) } : {}),
...(out.destroy.size > 0 ? { destroy: [...out.destroy.keys()] } : {}),
}, 's']], CONTACTS_USING);
const [, result] = responseFor(response, 's');
// What the server now holds. A network failure here is a lost response: the rows stay.
const createdIds = new Map<string, string>();
for (const cid of creationIds.keys()) {
const created = result.created?.[cid] as { id?: unknown } | undefined;
if (created?.id) createdIds.set(cid, String(created.id));
}
const updatedIds = [...out.update.keys()].filter((id) => result.updated && id in result.updated);
const readBack = await readServerCards(ctx, jmap, [...createdIds.values(), ...updatedIds], limits);
// A card already has this uid: maybe this one, sent before.
const uidRefused = [...creationIds].filter(([cid]) => (result.notCreated?.[cid] as SetError | undefined)?.properties?.includes('uid'));
const taken = await cardsByUid(ctx, jmap, uidRefused.map(([, row]) => row.uid as string), limits);
db.transaction(() => {
for (const [cid, row] of creationIds) {
const realId = createdIds.get(cid);
const refused = result.notCreated?.[cid] as SetError | undefined;
if (realId) {
const card = readBack.get(realId);
if (card) {
settleCreated(ctx, row, card);
} else {
// Created and deleted again already. The delta will say the same.
dropPending(db, jmap, row.id);
removeCard(db, jmap, row.id);
}
} else if (refused?.properties?.includes('uid')) {
const card = taken.get(row.uid as string);
if (card && holdsSent(row.payload as string, card)) settleCreated(ctx, row, card);
else settleRefused(ctx, row, refused, undefined);
} else if (refused) {
settleRefused(ctx, row, refused, undefined);
}
}
for (const [id, row] of out.update) {
const refused = result.notUpdated?.[id] as SetError | undefined;
if (refused) settleRefused(ctx, row, refused, current.get(id));
else if (updatedIds.includes(id)) settleUpdated(ctx, row, readBack.get(id));
}
for (const [id, row] of out.destroy) {
const refused = result.notDestroyed?.[id] as SetError | undefined;
if (refused) settleRefused(ctx, row, refused, current.get(id));
else if ((result.destroyed as string[] | undefined)?.includes(id)) dropPending(db, jmap, id);
}
});
}
/** The server said no to the request itself, for a reason that won't go away by itself. */
function requestRefusal(error: unknown): SetError | null {
if (error instanceof JmapMethodError) return TRANSIENT_ERRORS.has(error.type) ? null : { type: error.type, description: error.message };
if (error instanceof TransportError && error.kind === 'http' && error.status !== undefined && error.status >= 400 && error.status < 500) {
return { type: 'requestRefused', description: error.message };
}
return null;
}
/** One card may be what makes a request fail: the others go one by one, and that one becomes a conflict. */
async function sendRows(ctx: SyncContext, jmap: string, rows: readonly PendingRow[], limits: RequestLimits): Promise<void> {
try {
await exchange(ctx, jmap, rows, limits);
} catch (error) {
const refusal = requestRefusal(error);
if (!refusal) throw error;
if (rows.length > 1) {
for (const row of rows) {
if (ctx.stopped()) return;
await sendRows(ctx, jmap, [row], limits);
}
return;
}
ctx.db.transaction(() => {
const row = pendingFor(ctx.db, jmap, rows[0].id);
if (row) settleRefused(ctx, row, refusal, undefined);
});
}
}
/** Sends what's queued for one JMAP account. Network failures reach the engine and the rows stay. */
export async function flushPending(ctx: SyncContext, jmap: string): Promise<void> {
const limits = requestLimits(ctx.session);
let afterSeq = 0;
let settled = false;
try {
for (;;) {
if (ctx.stopped()) break;
const batch = nextBatch(ctx.db, jmap, afterSeq, limits);
if (batch.length === 0) break;
afterSeq = batch[batch.length - 1].seq;
settled = true;
await sendRows(ctx, jmap, batch, limits);
}
} finally {
if (settled) ctx.emit({ type: 'changed', account: ctx.account, collections: ['ContactCard'] });
}
}
+116
View File
@@ -0,0 +1,116 @@
/** How a ContactCard becomes rows, and how the search index reads it. */
import type { SqlValue, StorageAdapter } from '../storage';
export const CONTACTS_CAPABILITY = 'urn:ietf:params:jmap:contacts';
export const CONTACTS_USING = ['urn:ietf:params:jmap:core', CONTACTS_CAPABILITY];
/** As the client asks for them. Without them Stalwart may leave out shareWith and myRights. */
export const ADDRESS_BOOK_PROPERTIES = ['id', 'name', 'description', 'sortOrder', 'isDefault', 'isSubscribed', 'shareWith', 'myRights'];
export type Card = Record<string, unknown> & { id: string };
function values(map: unknown): Array<Record<string, unknown>> {
if (typeof map !== 'object' || map === null) return [];
return Object.values(map).filter((v): v is Record<string, unknown> => typeof v === 'object' && v !== null);
}
function strings(items: unknown[]): string[] {
return items.filter((item): item is string => typeof item === 'string' && item.length > 0);
}
/** Lower case, no accents: what both the index and the search terms go through. */
export function foldText(text: string): string {
return text
.normalize('NFKD')
.replace(/\p{M}+/gu, '')
.toLowerCase()
.replace(/ß/g, 'ss')
.replace(/æ/g, 'ae')
.replace(/œ/g, 'oe')
.replace(/[øđł]/g, (c) => ({ ø: 'o', đ: 'd', ł: 'l' })[c] as string);
}
/**
* The text to index. A German name is indexed both ways, since people type
* "Mueller" for "Müller" as often as "Muller".
*/
function indexText(text: string): string {
const folded = foldText(text);
const german = foldText(
text.normalize('NFC').replace(/[äÄ]/g, 'ae').replace(/[öÖ]/g, 'oe').replace(/[üÜ]/g, 'ue'),
);
return german === folded ? folded : `${folded} ${german}`;
}
export function cardDisplayName(card: Record<string, unknown>): string {
const name = card.name as { full?: unknown; components?: unknown } | undefined;
if (typeof name?.full === 'string' && name.full) return name.full;
const parts = Array.isArray(name?.components) ? strings((name.components as Array<{ value?: unknown }>).map((c) => c?.value)) : [];
if (parts.length > 0) return parts.join(' ');
return strings(values(card.emails).map((e) => e.address))[0] ?? '';
}
function cardIndex(card: Record<string, unknown>): { name: string; email: string; org: string } {
const name = card.name as { full?: unknown; components?: unknown } | undefined;
const components = Array.isArray(name?.components) ? (name.components as Array<{ value?: unknown }>).map((c) => c?.value) : [];
const nicknames = values(card.nicknames).map((n) => n.name);
const emails = values(card.emails).map((e) => e.address);
const organizations = values(card.organizations).flatMap((o) => [
o.name,
...(Array.isArray(o.units) ? (o.units as Array<{ name?: unknown }>).map((u) => u?.name) : []),
]);
return {
name: indexText(strings([name?.full, ...components, ...nicknames]).join(' ')),
email: indexText(strings(emails).join(' ')),
org: indexText(strings(organizations).join(' ')),
};
}
function books(card: Record<string, unknown>): string[] {
const ids = card.addressBookIds;
if (typeof ids !== 'object' || ids === null) return [];
return Object.entries(ids).filter(([, on]) => on).map(([id]) => id);
}
/** Writes one card and its index rows. Without a gen, the row keeps its own. */
export function writeCard(db: StorageAdapter, jmap: string, card: Card, gen?: number): void {
const existing = db.get<{ rowid: number; gen: number }>('SELECT rowid, gen FROM contact_card WHERE jmap = ? AND id = ?', [jmap, card.id]);
const row: SqlValue[] = [
typeof card.uid === 'string' ? card.uid : null,
foldText(cardDisplayName(card)),
JSON.stringify(card),
gen ?? Number(existing?.gen ?? 0),
];
let rowid: number;
if (existing) {
rowid = Number(existing.rowid);
db.run('UPDATE contact_card SET uid = ?, sort_key = ?, json = ?, gen = ? WHERE rowid = ?', [...row, rowid]);
dropIndex(db, rowid);
} else {
rowid = db.run('INSERT INTO contact_card (uid, sort_key, json, gen, jmap, id) VALUES (?, ?, ?, ?, ?, ?)', [...row, jmap, card.id]).lastInsertRowid;
}
const text = cardIndex(card);
for (const table of ['contact_fts_word', 'contact_fts_tri']) {
db.run(`INSERT INTO ${table} (rowid, name, email, org) VALUES (?, ?, ?, ?)`, [rowid, text.name, text.email, text.org]);
}
for (const book of books(card)) db.run('INSERT OR IGNORE INTO contact_card_book (card_rowid, book_id) VALUES (?, ?)', [rowid, book]);
}
function dropIndex(db: StorageAdapter, rowid: number): void {
db.run('DELETE FROM contact_fts_word WHERE rowid = ?', [rowid]);
db.run('DELETE FROM contact_fts_tri WHERE rowid = ?', [rowid]);
db.run('DELETE FROM contact_card_book WHERE card_rowid = ?', [rowid]);
}
export function removeCard(db: StorageAdapter, jmap: string, id: string): void {
const existing = db.get<{ rowid: number }>('SELECT rowid FROM contact_card WHERE jmap = ? AND id = ?', [jmap, id]);
if (!existing) return;
dropIndex(db, Number(existing.rowid));
db.run('DELETE FROM contact_card WHERE rowid = ?', [existing.rowid]);
}
export function readCard(db: StorageAdapter, jmap: string, id: string): Card | null {
const row = db.get<{ json: string }>('SELECT json FROM contact_card WHERE jmap = ? AND id = ?', [jmap, id]);
return row ? JSON.parse(row.json) as Card : null;
}
+77
View File
@@ -0,0 +1,77 @@
import type { Migration } from '../storage';
export const CONTACT_MIGRATIONS: readonly Migration[] = [
{
id: 600,
name: 'contacts',
sql: `
CREATE TABLE contact_card (
rowid INTEGER PRIMARY KEY,
jmap TEXT NOT NULL,
id TEXT NOT NULL,
uid TEXT,
sort_key TEXT NOT NULL DEFAULT '',
-- What the UI sees: the server's card with queued edits on top.
json TEXT NOT NULL,
gen INTEGER NOT NULL DEFAULT 0,
UNIQUE (jmap, id)
);
CREATE INDEX contact_card_sort ON contact_card (jmap, sort_key, id);
CREATE TABLE contact_card_book (
card_rowid INTEGER NOT NULL,
book_id TEXT NOT NULL,
PRIMARY KEY (card_rowid, book_id)
) WITHOUT ROWID;
CREATE INDEX contact_card_book_lookup ON contact_card_book (book_id, card_rowid);
-- Text goes in through foldText (records.ts), which also takes care of accents.
CREATE VIRTUAL TABLE contact_fts_word USING fts5(
name, email, org,
content = '', contentless_delete = 1,
tokenize = 'unicode61 remove_diacritics 0',
prefix = '2 3'
);
CREATE VIRTUAL TABLE contact_fts_tri USING fts5(
name, email, org,
content = '', contentless_delete = 1,
tokenize = 'trigram'
);
-- One row per card with something to send. Edits to a card merge here.
CREATE TABLE contact_pending (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
jmap TEXT NOT NULL,
id TEXT NOT NULL,
op TEXT NOT NULL,
uid TEXT,
-- create: the whole card. update: the merged patch. destroy: nothing.
payload TEXT,
-- The server's card when the first edit was queued, to see what changed there since.
base TEXT,
-- A request may have reached the server, so a create looks for its uid first.
attempted INTEGER NOT NULL DEFAULT 0,
-- The payload that request held: the server may have this, not the newer payload.
sent TEXT,
queued_at INTEGER NOT NULL,
UNIQUE (jmap, id)
);
-- Stand-in ids the page may still hold after a create went through.
CREATE TABLE contact_id_map (
jmap TEXT NOT NULL,
local_id TEXT NOT NULL,
real_id TEXT NOT NULL,
PRIMARY KEY (jmap, local_id)
) WITHOUT ROWID;
CREATE TABLE contact_conflict (
id INTEGER PRIMARY KEY AUTOINCREMENT,
jmap TEXT NOT NULL,
record_id TEXT NOT NULL,
-- refused: the server said no. changed: edited there too. gone: deleted there.
kind TEXT NOT NULL,
summary TEXT NOT NULL,
local TEXT NOT NULL,
server TEXT,
error TEXT,
created_at INTEGER NOT NULL
);
`,
},
];
+216
View File
@@ -0,0 +1,216 @@
/**
* Contact sync for one JMAP account: first what's queued goes out, then the
* address books, then the cards. A full pass over the cards captures the
* state first and pages, with its cursor in sync_state, so a reload continues
* where it stopped. Cards the pass didn't see are gone from the server, so a
* page must not skip any: it goes from the last card read (`anchor`), since a
* position moves when an earlier card is deleted. Both Stalwarts take that.
*/
import type { SyncContext, Syncer } from '../area';
import { runChangesLoop } from '../engine/changes';
import { deleteObject, putObject } from '../engine/core-schema';
import { requestLimits } from '../engine/limits';
import { JmapMethodError, responseFor, TransportError, type MethodCall } from '../engine/transport';
import { flushPending, serverDestroyed, storeServerCard } from './pending';
import { ADDRESS_BOOK_PROPERTIES, CONTACTS_CAPABILITY, CONTACTS_USING, type Card } from './records';
const MOST_PAIRS_PER_REQUEST = 4;
function pageSize(ctx: SyncContext): number {
return requestLimits(ctx.session).maxObjectsInGet;
}
// ---- Address books ----
async function fullBooks(ctx: SyncContext, jmap: string): Promise<void> {
const response = await ctx.transport.request([['AddressBook/get', { accountId: jmap, properties: ADDRESS_BOOK_PROPERTIES }, 'b']], CONTACTS_USING);
const [, result] = responseFor(response, 'b');
ctx.db.transaction(() => {
ctx.db.run("DELETE FROM object WHERE jmap = ? AND type = 'AddressBook'", [jmap]);
for (const book of result.list as Array<{ id: string }>) putObject(ctx.db, jmap, 'AddressBook', book.id, book);
ctx.setSyncState(jmap, 'AddressBook', { state: String(result.state), phase: null, cursor: null });
});
ctx.emit({ type: 'changed', account: ctx.account, collections: ['AddressBook'] });
}
async function syncBooks(ctx: SyncContext, jmap: string): Promise<void> {
const { state, target } = ctx.syncState(jmap, 'AddressBook');
if (!state) return fullBooks(ctx, jmap);
const outcome = await runChangesLoop({
transport: ctx.transport,
type: 'AddressBook',
jmap,
using: CONTACTS_USING,
since: state,
target,
stopped: ctx.stopped,
apply: async (page) => {
const ids = [...page.created, ...page.updated];
const list: Array<{ id: string }> = [];
const size = pageSize(ctx);
for (let i = 0; i < ids.length; i += size) {
const response = await ctx.transport.request([['AddressBook/get', { accountId: jmap, ids: ids.slice(i, i + size), properties: ADDRESS_BOOK_PROPERTIES }, 'b']], CONTACTS_USING);
list.push(...responseFor(response, 'b')[1].list);
}
ctx.db.transaction(() => {
for (const book of list) putObject(ctx.db, jmap, 'AddressBook', book.id, book);
for (const id of page.destroyed) deleteObject(ctx.db, jmap, 'AddressBook', id);
ctx.setSyncState(jmap, 'AddressBook', { state: page.newState });
});
if (ids.length + page.destroyed.length > 0) ctx.emit({ type: 'changed', account: ctx.account, collections: ['AddressBook'] });
},
});
if (outcome.outcome === 'resync') await fullBooks(ctx, jmap);
}
// ---- Cards ----
interface ListCursor { position: number; anchor: string | null; gen: number; captured: string }
async function startFullList(ctx: SyncContext, jmap: string): Promise<ListCursor> {
const response = await ctx.transport.request([['ContactCard/get', { accountId: jmap, ids: [] }, 's']], CONTACTS_USING);
const captured = String(responseFor(response, 's')[1].state);
const gen = Number(ctx.db.get<{ gen: number | null }>('SELECT MAX(gen) AS gen FROM contact_card WHERE jmap = ?', [jmap])?.gen ?? 0) + 1;
const cursor: ListCursor = { position: 0, anchor: null, gen, captured };
// Answers wait for the end of a first pass (phase `first`). A resync has an older copy to give.
const phase = ctx.syncState(jmap, 'ContactCard').state ? 'list' : 'first';
ctx.db.transaction(() => ctx.setSyncState(jmap, 'ContactCard', { state: null, phase, cursor: { ...cursor } }));
return cursor;
}
/** Cards from before this pass that it didn't find, leaving alone creates still on this device. */
function sweep(ctx: SyncContext, jmap: string, gen: number): void {
const stale = ctx.db.all<{ id: string }>(
`SELECT c.id FROM contact_card c WHERE c.jmap = ? AND c.gen < ?
AND NOT EXISTS (SELECT 1 FROM contact_pending p WHERE p.jmap = c.jmap AND p.id = c.id AND p.op = 'create')`,
[jmap, gen],
);
for (const row of stale) serverDestroyed(ctx, jmap, String(row.id));
}
async function runFullList(ctx: SyncContext, jmap: string, start: ListCursor): Promise<boolean> {
const cursor = { ...start };
const limits = requestLimits(ctx.session);
const page = limits.maxObjectsInGet;
const pairs = Math.max(1, Math.min(MOST_PAIRS_PER_REQUEST, Math.floor(limits.maxCallsInRequest / 2)));
for (;;) {
if (ctx.stopped()) return false;
const calls: MethodCall[] = [];
for (let i = 0; i < pairs; i += 1) {
const from = cursor.anchor === null ? { position: i * page } : { anchor: cursor.anchor, anchorOffset: 1 + i * page };
calls.push(['ContactCard/query', { accountId: jmap, ...from, limit: page, calculateTotal: i === 0 }, `q${i}`]);
calls.push(['ContactCard/get', { accountId: jmap, '#ids': { resultOf: `q${i}`, name: 'ContactCard/query', path: '/ids' } }, `g${i}`]);
}
const response = await ctx.transport.request(calls, CONTACTS_USING);
let total: number | null = null;
let got = 0;
let reached = 0;
let short = false;
let last: string | null = null;
const cards: Card[] = [];
try {
for (let i = 0; i < pairs && !short; i += 1) {
const [, query] = responseFor(response, `q${i}`);
const ids = query.ids as string[];
if (i === 0 && typeof query.total === 'number') total = query.total;
cards.push(...responseFor(response, `g${i}`)[1].list as Card[]);
got += ids.length;
if (ids.length > 0) last = ids[ids.length - 1];
reached = Number(query.position ?? 0) + ids.length;
short = ids.length < page;
}
} catch (error) {
if (!(error instanceof JmapMethodError && error.type === 'anchorNotFound')) throw error;
// The card it went from is gone. Going through again costs requests, not cards.
cursor.anchor = null;
cursor.position = 0;
ctx.db.transaction(() => ctx.setSyncState(jmap, 'ContactCard', { cursor: { ...cursor } }));
continue;
}
if (last !== null) cursor.anchor = last;
cursor.position = reached;
// The server may clamp a page, so a short one only ends this if there's no total to go by.
const done = got === 0 || (total === null ? short : reached >= total);
ctx.db.transaction(() => {
for (const card of cards) storeServerCard(ctx.db, jmap, card, cursor.gen);
if (done) {
sweep(ctx, jmap, cursor.gen);
ctx.setSyncState(jmap, 'ContactCard', { state: cursor.captured, phase: null, cursor: null });
} else {
ctx.setSyncState(jmap, 'ContactCard', { cursor: { ...cursor } });
}
});
ctx.emit({ type: 'progress', account: ctx.account, area: 'contacts', phase: 'list', done: cursor.position, total });
if (done) {
ctx.emit({ type: 'changed', account: ctx.account, collections: ['ContactCard'] });
return true;
}
}
}
async function syncDelta(ctx: SyncContext, jmap: string, since: string, target: string | null): Promise<'done' | 'resync' | 'stopped'> {
const outcome = await runChangesLoop({
transport: ctx.transport,
type: 'ContactCard',
jmap,
using: CONTACTS_USING,
since,
target,
stopped: ctx.stopped,
apply: async (page) => {
const ids = [...page.created, ...page.updated];
const fetched: Card[] = [];
const size = pageSize(ctx);
for (let i = 0; i < ids.length; i += size) {
const response = await ctx.transport.request([['ContactCard/get', { accountId: jmap, ids: ids.slice(i, i + size) }, 'g']], CONTACTS_USING);
fetched.push(...responseFor(response, 'g')[1].list as Card[]);
}
ctx.db.transaction(() => {
const gen = Number(ctx.db.get<{ gen: number | null }>('SELECT MAX(gen) AS gen FROM contact_card WHERE jmap = ?', [jmap])?.gen ?? 0);
for (const card of fetched) storeServerCard(ctx.db, jmap, card, gen);
for (const id of page.destroyed) serverDestroyed(ctx, jmap, id);
ctx.setSyncState(jmap, 'ContactCard', { state: page.newState });
});
if (ids.length + page.destroyed.length > 0) ctx.emit({ type: 'changed', account: ctx.account, collections: ['ContactCard'] });
},
});
if (outcome.outcome === 'resync') return 'resync';
return outcome.outcome === 'stopped' ? 'stopped' : 'done';
}
async function syncCards(ctx: SyncContext, jmap: string): Promise<void> {
const row = ctx.syncState(jmap, 'ContactCard');
let cursor = row.phase === 'list' || row.phase === 'first' ? row.cursor as unknown as ListCursor : null;
if (!cursor && !row.state) cursor = await startFullList(ctx, jmap);
if (!cursor && row.state) {
const outcome = await syncDelta(ctx, jmap, row.state, row.target);
if (outcome === 'stopped') return;
if (outcome === 'resync') cursor = await startFullList(ctx, jmap);
}
if (cursor) {
if (!(await runFullList(ctx, jmap, cursor))) return;
// Catch what changed while paging.
const after = ctx.syncState(jmap, 'ContactCard');
if (after.state) await syncDelta(ctx, jmap, after.state, after.target);
}
}
export const contactsSyncer: Syncer = {
name: 'contacts',
capability: CONTACTS_CAPABILITY,
async run(ctx, jmap) {
try {
await flushPending(ctx, jmap);
} catch (error) {
// No network, no credentials or a busy server is the engine's to handle.
// Anything else leaves the edits queued and mustn't stop contacts syncing.
if (error instanceof TransportError && error.kind !== 'http') throw error;
ctx.emit({ type: 'error', account: ctx.account, area: 'contacts', message: error instanceof Error ? error.message : String(error) });
}
if (ctx.stopped()) return;
await syncBooks(ctx, jmap);
if (ctx.stopped()) return;
await syncCards(ctx, jmap);
},
};
+2
View File
@@ -304,6 +304,7 @@ export class AccountEngine {
now: this.now,
emit: this.emitter,
stopped: () => this.closed || !this.online || this.scopeChanged,
requestSync: () => this.requestSync(),
syncState: (jmap, type) => this.syncState(jmap, type),
setSyncState: (jmap, type, row) => this.setSyncState(jmap, type, row),
overlay: (jmap, type, records) => this.overlay(jmap, type, records),
@@ -321,6 +322,7 @@ export class AccountEngine {
emit: this.emitter,
overlay: (jmap, type, records) => this.overlay(jmap, type, records),
keptByOthers: (area) => this.keptByOthers(area),
requestSync: () => this.requestSync(),
};
}
+31
View File
@@ -0,0 +1,31 @@
/** What one request may hold: the session's limits, never past the ones the engine was built for. */
import type { StoreSession } from './transport';
export interface RequestLimits {
maxObjectsInGet: number;
maxObjectsInSet: number;
maxCallsInRequest: number;
maxSizeRequest: number;
}
const DEFAULTS: RequestLimits = {
maxObjectsInGet: 500,
maxObjectsInSet: 500,
maxCallsInRequest: 16,
maxSizeRequest: 10 * 1024 * 1024,
};
export function requestLimits(session: StoreSession): RequestLimits {
const core = (session.capabilities['urn:ietf:params:jmap:core'] ?? {}) as Partial<RequestLimits>;
const limit = (key: keyof RequestLimits) => {
const value = core[key];
return typeof value === 'number' && value > 0 ? Math.min(value, DEFAULTS[key]) : DEFAULTS[key];
};
return {
maxObjectsInGet: limit('maxObjectsInGet'),
maxObjectsInSet: limit('maxObjectsInSet'),
maxCallsInRequest: limit('maxCallsInRequest'),
maxSizeRequest: limit('maxSizeRequest'),
};
}
+27
View File
@@ -0,0 +1,27 @@
import type { StoreArea } from '../area';
import { fileBlob, keptStatus, noteOpened, pinFile, readFile, unpinFile, type NodeRef } from './keep';
import { FILE_METHODS } from './methods';
import { FILE_MIGRATIONS } from './schema';
import { filesSyncer } from './sync';
export { fileBlob, FILE_CACHE_CAP_BYTES } from './keep';
export const filesArea: StoreArea = {
name: 'files',
migrations: FILE_MIGRATIONS,
methods: FILE_METHODS,
syncers: [filesSyncer],
calls: {
/** Keep this file's content on the device until it's unpinned. */
'files.pin': (ctx, params: NodeRef) => pinFile(ctx, params),
'files.unpin': (ctx, params: NodeRef) => unpinFile(ctx, params),
/** The page opened this file: keep its content for a while. */
'files.noteOpened': (ctx, params: NodeRef) => noteOpened(ctx, params),
/** A kept file's content and name, or null. */
'files.read': (ctx, params: NodeRef) => readFile(ctx, params),
/** A kept content by blob id, or null. */
'files.blob': (ctx, params: { jmap: string; blobId: string }) => fileBlob(ctx, params.jmap, params.blobId),
/** Pinned and recently opened files, for a settings page. */
'files.kept': (ctx) => keptStatus(ctx),
},
};
+295
View File
@@ -0,0 +1,295 @@
/**
* File contents kept on the device: pinned files, and recently opened ones
* within a cap. Metadata for every file is in file_node regardless.
*
* What gets kept:
* - A pinned file stays until it's unpinned or removed on the server. The cap
* doesn't push it out and the size limit for one file doesn't apply. The
* account's total limit (scope.maxTotalBytes, which a policy can lower) does.
* - A recently opened file is kept if it's within scope.maxAttachmentBytes, and
* pushed out oldest first when the cap is reached. Its opened_at goes with
* it, so it isn't fetched straight back.
* - With scope.attachments off, nothing is kept.
*/
import { TransportError } from '../engine/transport';
import { MethodError, type StoreContext, type SyncContext } from '../area';
import type { StorageAdapter } from '../storage';
import { nodeMediaType, type WireNode } from './records';
export const FILE_CACHE_CAP_BYTES = 500 * 1024 * 1024;
const CHUNK_BYTES = 1024 * 1024;
export interface KeptFile { data: Uint8Array; type: string; size: number }
export interface NodeRef { jmap: string; id: string }
interface NodeRow {
jmap: string;
id: string;
name: string;
kind: string;
blob_id: string | null;
size: number | null;
json: string;
pinned: number;
opened_at: number | null;
keep_error: string | null;
}
interface BlobRow {
jmap: string;
blob_id: string;
size: number;
kept_at: number;
pinned: number;
opened_at: number | null;
}
function capBytes(ctx: Pick<StoreContext, 'scope'>): number {
return Math.min(FILE_CACHE_CAP_BYTES, ctx.scope.maxTotalBytes);
}
/** A kept file's content, for the client's blob lookup. */
export function fileBlob(ctx: Pick<StoreContext, 'db'>, jmap: string, blobId: string): KeptFile | null {
const row = ctx.db.get<{ type: string; size: number }>('SELECT type, size FROM file_blob WHERE jmap = ? AND blob_id = ?', [jmap, blobId]);
if (!row) return null;
const data = new Uint8Array(Number(row.size));
let at = 0;
for (const chunk of ctx.db.all<{ data: Uint8Array }>('SELECT data FROM file_blob_chunk WHERE jmap = ? AND blob_id = ? ORDER BY seq', [jmap, blobId])) {
data.set(chunk.data, at);
at += chunk.data.length;
}
return { data, type: row.type, size: Number(row.size) };
}
function hasBlob(db: StorageAdapter, jmap: string, blobId: string): boolean {
return Boolean(db.get('SELECT 1 AS x FROM file_blob WHERE jmap = ? AND blob_id = ?', [jmap, blobId]));
}
function dropBlob(db: StorageAdapter, jmap: string, blobId: string): void {
db.run('DELETE FROM file_blob_chunk WHERE jmap = ? AND blob_id = ?', [jmap, blobId]);
db.run('DELETE FROM file_blob WHERE jmap = ? AND blob_id = ?', [jmap, blobId]);
}
function storeBlob(ctx: StoreContext, jmap: string, blobId: string, type: string, data: Uint8Array): void {
dropBlob(ctx.db, jmap, blobId);
ctx.db.run('INSERT INTO file_blob (jmap, blob_id, type, size, kept_at) VALUES (?, ?, ?, ?, ?)', [jmap, blobId, type, data.length, ctx.now()]);
for (let seq = 0, at = 0; at < data.length; seq += 1, at += CHUNK_BYTES) {
ctx.db.run('INSERT INTO file_blob_chunk (jmap, blob_id, seq, data) VALUES (?, ?, ?, ?)', [jmap, blobId, seq, data.subarray(at, at + CHUNK_BYTES)]);
}
}
/** All kept contents in the store, with what the nodes pointing at each say about it. */
function keptBlobs(db: StorageAdapter): BlobRow[] {
return db.all<BlobRow>(
`SELECT b.jmap, b.blob_id, b.size, b.kept_at, COALESCE(MAX(n.pinned), 0) AS pinned, MAX(n.opened_at) AS opened_at
FROM file_blob b LEFT JOIN file_node n ON n.jmap = b.jmap AND n.blob_id = b.blob_id
GROUP BY b.jmap, b.blob_id`,
);
}
function ageOf(blob: BlobRow): number {
return Number(blob.opened_at ?? blob.kept_at);
}
function pushOut(db: StorageAdapter, blob: BlobRow): void {
dropBlob(db, blob.jmap, blob.blob_id);
db.run('UPDATE file_node SET opened_at = NULL WHERE jmap = ? AND blob_id = ?', [blob.jmap, blob.blob_id]);
}
/**
* Pushes out unpinned contents, oldest first, so `size` more fits under the
* cap. A recently opened file only pushes out older ones and says false when
* that isn't enough. A pinned one always gets in.
*/
function makeRoom(ctx: StoreContext, size: number, incoming: { pinned: boolean; openedAt: number }): boolean {
const cap = capBytes(ctx);
const blobs = keptBlobs(ctx.db);
let used = blobs.reduce((sum, blob) => sum + Number(blob.size), 0);
const candidates = blobs
.filter((blob) => !blob.pinned && (incoming.pinned || ageOf(blob) < incoming.openedAt))
.sort((a, b) => ageOf(a) - ageOf(b));
const chosen: BlobRow[] = [];
for (const blob of candidates) {
if (used + size <= cap) break;
used -= Number(blob.size);
chosen.push(blob);
}
if (used + size > cap && !incoming.pinned) return false;
for (const blob of chosen) pushOut(ctx.db, blob);
return true;
}
function pinnedBytes(db: StorageAdapter): number {
return keptBlobs(db).filter((blob) => blob.pinned).reduce((sum, blob) => sum + Number(blob.size), 0);
}
function setError(db: StorageAdapter, jmap: string, blobId: string, error: string | null): void {
db.run('UPDATE file_node SET keep_error = ? WHERE jmap = ? AND blob_id = ?', [error, jmap, blobId]);
}
/** Contents no node points at any more: the file was removed or has a new version. */
export function dropOrphanBlobs(db: StorageAdapter): void {
for (const blob of keptBlobs(db)) {
const used = db.get('SELECT 1 AS x FROM file_node WHERE jmap = ? AND blob_id = ?', [blob.jmap, blob.blob_id]);
if (!used) dropBlob(db, blob.jmap, blob.blob_id);
}
}
interface Wanted { blob_id: string; id: string; name: string; size: number | null; json: string; pinned: number; opened_at: number | null }
function wantedRow(db: StorageAdapter, jmap: string, blobId: string): Wanted | undefined {
return db.get<Wanted>(
`SELECT blob_id, id, name, size, json, pinned, opened_at FROM file_node
WHERE jmap = ? AND blob_id = ? AND (pinned = 1 OR opened_at IS NOT NULL)
ORDER BY pinned DESC, opened_at DESC LIMIT 1`,
[jmap, blobId],
);
}
/** Fetches what should be kept and isn't yet, one file at a time, pinned files first. */
export async function fetchKeptFiles(ctx: SyncContext, jmap: string): Promise<void> {
const { db } = ctx;
if (!ctx.scope.attachments) {
db.transaction(() => {
for (const blob of keptBlobs(db)) dropBlob(db, blob.jmap, blob.blob_id);
db.run("UPDATE file_node SET keep_error = 'notAllowed' WHERE jmap = ? AND (pinned = 1 OR opened_at IS NOT NULL)", [jmap]);
});
return;
}
db.transaction(() => {
db.run("UPDATE file_node SET keep_error = NULL WHERE jmap = ? AND keep_error = 'notAllowed'", [jmap]);
// The limit may have gone down since last time.
makeRoom(ctx, 0, { pinned: true, openedAt: 0 });
});
const todo = db.all<{ blob_id: string }>(
`SELECT n.blob_id FROM file_node n
WHERE n.jmap = ? AND n.blob_id IS NOT NULL AND (n.pinned = 1 OR n.opened_at IS NOT NULL)
AND NOT EXISTS (SELECT 1 FROM file_blob b WHERE b.jmap = n.jmap AND b.blob_id = n.blob_id)
GROUP BY n.blob_id ORDER BY MAX(n.pinned) DESC, MAX(n.opened_at) DESC`,
[jmap],
).map((row) => String(row.blob_id));
let done = 0;
for (const blobId of todo) {
if (ctx.stopped()) return;
// The page may have changed its mind during the last download.
const want = wantedRow(db, jmap, blobId);
if (!want) continue;
const known = Number(want.size ?? 0);
if (!want.pinned && known > Math.min(ctx.scope.maxAttachmentBytes, capBytes(ctx))) {
setError(db, jmap, blobId, 'tooLarge');
continue;
}
if (want.pinned && pinnedBytes(db) + known > ctx.scope.maxTotalBytes) {
setError(db, jmap, blobId, 'overLimit');
continue;
}
let data: Uint8Array;
const type = nodeMediaType(JSON.parse(want.json) as WireNode);
try {
data = await ctx.transport.download(jmap, blobId, type, want.name);
} catch (error) {
// No network or no credentials reach the engine. One bad file doesn't stop the others.
if (error instanceof TransportError && error.kind === 'http') {
setError(db, jmap, blobId, 'download');
continue;
}
throw error;
}
db.transaction(() => {
const now = wantedRow(db, jmap, blobId);
if (!now) return;
if (now.pinned ? pinnedBytes(db) + data.length > ctx.scope.maxTotalBytes : data.length > Math.min(ctx.scope.maxAttachmentBytes, capBytes(ctx))) {
// The size in the metadata was wrong or missing. With the real one
// noted, the next pass sees this without downloading it again.
db.run('UPDATE file_node SET size = ? WHERE jmap = ? AND blob_id = ?', [data.length, jmap, blobId]);
setError(db, jmap, blobId, now.pinned ? 'overLimit' : 'tooLarge');
return;
}
if (!makeRoom(ctx, data.length, { pinned: Boolean(now.pinned), openedAt: Number(now.opened_at ?? 0) })) {
db.run('UPDATE file_node SET opened_at = NULL WHERE jmap = ? AND blob_id = ? AND pinned = 0', [jmap, blobId]);
return;
}
storeBlob(ctx, jmap, blobId, type, data);
setError(db, jmap, blobId, null);
});
done += 1;
ctx.emit({ type: 'progress', account: ctx.account, area: 'files', phase: 'contents', done, total: todo.length });
}
if (done > 0) ctx.emit({ type: 'changed', account: ctx.account, collections: ['FileContent'] });
}
// ---- Calls from the page ----
function fileRow(db: StorageAdapter, { jmap, id }: NodeRef): NodeRow {
const row = db.get<NodeRow>('SELECT jmap, id, name, kind, blob_id, size, json, pinned, opened_at, keep_error FROM file_node WHERE jmap = ? AND id = ?', [jmap, id]);
if (!row) throw new MethodError('notFound', `No file ${id}`);
if (!row.blob_id || row.kind !== 'file') throw new MethodError('invalidArguments', 'Only files can be kept');
return row;
}
export function pinFile(ctx: StoreContext, ref: NodeRef): { kept: boolean } {
const row = fileRow(ctx.db, ref);
ctx.db.run('UPDATE file_node SET pinned = 1, keep_error = NULL WHERE jmap = ? AND id = ?', [ref.jmap, ref.id]);
const kept = hasBlob(ctx.db, ref.jmap, row.blob_id as string);
if (!kept) void ctx.requestSync();
return { kept };
}
/** Back to being a recently opened file, or let go of it if it never was one. */
export function unpinFile(ctx: StoreContext, ref: NodeRef): void {
const row = fileRow(ctx.db, ref);
ctx.db.transaction(() => {
ctx.db.run('UPDATE file_node SET pinned = 0 WHERE jmap = ? AND id = ?', [ref.jmap, ref.id]);
if (!wantedRow(ctx.db, ref.jmap, row.blob_id as string)) dropBlob(ctx.db, ref.jmap, row.blob_id as string);
});
}
export function noteOpened(ctx: StoreContext, ref: NodeRef): { kept: boolean } {
const row = fileRow(ctx.db, ref);
ctx.db.run('UPDATE file_node SET opened_at = ?, keep_error = NULL WHERE jmap = ? AND id = ?', [ctx.now(), ref.jmap, ref.id]);
const kept = hasBlob(ctx.db, ref.jmap, row.blob_id as string);
if (!kept) void ctx.requestSync();
return { kept };
}
export function readFile(ctx: StoreContext, ref: NodeRef): (KeptFile & { name: string }) | null {
const row = fileRow(ctx.db, ref);
const kept = fileBlob(ctx, ref.jmap, row.blob_id as string);
return kept ? { ...kept, name: row.name } : null;
}
export interface KeptFileStatus {
jmap: string;
id: string;
name: string;
size: number | null;
pinned: boolean;
openedAt: number | null;
kept: boolean;
/** Why it isn't kept: tooLarge, overLimit, notAllowed or download. */
error: string | null;
}
/** Pinned and recently opened files, and how much room their contents take. */
export function keptStatus(ctx: StoreContext): { files: KeptFileStatus[]; bytes: number; capBytes: number } {
const rows = ctx.db.all<NodeRow>(
`SELECT jmap, id, name, kind, blob_id, size, json, pinned, opened_at, keep_error FROM file_node
WHERE pinned = 1 OR opened_at IS NOT NULL ORDER BY pinned DESC, opened_at DESC, name`,
);
return {
files: rows.map((row) => ({
jmap: row.jmap,
id: row.id,
name: row.name,
size: row.size === null ? null : Number(row.size),
pinned: Boolean(row.pinned),
openedAt: row.opened_at === null ? null : Number(row.opened_at),
kept: row.blob_id !== null && hasBlob(ctx.db, row.jmap, row.blob_id),
error: row.keep_error,
})),
bytes: keptBlobs(ctx.db).reduce((sum, blob) => sum + Number(blob.size), 0),
capBytes: capBytes(ctx),
};
}
+108
View File
@@ -0,0 +1,108 @@
/**
* FileNode reads answered from the store, so listings work offline. Nothing
* here answers FileNode/set: the responder gives it a per-call
* serverUnavailable, since the design has no offline file writes.
*/
import { MethodError, type MethodContext, type MethodHandler, type MethodResult } from '../area';
import type { SqlValue } from '../storage';
import { FILES_CAPABILITY } from './records';
function jmapOf(ctx: MethodContext, args: Record<string, unknown>): string {
return (args.accountId as string | undefined) ?? ctx.session.primaryAccounts[FILES_CAPABILITY] ?? ctx.defaultJmap;
}
/** The last state from the server. Throws when the list was never read, so an empty answer always means empty. */
function syncedState(ctx: MethodContext, jmap: string): string | null {
const row = ctx.db.get<{ state: string | null; phase: string | null }>("SELECT state, phase FROM sync_state WHERE jmap = ? AND type = 'FileNode'", [jmap]);
if (!row || (!row.state && !row.phase)) throw new MethodError('serverUnavailable', 'Files not kept yet');
return row.state;
}
function pick(node: Record<string, unknown>, properties: unknown): Record<string, unknown> {
if (!Array.isArray(properties)) return node;
const out: Record<string, unknown> = { id: node.id };
for (const property of properties as string[]) if (property in node) out[property] = node[property];
return out;
}
const SORTS: Record<string, string> = {
name: 'name COLLATE NOCASE',
size: 'size',
created: "json_extract(json, '$.created')",
modified: "json_extract(json, '$.modified')",
};
function orderSql(sort: unknown): string {
const parts: string[] = [];
for (const level of (Array.isArray(sort) ? sort : []) as Array<{ property?: string; isAscending?: boolean }>) {
const column = SORTS[String(level.property)];
if (!column) throw new MethodError('unsupportedSort', `${String(level.property)} isn't available offline`);
parts.push(`${column} ${level.isAscending === false ? 'DESC' : 'ASC'}`);
}
parts.push('name COLLATE NOCASE ASC', 'id ASC');
return parts.join(', ');
}
const fileNodeGet: MethodHandler = (ctx, args) => {
const jmap = jmapOf(ctx, args);
const state = syncedState(ctx, jmap);
const list: Array<Record<string, unknown>> = [];
const notFound: string[] = [];
if (Array.isArray(args.ids)) {
for (const id of args.ids as string[]) {
const row = ctx.db.get<{ json: string }>('SELECT json FROM file_node WHERE jmap = ? AND id = ?', [jmap, id]);
if (row) list.push(pick(JSON.parse(row.json) as Record<string, unknown>, args.properties));
else notFound.push(id);
}
} else {
// The whole tree at once. The client reads a long list as possibly cut,
// pages FileNode/query to check, and finds nothing missing.
for (const row of ctx.db.all<{ json: string }>('SELECT json FROM file_node WHERE jmap = ?', [jmap])) {
list.push(pick(JSON.parse(row.json) as Record<string, unknown>, args.properties));
}
}
return { accountId: jmap, ...(state ? { state } : {}), list, notFound };
};
const fileNodeQuery: MethodHandler = (ctx, args) => {
const jmap = jmapOf(ctx, args);
syncedState(ctx, jmap);
if (args.anchor !== undefined) throw new MethodError('unsupportedFilter', 'anchor needs the server');
const filter = (args.filter ?? {}) as Record<string, unknown>;
const conditions = ['jmap = ?'];
const params: SqlValue[] = [jmap];
for (const [key, value] of Object.entries(filter)) {
if (key !== 'parentId') throw new MethodError('unsupportedFilter', `${key} isn't available offline`);
if (value === null) {
conditions.push('parent_id IS NULL');
} else {
conditions.push('parent_id = ?');
params.push(String(value));
}
}
const where = conditions.join(' AND ');
const position = Math.max(0, Number(args.position ?? 0));
const limit = typeof args.limit === 'number' && args.limit > 0 ? args.limit : -1;
const ids = ctx.db.all<{ id: string }>(
`SELECT id FROM file_node WHERE ${where} ORDER BY ${orderSql(args.sort)} LIMIT ? OFFSET ?`,
[...params, limit, position],
).map((row) => String(row.id));
const result: MethodResult = {
accountId: jmap,
// Local answers aren't a position in the server's log, so this is a token only.
queryState: 'local',
canCalculateChanges: false,
position,
ids,
};
if (args.calculateTotal) {
result.total = Number(ctx.db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM file_node WHERE ${where}`, params)?.n ?? 0);
}
return result;
};
export const FILE_METHODS: Readonly<Record<string, MethodHandler>> = {
'FileNode/get': fileNodeGet,
'FileNode/query': fileNodeQuery,
};
+50
View File
@@ -0,0 +1,50 @@
/** How a FileNode becomes a row. The server's own copy stays in `json`, in whichever draft it speaks. */
import { fileNodeKind, fromDraftFileNode } from '@/lib/jmap/filenode-kind';
import { decodeFileNodeName } from '@/lib/jmap/filenode-name';
import type { FileNode } from '@/lib/jmap/types';
import type { StorageAdapter } from '../storage';
export const FILES_CAPABILITY = 'urn:ietf:params:jmap:filenode';
export const FILES_USING = ['urn:ietf:params:jmap:core', FILES_CAPABILITY];
/** The client's list (lib/jmap/client.ts). Stalwart leaves out shareWith and myRights unless asked. */
export const FILE_NODE_PROPERTIES = [
'id', 'parentId', 'name', 'type', 'blobId', 'size', 'created', 'modified',
'nodeType', '@type', 'mediaType', 'shareWith', 'myRights',
];
export type WireNode = Record<string, unknown> & { id: string };
/** The node's media type, whichever draft named it. */
export function nodeMediaType(node: WireNode): string {
const { type } = fromDraftFileNode(node as unknown as Partial<FileNode> & { '@type'?: unknown; mediaType?: unknown });
return typeof type === 'string' && type ? type : 'application/octet-stream';
}
export function writeNode(db: StorageAdapter, jmap: string, node: WireNode, gen: number): void {
const normal = fromDraftFileNode(node as unknown as FileNode & { '@type'?: unknown; mediaType?: unknown });
db.run(
`INSERT INTO file_node (jmap, id, parent_id, name, kind, blob_id, size, json, gen) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (jmap, id) DO UPDATE SET parent_id = excluded.parent_id, name = excluded.name, kind = excluded.kind,
size = excluded.size, json = excluded.json, gen = excluded.gen,
keep_error = CASE WHEN blob_id IS excluded.blob_id THEN keep_error END,
blob_id = excluded.blob_id`,
[
jmap,
node.id,
typeof node.parentId === 'string' ? node.parentId : null,
// The client decodes names itself from `json`. This column is for the store's own lists.
typeof node.name === 'string' ? decodeFileNodeName(node.name) : '',
fileNodeKind(normal),
typeof node.blobId === 'string' ? node.blobId : null,
typeof node.size === 'number' ? node.size : null,
JSON.stringify(node),
gen,
],
);
}
export function removeNode(db: StorageAdapter, jmap: string, id: string): void {
db.run('DELETE FROM file_node WHERE jmap = ? AND id = ?', [jmap, id]);
}
+47
View File
@@ -0,0 +1,47 @@
import type { Migration } from '../storage';
export const FILE_MIGRATIONS: readonly Migration[] = [
{
id: 700,
name: 'files',
sql: `
CREATE TABLE file_node (
jmap TEXT NOT NULL,
id TEXT NOT NULL,
parent_id TEXT,
name TEXT NOT NULL DEFAULT '',
-- file, directory or symlink, whichever way the server says so.
kind TEXT NOT NULL,
blob_id TEXT,
size INTEGER,
json TEXT NOT NULL,
gen INTEGER NOT NULL DEFAULT 0,
pinned INTEGER NOT NULL DEFAULT 0,
-- When the page last opened it. Cleared when its content is pushed out.
opened_at INTEGER,
-- Why the content isn't kept: tooLarge, overLimit, notAllowed or download.
keep_error TEXT,
PRIMARY KEY (jmap, id)
) WITHOUT ROWID;
CREATE INDEX file_node_parent ON file_node (jmap, parent_id);
CREATE INDEX file_node_blob ON file_node (jmap, blob_id);
CREATE TABLE file_blob (
jmap TEXT NOT NULL,
blob_id TEXT NOT NULL,
type TEXT NOT NULL,
size INTEGER NOT NULL,
kept_at INTEGER NOT NULL,
PRIMARY KEY (jmap, blob_id)
) WITHOUT ROWID;
-- In pieces: a whole file bound at once would grow the WASM heap by its
-- size, and that memory never comes back.
CREATE TABLE file_blob_chunk (
jmap TEXT NOT NULL,
blob_id TEXT NOT NULL,
seq INTEGER NOT NULL,
data BLOB NOT NULL,
PRIMARY KEY (jmap, blob_id, seq)
);
`,
},
];
+160
View File
@@ -0,0 +1,160 @@
/**
* FileNode metadata for one JMAP account, then the contents worth keeping.
*
* A full pass reads the tree the way the client does (fetchAllFileNodes,
* lib/jmap/client.ts). `FileNode/get` with ids null returns everything, but
* 0.16 silently stops at maxObjectsInGet and 1.0 refuses a larger account with
* requestTooLarge. Past that, ids come from paging FileNode/query. Before
* 0.16.6 that query leaves out folders, so parents still unknown afterwards are
* read by id. The cursor is in sync_state, so a reload continues.
*/
import type { SyncContext, Syncer } from '../area';
import { runChangesLoop } from '../engine/changes';
import { JmapMethodError, responseFor, type MethodCall } from '../engine/transport';
import { dropOrphanBlobs, fetchKeptFiles } from './keep';
import { FILE_NODE_PROPERTIES, FILES_CAPABILITY, FILES_USING, removeNode, writeNode, type WireNode } from './records';
const MAX_GET = 500;
const GETS_PER_REQUEST = 8;
const QUERY_PAGE = 5000;
function getBatch(ctx: SyncContext): number {
const core = ctx.session.capabilities['urn:ietf:params:jmap:core'] as { maxObjectsInGet?: number } | undefined;
return Math.min(MAX_GET, core?.maxObjectsInGet || MAX_GET);
}
async function fetchNodes(ctx: SyncContext, jmap: string, ids: string[]): Promise<WireNode[]> {
const size = getBatch(ctx);
const nodes: WireNode[] = [];
for (let at = 0; at < ids.length;) {
const calls: MethodCall[] = [];
for (let i = 0; i < GETS_PER_REQUEST && at < ids.length; i += 1, at += size) {
calls.push(['FileNode/get', { accountId: jmap, ids: ids.slice(at, at + size), properties: FILE_NODE_PROPERTIES }, `g${i}`]);
}
const response = await ctx.transport.request(calls, FILES_USING);
calls.forEach(([, , callId]) => nodes.push(...responseFor(response, callId)[1].list as WireNode[]));
}
return nodes;
}
interface ListCursor { step: 'all' | 'query' | 'parents'; position: number; gen: number; captured: string }
async function startFullList(ctx: SyncContext, jmap: string): Promise<ListCursor> {
const response = await ctx.transport.request([['FileNode/get', { accountId: jmap, ids: [], properties: ['id'] }, 's']], FILES_USING);
const captured = String(responseFor(response, 's')[1].state);
const gen = Number(ctx.db.get<{ gen: number | null }>('SELECT MAX(gen) AS gen FROM file_node WHERE jmap = ?', [jmap])?.gen ?? 0) + 1;
const cursor: ListCursor = { step: 'all', position: 0, gen, captured };
ctx.db.transaction(() => ctx.setSyncState(jmap, 'FileNode', { state: null, phase: 'list', cursor: { ...cursor } }));
return cursor;
}
function saveStep(ctx: SyncContext, jmap: string, nodes: WireNode[], cursor: ListCursor): void {
ctx.db.transaction(() => {
for (const node of nodes) writeNode(ctx.db, jmap, node, cursor.gen);
ctx.setSyncState(jmap, 'FileNode', { cursor: { ...cursor } });
});
}
async function runFullList(ctx: SyncContext, jmap: string, start: ListCursor): Promise<boolean> {
const cursor = { ...start };
if (cursor.step === 'all') {
let nodes: WireNode[] = [];
let complete = false;
try {
const response = await ctx.transport.request([['FileNode/get', { accountId: jmap, ids: null, properties: FILE_NODE_PROPERTIES }, 'a']], FILES_USING);
nodes = responseFor(response, 'a')[1].list as WireNode[];
complete = nodes.length < getBatch(ctx);
} catch (error) {
if (!(error instanceof JmapMethodError && error.type === 'requestTooLarge')) throw error;
}
if (!complete) cursor.step = 'query';
else cursor.step = 'parents';
saveStep(ctx, jmap, nodes, cursor);
}
while (cursor.step === 'query') {
if (ctx.stopped()) return false;
const response = await ctx.transport.request([['FileNode/query', { accountId: jmap, filter: {}, position: cursor.position, limit: QUERY_PAGE, calculateTotal: true }, 'q']], FILES_USING);
const [, query] = responseFor(response, 'q');
const ids = query.ids as string[];
const missing = ids.filter((id) => Number(ctx.db.get<{ gen: number }>('SELECT gen FROM file_node WHERE jmap = ? AND id = ?', [jmap, id])?.gen ?? -1) < cursor.gen);
const nodes = await fetchNodes(ctx, jmap, missing);
cursor.position += ids.length;
// The server may clamp the limit, so only an empty page or the total ends this.
if (ids.length === 0 || (typeof query.total === 'number' && cursor.position >= query.total)) cursor.step = 'parents';
saveStep(ctx, jmap, nodes, cursor);
ctx.emit({ type: 'progress', account: ctx.account, area: 'files', phase: 'list', done: cursor.position, total: typeof query.total === 'number' ? query.total : null });
}
const asked = new Set<string>();
for (;;) {
if (ctx.stopped()) return false;
const parents = ctx.db.all<{ id: string }>(
`SELECT DISTINCT n.parent_id AS id FROM file_node n
WHERE n.jmap = ? AND n.gen = ? AND n.parent_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM file_node p WHERE p.jmap = n.jmap AND p.id = n.parent_id AND p.gen = ?)`,
[jmap, cursor.gen, cursor.gen],
).map((row) => String(row.id)).filter((id) => !asked.has(id));
if (parents.length === 0) break;
for (const id of parents) asked.add(id);
saveStep(ctx, jmap, await fetchNodes(ctx, jmap, parents), cursor);
}
ctx.db.transaction(() => {
// Whatever this pass didn't see is gone from the server.
ctx.db.run('DELETE FROM file_node WHERE jmap = ? AND gen < ?', [jmap, cursor.gen]);
ctx.setSyncState(jmap, 'FileNode', { state: cursor.captured, phase: null, cursor: null });
});
ctx.emit({ type: 'changed', account: ctx.account, collections: ['FileNode'] });
return true;
}
async function syncDelta(ctx: SyncContext, jmap: string, since: string, target: string | null): Promise<'done' | 'resync' | 'stopped'> {
const outcome = await runChangesLoop({
transport: ctx.transport,
type: 'FileNode',
jmap,
using: FILES_USING,
since,
target,
stopped: ctx.stopped,
apply: async (page) => {
const nodes = await fetchNodes(ctx, jmap, [...page.created, ...page.updated]);
ctx.db.transaction(() => {
const gen = Number(ctx.db.get<{ gen: number | null }>('SELECT MAX(gen) AS gen FROM file_node WHERE jmap = ?', [jmap])?.gen ?? 0);
for (const node of nodes) writeNode(ctx.db, jmap, node, gen);
for (const id of page.destroyed) removeNode(ctx.db, jmap, id);
ctx.setSyncState(jmap, 'FileNode', { state: page.newState });
});
if (nodes.length + page.destroyed.length > 0) ctx.emit({ type: 'changed', account: ctx.account, collections: ['FileNode'] });
},
});
if (outcome.outcome === 'resync') return 'resync';
return outcome.outcome === 'stopped' ? 'stopped' : 'done';
}
export const filesSyncer: Syncer = {
name: 'files',
capability: FILES_CAPABILITY,
async run(ctx, jmap) {
const row = ctx.syncState(jmap, 'FileNode');
let cursor = row.phase === 'list' ? row.cursor as unknown as ListCursor : null;
if (!cursor && !row.state) cursor = await startFullList(ctx, jmap);
if (!cursor && row.state) {
const outcome = await syncDelta(ctx, jmap, row.state, row.target);
if (outcome === 'stopped') return;
if (outcome === 'resync') cursor = await startFullList(ctx, jmap);
}
if (cursor) {
if (!(await runFullList(ctx, jmap, cursor))) return;
// Catch what changed while paging.
const after = ctx.syncState(jmap, 'FileNode');
if (after.state && (await syncDelta(ctx, jmap, after.state, after.target)) === 'stopped') return;
}
if (ctx.stopped()) return;
ctx.db.transaction(() => dropOrphanBlobs(ctx.db));
await fetchKeptFiles(ctx, jmap);
},
};
+74 -1
View File
@@ -9,10 +9,11 @@ import {
normalizeContactPhotoUri,
TRUSTED_SENDERS_BOOK_NAME,
} from '../contact-store';
import type { ContactCard, ContactMedia } from '@/lib/jmap/types';
import type { AddressBook, ContactCard, ContactMedia } from '@/lib/jmap/types';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import { clearContactPhotoCache } from '@/lib/contact-photo-cache';
import { PrincipalsRequestError } from '@/lib/jmap/principals-error';
import { PartialAccountsError } from '@/lib/jmap/partial-accounts';
import { setClientLookup } from '../client-registry';
vi.stubGlobal('crypto', { randomUUID: () => '00000000-0000-0000-0000-000000000000' });
@@ -1364,6 +1365,78 @@ describe('contact-store', () => {
});
});
describe('an account that cannot answer', () => {
it('keeps its contacts and books while the other accounts refresh', async () => {
const failing = {
getAllContacts: vi.fn().mockRejectedValue(new Error('offline')),
getAllAddressBooks: vi.fn().mockRejectedValue(new Error('offline')),
} as unknown as IJMAPClient;
const working = {
getAllContacts: vi.fn().mockResolvedValue([makeContact({ id: 'b1' })]),
getAllAddressBooks: vi.fn().mockResolvedValue([{ id: 'bookb', name: 'B' }]),
} as unknown as IJMAPClient;
const kept = makeContact({ id: 'acc-a::a1', localAccountId: 'acc-a' });
const keptBook = { id: 'acc-a::booka', name: 'A', localAccountId: 'acc-a' } as AddressBook;
useContactStore.setState({ contacts: [kept], addressBooks: [keptBook] });
const accounts = [{ client: failing, localAccountId: 'acc-a' }, { client: working, localAccountId: 'acc-b' }];
await useContactStore.getState().fetchAllAccountsContacts(accounts);
await useContactStore.getState().fetchAllAccountsAddressBooks(accounts);
const state = useContactStore.getState();
expect(state.contacts.map((c) => c.id)).toEqual(['acc-a::a1', 'acc-b::b1']);
expect(state.addressBooks.map((b) => b.id)).toEqual(['acc-a::booka', 'acc-b::bookb']);
});
it('keeps the primary account\'s cards when only a shared account answered', async () => {
const refusal = new PartialAccountsError<ContactCard>([makeContact({ id: 'shared:s2', accountId: 'shared', isShared: true })], ['me'], 'me', new Error('offline'));
const failing = { getAllContacts: vi.fn().mockRejectedValue(refusal) } as unknown as IJMAPClient;
useContactStore.setState({
contacts: [
makeContact({ id: 'p1', accountId: 'me' }),
// A card just created has no accountId yet: it's the primary account's.
makeContact({ id: 'p2' }),
makeContact({ id: 'shared:s1', accountId: 'shared', isShared: true }),
],
});
await useContactStore.getState().fetchContacts(failing);
const state = useContactStore.getState();
expect(state.contacts.map((c) => c.id).sort()).toEqual(['p1', 'p2', 'shared:s2']);
expect(state.error).toBe('Failed to fetch contacts');
expect(state.isLoading).toBe(false);
});
it('keeps the primary account\'s books when only a shared account answered', async () => {
const refusal = new PartialAccountsError<AddressBook>([{ id: 'shared:b', name: 'B', accountId: 'shared' } as AddressBook], ['me'], 'me', new Error('offline'));
const failing = { getAllAddressBooks: vi.fn().mockRejectedValue(refusal) } as unknown as IJMAPClient;
useContactStore.setState({ addressBooks: [{ id: 'mine', name: 'Mine', accountId: 'me' } as AddressBook] });
await useContactStore.getState().fetchAddressBooks(failing);
expect(useContactStore.getState().addressBooks.map((b) => b.id).sort()).toEqual(['mine', 'shared:b']);
});
it('keeps a failed JMAP account of one login while its others refresh', async () => {
const refusal = new PartialAccountsError<ContactCard>([makeContact({ id: 's2', accountId: 'shared' })], ['me'], 'me', new Error('offline'));
const failing = { getAllContacts: vi.fn().mockRejectedValue(refusal) } as unknown as IJMAPClient;
useContactStore.setState({
contacts: [
makeContact({ id: 'acc-a::p1', accountId: 'me', localAccountId: 'acc-a' }),
makeContact({ id: 'acc-a::s1', accountId: 'shared', localAccountId: 'acc-a' }),
makeContact({ id: 'acc-b::x', accountId: 'me', localAccountId: 'acc-b' }),
],
});
await useContactStore.getState().fetchAllAccountsContacts([{ client: failing, localAccountId: 'acc-a' }]);
expect(useContactStore.getState().contacts.map((c) => c.id).sort()).toEqual(['acc-a::p1', 'acc-a::s2']);
});
it('keeps the list when the only account fails', async () => {
const failing = { getAllContacts: vi.fn().mockRejectedValue(new Error('offline')) } as unknown as IJMAPClient;
useContactStore.setState({ contacts: [makeContact()] });
await useContactStore.getState().fetchContacts(failing);
expect(useContactStore.getState().contacts).toHaveLength(1);
expect(useContactStore.getState().error).toBe('Failed to fetch contacts');
});
});
describe('persistence/partialize', () => {
it('should persist contacts when supportsSync is false', () => {
const { partialize } = (useContactStore as unknown as { persist: { getOptions: () => { partialize: (state: Record<string, unknown>) => Record<string, unknown> } } }).persist.getOptions();
+16
View File
@@ -120,6 +120,22 @@ describe('file-store hierarchy (issue #379)', () => {
});
});
it('keeps the listing when refreshing the same folder fails, and starts empty in a new one', async () => {
const client = makeMockClient([dir('stuff', 'Stuff', null), file('notes', 'Notes.md', null), file('inner', 'Inner.md', 'stuff')]);
useFileStore.getState().initClient(client);
await useFileStore.getState().navigate(null);
expect(useFileStore.getState().resources.map(r => r.name).sort()).toEqual(['Notes.md', 'Stuff']);
client.listAllFileNodes = async () => { throw new Error('offline'); };
await useFileStore.getState().navigate(null);
expect(useFileStore.getState().error).toBe('offline');
expect(useFileStore.getState().resources.map(r => r.name).sort()).toEqual(['Notes.md', 'Stuff']);
await useFileStore.getState().navigate('stuff', 'Stuff');
expect(useFileStore.getState().error).toBe('offline');
expect(useFileStore.getState().resources).toEqual([]);
});
it('lists only root-level nodes at the root, not the whole tree flattened', async () => {
// Stuff > Nonsense > Notes.md / Other.md (the exact shape from the issue)
const client = makeMockClient([
+25 -4
View File
@@ -13,6 +13,7 @@ import { sanitizeDisplayName, splitMailbox } from '@/lib/rfc5322-mailbox';
import { inlineContactMedia, writeContactWithNewPhoto, type ContactSaveOptions } from '@/lib/contact-media';
import { contactToView } from '@/lib/plugin-projection';
import { PrincipalsRequestError } from '@/lib/jmap/principals-error';
import { PartialAccountsError, partialAccounts, withFailedAccountsKept } from '@/lib/jmap/partial-accounts';
import type { Principal } from '@/lib/jmap/types';
import { currentStoreEpoch } from '@/lib/store-epoch';
@@ -614,7 +615,14 @@ export const useContactStore = create<ContactStore>()(
} catch (error) {
if (currentStoreEpoch() !== epoch) return;
console.error('Failed to fetch contacts:', error);
set({ error: 'Failed to fetch contacts', isLoading: false, contactsLoaded: true });
// An account that couldn't answer keeps its cards; the others are fresh.
const partial = partialAccounts<ContactCard>(error);
set((state) => ({
error: 'Failed to fetch contacts',
isLoading: false,
contactsLoaded: true,
contacts: partial ? withFailedAccountsKept(partial, state.contacts) : state.contacts,
}));
}
},
@@ -656,7 +664,11 @@ export const useContactStore = create<ContactStore>()(
} catch (error) {
if (currentStoreEpoch() !== epoch) return;
console.error('Failed to fetch address books:', error);
set({ error: 'Failed to fetch address books' });
const partial = partialAccounts<AddressBook>(error);
set((state) => ({
error: 'Failed to fetch address books',
addressBooks: partial ? withFailedAccountsKept(partial, state.addressBooks) : state.addressBooks,
}));
}
},
@@ -671,7 +683,12 @@ export const useContactStore = create<ContactStore>()(
return prefixContactsWithLocalAccount(list, localAccountId);
} catch (error) {
debug.error(`Failed to fetch contacts for account ${localAccountId}:`, error);
return [] as ContactCard[];
// Keep what this login's cards were: it just couldn't answer.
const held = get().contacts.filter((contact) => contact.localAccountId === localAccountId);
const partial = partialAccounts<ContactCard>(error);
if (!partial) return held;
const fresh = prefixContactsWithLocalAccount(partial.items, localAccountId);
return withFailedAccountsKept(new PartialAccountsError(fresh, partial.failedAccountIds, partial.primaryAccountId, error), held);
}
}),
);
@@ -694,7 +711,11 @@ export const useContactStore = create<ContactStore>()(
return prefixAddressBooksWithLocalAccount(list, localAccountId);
} catch (error) {
debug.error(`Failed to fetch address books for account ${localAccountId}:`, error);
return [] as AddressBook[];
const held = get().addressBooks.filter((book) => book.localAccountId === localAccountId);
const partial = partialAccounts<AddressBook>(error);
if (!partial) return held;
const fresh = prefixAddressBooksWithLocalAccount(partial.items, localAccountId);
return withFailedAccountsKept(new PartialAccountsError(fresh, partial.failedAccountIds, partial.primaryAccountId, error), held);
}
}),
);
+3 -1
View File
@@ -983,7 +983,9 @@ export const useFileStore = create<FileState>((set, get) => ({
error: error instanceof Error ? error.message : 'Failed to list directory',
hasListed: true,
isLoading: false,
resources: [],
// A refresh that fails keeps what the folder showed. Another folder's
// rows would be wrong here, so one being opened starts empty.
resources: opensFolder ? [] : get().resources,
});
}
},