From 6da9a6a6f5d7cff151f6972236a2a9b957753eb3 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 04:57:20 +0100 Subject: [PATCH 01/16] feat: add an encrypted local mail store that syncs in a worker --- .dockerignore | 5 + .gitignore | 3 + Dockerfile | 1 + lib/store/__tests__/helpers/fake-jmap.ts | 285 + lib/store/__tests__/helpers/node-adapter.ts | 36 + lib/store/__tests__/mail-sync.test.ts | 390 + lib/store/adapters/wasm.ts | 197 + lib/store/area.ts | 101 + lib/store/areas.ts | 6 + lib/store/client/keys.ts | 173 + lib/store/client/store-client.ts | 261 + lib/store/engine/account-engine.ts | 223 + lib/store/engine/changes.ts | 101 + lib/store/engine/core-schema.ts | 57 + lib/store/engine/responder.ts | 89 + lib/store/engine/transport.ts | 142 + lib/store/mail/index.ts | 11 + lib/store/mail/methods.ts | 273 + lib/store/mail/records.ts | 147 + lib/store/mail/schema.ts | 65 + lib/store/mail/search.ts | 59 + lib/store/mail/sync.ts | 323 + lib/store/sqlite-version.ts | 3 + lib/store/storage.ts | 73 + lib/store/worker/host.ts | 168 + lib/store/worker/protocol.ts | 76 + lib/store/worker/worker.ts | 147 + next.config.ts | 5 + package.json | 4 +- proxy.ts | 2 +- scripts/build-store-worker.mjs | 46 + scripts/fetch-sqlite3mc.mjs | 79 + scripts/lite/lib.mjs | 3 + vendor/sqlite3mc/2.6.1-3.54.0/LICENSE | 21 + vendor/sqlite3mc/2.6.1-3.54.0/sqlite3.mjs | 14727 ++++++++++++++++++ vendor/sqlite3mc/2.6.1-3.54.0/sqlite3.wasm | Bin 0 -> 1072003 bytes 36 files changed, 18299 insertions(+), 3 deletions(-) create mode 100644 lib/store/__tests__/helpers/fake-jmap.ts create mode 100644 lib/store/__tests__/helpers/node-adapter.ts create mode 100644 lib/store/__tests__/mail-sync.test.ts create mode 100644 lib/store/adapters/wasm.ts create mode 100644 lib/store/area.ts create mode 100644 lib/store/areas.ts create mode 100644 lib/store/client/keys.ts create mode 100644 lib/store/client/store-client.ts create mode 100644 lib/store/engine/account-engine.ts create mode 100644 lib/store/engine/changes.ts create mode 100644 lib/store/engine/core-schema.ts create mode 100644 lib/store/engine/responder.ts create mode 100644 lib/store/engine/transport.ts create mode 100644 lib/store/mail/index.ts create mode 100644 lib/store/mail/methods.ts create mode 100644 lib/store/mail/records.ts create mode 100644 lib/store/mail/schema.ts create mode 100644 lib/store/mail/search.ts create mode 100644 lib/store/mail/sync.ts create mode 100644 lib/store/sqlite-version.ts create mode 100644 lib/store/storage.ts create mode 100644 lib/store/worker/host.ts create mode 100644 lib/store/worker/protocol.ts create mode 100644 lib/store/worker/worker.ts create mode 100644 scripts/build-store-worker.mjs create mode 100644 scripts/fetch-sqlite3mc.mjs create mode 100644 vendor/sqlite3mc/2.6.1-3.54.0/LICENSE create mode 100644 vendor/sqlite3mc/2.6.1-3.54.0/sqlite3.mjs create mode 100644 vendor/sqlite3mc/2.6.1-3.54.0/sqlite3.wasm diff --git a/.dockerignore b/.dockerignore index e61ff58d2..060be0c1f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -12,11 +12,16 @@ scripts/ !scripts/lite/ !scripts/build-plugin-sandbox.mjs !scripts/build-pst-worker.mjs +!scripts/build-store-worker.mjs +!scripts/fetch-sqlite3mc.mjs !scripts/healthcheck.mjs !scripts/sourcemaps.mjs !scripts/standalone-guard.mjs # Built in the image by scripts/build-plugin-sandbox.mjs public/plugin-sandbox-assets/ +# Built in the image by scripts/build-store-worker.mjs +public/store-worker.js +public/sqlite3mc/ TODO.md *.md !README.md diff --git a/.gitignore b/.gitignore index 0914d4091..67e25a46d 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,9 @@ /public/plugin-sandbox-assets/ # scripts/build-pst-worker.mjs /public/pst-worker.js +# scripts/build-store-worker.mjs +/public/store-worker.js +/public/sqlite3mc/ # production /build diff --git a/Dockerfile b/Dockerfile index ed2713b1f..d8d5081c8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -35,6 +35,7 @@ RUN rm -rf "app/api/dev-jmap" && \ \( -name __tests__ -o -name "*.test.ts" -o -name "*.test.tsx" \) \ -prune -exec rm -rf {} \; && \ node scripts/build-pst-worker.mjs && \ + node scripts/build-store-worker.mjs && \ node scripts/build-plugin-sandbox.mjs && \ npx next build --webpack && \ node scripts/standalone-guard.mjs diff --git a/lib/store/__tests__/helpers/fake-jmap.ts b/lib/store/__tests__/helpers/fake-jmap.ts new file mode 100644 index 000000000..9aa917be5 --- /dev/null +++ b/lib/store/__tests__/helpers/fake-jmap.ts @@ -0,0 +1,285 @@ +/** + * A small JMAP server for engine tests, modelled on how Stalwart really + * behaves (local-data/desktop-offline-facts-parts/stalwart-changes-prf.md): + * a 5000 cap on /changes pages, fixed log ranges, the three kinds of state + * refusal, 500 ids per Email/get, 16 calls per request, `ids: null` giving the + * first 500. + */ + +import { TransportError, type JmapResponse, type JmapTransport, type MethodCall, type MethodResponse, type StoreSession } from '../../engine/transport'; + +export type FakeEmail = Record & { id: string }; + +export const JMAP_ACCOUNT = 'acc1'; + +export const FAKE_SESSION: StoreSession = { + apiUrl: 'https://fake.test/jmap/', + downloadUrl: 'https://fake.test/d/{accountId}/{blobId}/{name}?type={type}', + uploadUrl: 'https://fake.test/u/{accountId}', + primaryAccounts: { 'urn:ietf:params:jmap:mail': JMAP_ACCOUNT }, + accounts: { [JMAP_ACCOUNT]: { name: 'a@example.org', isPersonal: true, accountCapabilities: { 'urn:ietf:params:jmap:mail': {} } } }, + capabilities: { 'urn:ietf:params:jmap:mail': {} }, +}; + +type ChangeKind = 'created' | 'updated' | 'destroyed'; + +interface Log { kind: ChangeKind; id: string } + +class Collection { + log: Log[] = []; + /** Below this log position, states are refused as truncated. */ + truncatedBelow = 0; + + state(): string { + return `s${this.log.length}`; + } + + push(kind: ChangeKind, id: string): void { + this.log.push({ kind, id }); + } + + changes(since: string, maxChanges: number): { created: string[]; updated: string[]; destroyed: string[]; newState: string; hasMoreChanges: boolean } { + const from = Number(since.slice(1)); + const slice = this.log.slice(from, from + maxChanges); + const created = new Set(); + const updated = new Set(); + const destroyed = new Set(); + for (const { kind, id } of slice) { + if (kind === 'created') created.add(id); + else if (kind === 'updated') { + if (!created.has(id)) updated.add(id); + } else if (created.delete(id)) { + // Created and destroyed in the window: nowhere. + } else { + updated.delete(id); + destroyed.add(id); + } + } + const end = from + slice.length; + return { + created: [...created], + updated: [...updated], + destroyed: [...destroyed], + newState: `s${end}`, + hasMoreChanges: end < this.log.length, + }; + } +} + +export interface FakeOptions { + /** Ten mailboxes by default, a single Inbox here unless given. */ + mailboxes?: Array & { id: string }>; +} + +export class FakeJmap implements JmapTransport { + readonly emails = new Map(); + readonly mailboxes = new Map & { id: string }>(); + readonly threads = new Map(); + readonly coll = { Email: new Collection(), Mailbox: new Collection(), Thread: new Collection() }; + readonly calls: MethodCall[] = []; + requests = 0; + /** Set to make every request fail like a network drop. */ + offline = false; + /** Fails the next N requests as busy. */ + busyNext = 0; + /** Called before each request; can change data while a sync is running. */ + beforeRequest: ((n: number) => void) | null = null; + nextId = 1; + + constructor(options: FakeOptions = {}) { + for (const box of options.mailboxes ?? [{ id: 'inbox', name: 'Inbox', role: 'inbox', totalEmails: 0, unreadEmails: 0 }]) { + this.mailboxes.set(box.id, box); + this.coll.Mailbox.push('created', box.id); + } + } + + addEmail(partial: Partial = {}): FakeEmail { + const n = this.nextId++; + const id = partial.id ?? `e${n}`; + const threadId = (partial.threadId as string) ?? `t${n}`; + const email: FakeEmail = { + id, + threadId, + mailboxIds: { inbox: true }, + keywords: {}, + size: 100, + receivedAt: new Date(Date.UTC(2026, 9, 1) + n * 1000).toISOString(), + sentAt: new Date(Date.UTC(2026, 9, 1) + n * 1000).toISOString(), + from: [{ name: `Sender ${n}`, email: `s${n}@example.org` }], + to: [{ name: 'A', email: 'a@example.org' }], + cc: [], + subject: `Subject ${n}`, + preview: `Preview ${n}`, + hasAttachment: false, + blobId: `b${n}`, + textBody: [{ partId: '1', type: 'text/plain' }], + htmlBody: [{ partId: '1', type: 'text/plain' }], + bodyValues: { 1: { value: `Body of message ${n}`, isTruncated: false, isEncodingProblem: false } }, + attachments: [], + messageId: [`m${n}@example.org`], + headers: [], + bodyStructure: { partId: '1', type: 'text/plain' }, + ...partial, + }; + this.emails.set(id, email); + this.coll.Email.push('created', id); + const members = this.threads.get(threadId); + if (members) { + members.push(id); + this.coll.Thread.push('updated', threadId); + } else { + this.threads.set(threadId, [id]); + this.coll.Thread.push('created', threadId); + } + return email; + } + + updateEmail(id: string, patch: Partial): void { + Object.assign(this.emails.get(id) as FakeEmail, patch); + this.coll.Email.push('updated', id); + } + + destroyEmail(id: string): void { + const email = this.emails.get(id); + if (!email) return; + this.emails.delete(id); + this.coll.Email.push('destroyed', id); + const threadId = email.threadId as string; + const members = (this.threads.get(threadId) ?? []).filter((m) => m !== id); + if (members.length > 0) { + this.threads.set(threadId, members); + this.coll.Thread.push('updated', threadId); + } else { + this.threads.delete(threadId); + this.coll.Thread.push('destroyed', threadId); + } + } + + /** The next /changes from before this point gets cannotCalculateChanges. */ + truncateLog(type: 'Email' | 'Mailbox' | 'Thread'): void { + this.coll[type].truncatedBelow = this.coll[type].log.length; + } + + private queryIds(args: Record): string[] { + const filter = args.filter as { after?: string; inMailbox?: string } | undefined; + let list = [...this.emails.values()]; + if (filter?.after) list = list.filter((e) => Date.parse(String(e.receivedAt)) >= Date.parse(filter.after as string)); + if (filter?.inMailbox) list = list.filter((e) => (e.mailboxIds as Record)[filter.inMailbox as string]); + list.sort((a, b) => Date.parse(String(b.receivedAt)) - Date.parse(String(a.receivedAt))); + return list.map((e) => e.id); + } + + private handle(name: string, args: Record): Record { + switch (name) { + case 'Mailbox/get': { + const ids = args.ids as string[] | null; + const all = [...this.mailboxes.values()]; + const list = ids ? all.filter((b) => ids.includes(b.id)) : all; + return { accountId: args.accountId, state: this.coll.Mailbox.state(), list, notFound: [] }; + } + case 'Email/query': { + const ids = this.queryIds(args); + const position = Number(args.position ?? 0); + const limit = Number(args.limit ?? 5000) || 5000; + return { + accountId: args.accountId, + queryState: this.coll.Email.state(), + canCalculateChanges: true, + position, + ids: ids.slice(position, position + Math.min(limit, 5000)), + ...(args.calculateTotal ? { total: ids.length } : {}), + }; + } + case 'Email/get': { + const ids = args.ids as string[] | null; + if (ids && ids.length > 500) throw Object.assign(new Error('requestTooLarge'), { jmapError: 'requestTooLarge' }); + const chosen = ids ?? [...this.emails.keys()].slice(0, 500); + const properties = args.properties as string[] | undefined; + const list: Record[] = []; + const notFound: string[] = []; + for (const id of chosen) { + const email = this.emails.get(id); + if (!email) { + notFound.push(id); + continue; + } + if (!properties) { + list.push({ ...email }); + continue; + } + const out: Record = { id }; + for (const p of properties) if (p in email) out[p] = email[p]; + list.push(out); + } + return { accountId: args.accountId, state: this.coll.Email.state(), list, notFound }; + } + case 'Thread/get': { + const ids = (args.ids ?? []) as string[]; + return { + accountId: args.accountId, + state: this.coll.Thread.state(), + list: ids.filter((id) => this.threads.has(id)).map((id) => ({ id, emailIds: this.threads.get(id) })), + notFound: ids.filter((id) => !this.threads.has(id)), + }; + } + case 'Email/changes': + case 'Mailbox/changes': + case 'Thread/changes': { + const type = name.split('/')[0] as 'Email' | 'Mailbox' | 'Thread'; + const coll = this.coll[type]; + 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) }; + } + default: + throw Object.assign(new Error(name), { jmapError: 'unknownMethod' }); + } + } + + async request(calls: MethodCall[]): Promise { + this.requests += 1; + this.beforeRequest?.(this.requests); + if (this.offline) throw new TransportError('network', 'Failed to fetch'); + if (this.busyNext > 0) { + this.busyNext -= 1; + throw new TransportError('rate', 'Server busy', 429, undefined, 1); + } + if (calls.length > 16) throw new TransportError('http', 'too many calls', 400, 'urn:ietf:params:jmap:error:limit'); + const done: MethodResponse[] = []; + for (const call of calls) { + this.calls.push(call); + const [name, raw, id] = call; + const args: Record = {}; + for (const [key, value] of Object.entries(raw)) { + if (key.startsWith('#')) { + const ref = value as { resultOf: string; path: string }; + const source = done.find(([, , callId]) => callId === ref.resultOf); + args[key.slice(1)] = source?.[1][ref.path.slice(1)]; + } else { + args[key] = value; + } + } + try { + done.push([name, this.handle(name, args), id]); + } catch (error) { + const type = (error as { jmapError?: string }).jmapError; + if (!type) throw error; + done.push(['error', { type }, id]); + } + } + return { methodResponses: done }; + } + + async download(): Promise { + return new Uint8Array(); + } + + async upload(): Promise<{ blobId: string; type: string; size: number }> { + return { blobId: 'uploaded', type: 'application/octet-stream', size: 0 }; + } +} diff --git a/lib/store/__tests__/helpers/node-adapter.ts b/lib/store/__tests__/helpers/node-adapter.ts new file mode 100644 index 000000000..421324cfa --- /dev/null +++ b/lib/store/__tests__/helpers/node-adapter.ts @@ -0,0 +1,36 @@ +/** The storage adapter for tests: real SQLite with FTS5 through node:sqlite. */ + +import { DatabaseSync, type SQLInputValue } from 'node:sqlite'; +import { runTransaction, type SqlRow, type SqlValue, type StorageAdapter } from '../../storage'; + +export function openMemoryStore(): StorageAdapter { + return adapterFor(new DatabaseSync(':memory:')); +} + +export function openFileStore(file: string): StorageAdapter { + return adapterFor(new DatabaseSync(file)); +} + +function adapterFor(db: DatabaseSync): StorageAdapter { + const tx = { depth: 0 }; + const prepared = new Map>(); + const statement = (sql: string) => { + let stmt = prepared.get(sql); + if (!stmt) { + stmt = db.prepare(sql); + prepared.set(sql, stmt); + } + return stmt; + }; + return { + exec: (sql) => db.exec(sql), + run: (sql, params = []) => { + const result = statement(sql).run(...(params as SQLInputValue[])); + return { changes: Number(result.changes), lastInsertRowid: Number(result.lastInsertRowid) }; + }, + all: (sql: string, params: readonly SqlValue[] = []) => statement(sql).all(...(params as SQLInputValue[])) as T[], + get: (sql: string, params: readonly SqlValue[] = []) => statement(sql).get(...(params as SQLInputValue[])) as T | undefined, + transaction: (fn) => runTransaction(tx, (sql) => db.exec(sql), fn), + close: () => db.close(), + }; +} diff --git a/lib/store/__tests__/mail-sync.test.ts b/lib/store/__tests__/mail-sync.test.ts new file mode 100644 index 000000000..dd8b70728 --- /dev/null +++ b/lib/store/__tests__/mail-sync.test.ts @@ -0,0 +1,390 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { DEFAULT_SCOPE, type StoreEvent } from '../area'; +import { AccountEngine } from '../engine/account-engine'; +import { runChangesLoop } from '../engine/changes'; +import { TransportError, type MethodCall } from '../engine/transport'; +import { mailArea } from '../mail'; +import { FAKE_SESSION, FakeJmap, JMAP_ACCOUNT } from './helpers/fake-jmap'; +import { openFileStore, openMemoryStore } from './helpers/node-adapter'; + +const EVERYTHING = { ...DEFAULT_SCOPE, days: null }; +const INBOX = { inMailbox: 'inbox' }; + +function engineFor(server: FakeJmap, db = openMemoryStore(), emit?: (e: StoreEvent) => void) { + return new AccountEngine({ + account: 'a@fake.test', + db, + session: FAKE_SESSION, + transport: server, + areas: [mailArea], + scope: EVERYTHING, + emit, + now: () => Date.UTC(2026, 10, 3), + sleep: async () => {}, + }); +} + +function count(db: ReturnType, table: string): number { + return Number(db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM ${table}`)?.n); +} + +function callsNamed(server: FakeJmap, name: string): MethodCall[] { + return server.calls.filter(([n]) => n === name); +} + +describe('full sync', () => { + it('stores every message, thread and body across pages', async () => { + const server = new FakeJmap(); + for (let i = 0; i < 1234; i += 1) server.addEmail(); + const db = openMemoryStore(); + await engineFor(server, db).requestSync(); + expect(count(db, 'email')).toBe(1234); + expect(count(db, 'email_body')).toBe(1234); + expect(count(db, 'thread')).toBe(1234); + expect(count(db, 'email_mailbox')).toBe(1234); + }); + + it('captures the state before paging, so a message landing mid-sync is not lost', async () => { + const server = new FakeJmap(); + for (let i = 0; i < 600; i += 1) server.addEmail(); + let landed = false; + server.beforeRequest = (n) => { + if (n === 4 && !landed) { + landed = true; + server.addEmail({ id: 'late' }); + } + }; + const db = openMemoryStore(); + await engineFor(server, db).requestSync(); + expect(landed).toBe(true); + expect(db.get('SELECT id FROM email WHERE id = ?', ['late'])).toBeTruthy(); + }); + + it('keeps only the chosen window', async () => { + const server = new FakeJmap(); + server.addEmail({ id: 'old', receivedAt: '2020-01-01T00:00:00Z' }); + server.addEmail({ id: 'new', receivedAt: '2026-10-30T00:00:00Z' }); + const db = openMemoryStore(); + const engine = new AccountEngine({ + account: 'a@fake.test', db, session: FAKE_SESSION, transport: server, areas: [mailArea], + scope: { ...DEFAULT_SCOPE, days: 30 }, now: () => Date.UTC(2026, 10, 3), sleep: async () => {}, + }); + await engine.requestSync(); + expect(db.all('SELECT id FROM email').map((r) => r.id)).toEqual(['new']); + }); +}); + +describe('persisted state', () => { + it('survives a reload and continues from the saved state with a delta', async () => { + const dir = mkdtempSync(path.join(os.tmpdir(), 'store-')); + const file = path.join(dir, 's.db'); + try { + const server = new FakeJmap(); + for (let i = 0; i < 50; i += 1) server.addEmail(); + const first = openFileStore(file); + await engineFor(server, first).requestSync(); + first.close(); + + server.addEmail({ id: 'after-reload' }); + server.calls.length = 0; + const second = openFileStore(file); + await engineFor(server, second).requestSync(); + expect(second.get('SELECT id FROM email WHERE id = ?', ['after-reload'])).toBeTruthy(); + expect(count(second, 'email')).toBe(51); + // A delta, not another full listing. + expect(callsNamed(server, 'Email/query')).toHaveLength(0); + expect(callsNamed(server, 'Email/changes').length).toBeGreaterThan(0); + second.close(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('resumes an interrupted full sync from its cursor', async () => { + const dir = mkdtempSync(path.join(os.tmpdir(), 'store-')); + const file = path.join(dir, 's.db'); + try { + const server = new FakeJmap(); + for (let i = 0; i < 4500; i += 1) server.addEmail(); + const first = openFileStore(file); + // Goes down after the first page of the list. + server.beforeRequest = (n) => { + if (n === 4) server.offline = true; + }; + await engineFor(server, first).requestSync(); + const listed = count(first, 'email'); + expect(listed).toBeGreaterThan(0); + expect(listed).toBeLessThan(4500); + first.close(); + + server.offline = false; + server.beforeRequest = null; + server.calls.length = 0; + const second = openFileStore(file); + await engineFor(server, second).requestSync(); + expect(count(second, 'email')).toBe(4500); + const firstQuery = callsNamed(server, 'Email/query')[0]; + expect(firstQuery[1].position).toBe(listed); + second.close(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe('deltas', () => { + it('applies created, updated and destroyed, and leaves bodies alone on an update', async () => { + const server = new FakeJmap(); + const a = server.addEmail(); + const b = server.addEmail(); + const db = openMemoryStore(); + const engine = engineFor(server, db); + await engine.requestSync(); + + server.updateEmail(a.id, { keywords: { $seen: true } }); + server.destroyEmail(b.id); + server.addEmail({ id: 'c' }); + await engine.requestSync(); + + expect(db.all('SELECT id FROM email ORDER BY id').map((r) => r.id)).toEqual(['c', a.id]); + expect(db.get('SELECT 1 AS x FROM email_keyword WHERE keyword = ?', ['$seen'])).toBeTruthy(); + const kept = db.get<{ body_state: number }>('SELECT body_state FROM email WHERE id = ?', [a.id]); + expect(kept?.body_state).toBe(1); + // The destroyed message left nothing behind in the search index. + expect(db.all("SELECT rowid FROM email_fts_word WHERE email_fts_word MATCH ?", [`"Subject" `]).length).toBe(2); + }); + + it('pages through more changes than one call gives', async () => { + const server = new FakeJmap(); + server.addEmail(); + const db = openMemoryStore(); + const engine = engineFor(server, db); + await engine.requestSync(); + server.calls.length = 0; + for (let i = 0; i < 1500; i += 1) server.addEmail(); + await engine.requestSync(); + expect(count(db, 'email')).toBe(1501); + expect(callsNamed(server, 'Email/changes').length).toBeGreaterThanOrEqual(3); + const page = callsNamed(server, 'Email/changes')[0][1]; + expect(page.maxChanges).toBe(500); + }); + + it('sends sinceState last, because a malformed one otherwise fails the whole request on Stalwart', async () => { + const server = new FakeJmap(); + server.addEmail(); + const engine = engineFor(server); + await engine.requestSync(); + server.addEmail(); + server.calls.length = 0; + await engine.requestSync(); + const keys = Object.keys(callsNamed(server, 'Email/changes')[0][1]); + expect(keys[keys.length - 1]).toBe('sinceState'); + }); + + it('keeps going while behind the pushed state, since hasMoreChanges false does not mean caught up', async () => { + const server = new FakeJmap(); + server.addEmail(); + const applied: string[] = []; + let wrote = false; + const outcome = await runChangesLoop({ + transport: server, + type: 'Email', + jmap: JMAP_ACCOUNT, + using: [], + since: 's0', + target: 's2', + apply: async (page) => { + applied.push(page.newState); + if (!wrote) { + wrote = true; + server.addEmail(); + } + }, + }); + expect(outcome).toMatchObject({ outcome: 'caughtUp', state: 's2' }); + expect(applied).toEqual(['s1', 's2']); + }); + + it('stops when the state stands still even though the push named a newer one', async () => { + const server = new FakeJmap(); + server.addEmail(); + const outcome = await runChangesLoop({ + transport: server, type: 'Email', jmap: JMAP_ACCOUNT, using: [], since: 's1', target: 's9', + apply: async () => {}, + }); + expect(outcome.outcome).toBe('caughtUp'); + expect(server.requests).toBe(1); + }); +}); + +describe('resync', () => { + it('starts Email over on cannotCalculateChanges, keeps bodies, and drops what is gone', async () => { + const server = new FakeJmap(); + const keep = server.addEmail({ id: 'keep' }); + server.addEmail({ id: 'gone' }); + const db = openMemoryStore(); + const engine = engineFor(server, db); + await engine.requestSync(); + + server.destroyEmail('gone'); + server.updateEmail(keep.id, { keywords: { $flagged: true } }); + server.truncateLog('Email'); + server.calls.length = 0; + await engine.requestSync(); + + expect(callsNamed(server, 'Email/query').length).toBeGreaterThan(0); + expect(db.all('SELECT id FROM email').map((r) => r.id)).toEqual(['keep']); + expect(db.get('SELECT 1 AS x FROM email_keyword WHERE keyword = ?', ['$flagged'])).toBeTruthy(); + // The body wasn't fetched a second time. + const bodyFetches = callsNamed(server, 'Email/get').filter(([, a]) => a.fetchTextBodyValues); + expect(bodyFetches).toHaveLength(0); + expect(count(db, 'email_body')).toBe(1); + }); + + it.each([ + ['invalidArguments', { jmapError: 'invalidArguments' }], + ['cannotCalculateChanges', { jmapError: 'cannotCalculateChanges' }], + ])('treats %s as a reason to start over', async (_name, thrown) => { + const server = new FakeJmap(); + const original = server.request.bind(server); + let refused = false; + server.request = async (calls) => { + if (!refused && calls[0][0] === 'Email/changes') { + refused = true; + return { methodResponses: [['error', { type: thrown.jmapError }, 'c']] }; + } + return original(calls); + }; + const outcome = await runChangesLoop({ transport: server, type: 'Email', jmap: JMAP_ACCOUNT, using: [], since: 's0', apply: async () => {} }); + expect(outcome.outcome).toBe('resync'); + }); + + it('treats an HTTP 400 notRequest as a reason to start over, not as an outage', async () => { + const server = new FakeJmap(); + server.request = async () => { + throw new TransportError('http', 'bad', 400, 'urn:ietf:params:jmap:error:notRequest'); + }; + const outcome = await runChangesLoop({ transport: server, type: 'Email', jmap: JMAP_ACCOUNT, using: [], since: 's0', apply: async () => {} }); + expect(outcome.outcome).toBe('resync'); + }); + + it('never sends a stored state a server cannot have sent', async () => { + const server = new FakeJmap(); + const outcome = await runChangesLoop({ transport: server, type: 'Email', jmap: JMAP_ACCOUNT, using: [], since: 'bad state\n', apply: async () => {} }); + expect(outcome.outcome).toBe('resync'); + expect(server.requests).toBe(0); + }); +}); + +describe('when the server is busy or gone', () => { + it('backs off and carries on after a 429', async () => { + const server = new FakeJmap(); + server.addEmail(); + server.busyNext = 2; + const db = openMemoryStore(); + await engineFor(server, db).requestSync(); + expect(count(db, 'email')).toBe(1); + }); + + it('ends the run quietly when the network is gone, and picks up when back', async () => { + const server = new FakeJmap(); + server.addEmail(); + server.offline = true; + const events: StoreEvent[] = []; + const db = openMemoryStore(); + const engine = engineFor(server, db, (e) => events.push(e)); + await engine.requestSync(); + expect(count(db, 'email')).toBe(0); + expect(events.some((e) => e.type === 'error')).toBe(false); + server.offline = false; + await engine.requestSync(); + expect(count(db, 'email')).toBe(1); + }); + + it('tells the page about new mail only for messages it had not seen', async () => { + const server = new FakeJmap(); + server.addEmail(); + const events: StoreEvent[] = []; + const engine = engineFor(server, openMemoryStore(), (e) => events.push(e)); + await engine.requestSync(); + expect(events.filter((e) => e.type === 'created')).toHaveLength(0); + server.addEmail({ id: 'fresh' }); + await engine.requestSync(); + expect(events.filter((e) => e.type === 'created')).toEqual([ + { type: 'created', account: 'a@fake.test', jmap: JMAP_ACCOUNT, collection: 'Email', ids: ['fresh'] }, + ]); + }); +}); + +describe('answers from the store', () => { + async function seeded() { + const server = new FakeJmap(); + server.addEmail({ id: 'one', subject: 'Zahlungserinnerung Rechnungsbetrag', keywords: { $seen: true } }); + server.addEmail({ id: 'two', subject: 'Hello', mailboxIds: { inbox: true }, bodyValues: { 1: { value: 'See you Tuesday' } } }); + const db = openMemoryStore(); + const engine = engineFor(server, db); + await engine.requestSync(); + return { engine, db, server }; + } + + it('gives every call its own answer, in order, with a per-call error for what it cannot answer', async () => { + const { engine } = await seeded(); + const answer = await engine.answer([ + ['Email/query', { filter: INBOX, sort: [{ property: 'receivedAt', isAscending: false }], calculateTotal: true }, 'a'], + ['SieveScript/get', {}, 'b'], + ['Email/get', { '#ids': { resultOf: 'a', name: 'Email/query', path: '/ids' }, properties: ['id', 'subject'] }, 'c'], + ]); + expect(answer?.methodResponses.map(([name, , id]) => [name, id])).toEqual([ + ['Email/query', 'a'], ['error', 'b'], ['Email/get', 'c'], + ]); + expect(answer?.methodResponses[0][1]).toMatchObject({ ids: ['two', 'one'], total: 2, position: 0 }); + expect(answer?.methodResponses[1][1].type).toBe('serverUnavailable'); + expect(answer?.methodResponses[2][1].list).toEqual([ + { id: 'two', subject: 'Hello' }, + { id: 'one', subject: 'Zahlungserinnerung Rechnungsbetrag' }, + ]); + }); + + it('answers null when nothing in the request could be answered', async () => { + const { engine } = await seeded(); + expect(await engine.answer([['SieveScript/get', {}, 'a']])).toBeNull(); + }); + + it('finds a German compound from inside the word, and a word start', async () => { + const { engine } = await seeded(); + const search = async (text: string) => (await engine.answer([['Email/query', { filter: { text } }, 'q']]))?.methodResponses[0][1].ids; + expect(await search('rechnung')).toEqual(['one']); + expect(await search('erinnerung')).toEqual(['one']); + expect(await search('zahl*')).toEqual(['one']); + expect(await search('tuesday')).toEqual(['two']); + expect(await search('nichtda')).toEqual([]); + }); + + it('reads keyword sort the way the client sends it for that server', async () => { + const { engine } = await seeded(); + const sorted = async () => (await engine.answer([['Email/query', { + filter: INBOX, + sort: [{ property: 'hasKeyword', keyword: '$seen', isAscending: false }], + }, 'q']]))?.methodResponses[0][1].ids; + expect(await sorted()).toEqual(['one', 'two']); + engine.setInvertedKeywordSort(true); + expect(await sorted()).toEqual(['two', 'one']); + }); + + it('does not make up a message with empty text when the body is not kept', async () => { + const { engine, db } = await seeded(); + db.run('DELETE FROM email_body'); + const answer = await engine.answer([['Email/get', { ids: ['one'], fetchTextBodyValues: true }, 'g']]); + expect(answer?.methodResponses[0][1]).toMatchObject({ list: [], notFound: ['one'] }); + }); + + it('answers with the real server state, never a made-up one', async () => { + const { engine, server } = await seeded(); + const answer = await engine.answer([['Email/get', { ids: ['one'], properties: ['id'] }, 'g']]); + expect(answer?.methodResponses[0][1].state).toBe(server.coll.Email.state()); + }); +}); diff --git a/lib/store/adapters/wasm.ts b/lib/store/adapters/wasm.ts new file mode 100644 index 000000000..e85bf14f4 --- /dev/null +++ b/lib/store/adapters/wasm.ts @@ -0,0 +1,197 @@ +/** + * SQLite3MultipleCiphers on OPFS, for the owner tab's worker. + * + * Rules that come from bugs reproduced in 2.6.1, see + * local-data/desktop-offline-facts-parts/sqlite-wasm.md section 5: + * - PRAGMA key is the very first thing on a connection. Reading first deletes + * a hot journal (#292). + * - The page size must be 8192. On 4096, rollback doesn't work (#293). + * - No PRAGMA rekey (#291, #294). The key is random and only ever re-wrapped. + * - Always the multipleciphers VFS. On plain opfs-sahpool, PRAGMA key throws + * and the connection still writes in the clear if that's ignored. + */ + +import { runTransaction, type SqlRow, type SqlValue, type StorageAdapter } from '../storage'; + +const VFS = 'multipleciphers-opfs-sahpool'; +const INIT_TIMEOUT_MS = 30_000; +const REQUIRED_PAGE_SIZE = 8192; +const POOL_DIRECTORY = '.bulwark-store'; +const INITIAL_CAPACITY = 16; +/** A database and its journal, plus room for one more. */ +const SLOTS_PER_DATABASE = 3; + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +interface Pool { + getFileNames(): string[]; + getCapacity(): number; + getFileCount(): number; + addCapacity(n: number): Promise; + unlink(name: string): boolean; + wipeFiles(): Promise; + pauseVfs(): void; + unpauseVfs(): Promise; + isPaused(): boolean; +} + +export class StoreKeyError extends Error { + constructor() { + super('The local store could not be opened with this key'); + this.name = 'StoreKeyError'; + } +} + +function withTimeout(promise: Promise, ms: number, what: string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`${what} timed out`)), ms); + promise.then((value) => { + clearTimeout(timer); + resolve(value); + }, (error) => { + clearTimeout(timer); + reject(error); + }); + }); +} + +class WasmAdapter implements StorageAdapter { + private readonly tx = { depth: 0 }; + private readonly statements = new Map(); + + constructor(private readonly sqlite3: any, private readonly db: any) {} + + exec(sql: string): void { + this.db.exec(sql); + } + + private statement(sql: string): any { + let stmt = this.statements.get(sql); + if (!stmt) { + stmt = this.db.prepare(sql); + this.statements.set(sql, stmt); + } + return stmt; + } + + private bindAndRun(sql: string, params: readonly SqlValue[] | undefined, read: (stmt: any) => T): T { + const stmt = this.statement(sql); + try { + if (params && params.length > 0) stmt.bind(params as SqlValue[]); + return read(stmt); + } finally { + stmt.reset(true); + } + } + + run(sql: string, params?: readonly SqlValue[]): { changes: number; lastInsertRowid: number } { + this.bindAndRun(sql, params, (stmt) => { + while (stmt.step()) { /* no rows wanted */ } + }); + return { + changes: Number(this.db.changes()), + lastInsertRowid: Number(this.sqlite3.capi.sqlite3_last_insert_rowid(this.db.pointer)), + }; + } + + all(sql: string, params?: readonly SqlValue[]): T[] { + return this.bindAndRun(sql, params, (stmt) => { + const rows: T[] = []; + while (stmt.step()) rows.push(stmt.get({}) as T); + return rows; + }); + } + + get(sql: string, params?: readonly SqlValue[]): T | undefined { + return this.bindAndRun(sql, params, (stmt) => (stmt.step() ? stmt.get({}) as T : undefined)); + } + + transaction(fn: () => T): T { + return runTransaction(this.tx, (sql) => this.db.exec(sql), fn); + } + + close(): void { + for (const stmt of this.statements.values()) stmt.finalize(); + this.statements.clear(); + this.db.close(); + } +} + +/** The module and the pool, once. Only the owner gets here. */ +export class WasmStorage { + private constructor(private readonly sqlite3: any, private readonly pool: Pool) {} + + /** + * Call only while holding the owner lock. The holder before may still be + * letting go, so a refusal is retried for a while. + */ + static async install(moduleUrl: string): Promise { + const { default: init } = await import(/* webpackIgnore: true */ moduleUrl) as { default: (o: unknown) => Promise }; + // A failed wasm load never settles the promise from the module's init. + const sqlite3 = await withTimeout(init({ print() {}, printErr() {} }), INIT_TIMEOUT_MS, 'Loading the store engine'); + let lastError: unknown; + for (let attempt = 0; attempt < 20; attempt += 1) { + try { + const pool = await sqlite3.installOpfsSAHPoolVfs({ + name: 'opfs-sahpool', + directory: POOL_DIRECTORY, + initialCapacity: INITIAL_CAPACITY, + preserveOnInitFailure: true, + forceReinitIfPreviouslyFailed: true, + }) as Pool; + return new WasmStorage(sqlite3, pool); + } catch (error) { + lastError = error; + await new Promise((resolve) => setTimeout(resolve, 100 + attempt * 50)); + } + } + throw lastError; + } + + /** Opens one account's database. `keyHex` is 32 random bytes in hex. */ + async open(file: string, keyHex: string): Promise { + if (!/^\/a[0-9a-f]+\.db$/.test(file)) throw new Error('Store file names carry no account data'); + if (!/^[0-9a-f]{64}$/.test(keyHex)) throw new Error('A store key is 32 bytes of hex'); + const needed = this.pool.getFileCount() + SLOTS_PER_DATABASE; + if (this.pool.getCapacity() < needed) await this.pool.addCapacity(needed - this.pool.getCapacity()); + const db = new this.sqlite3.oo1.DB({ filename: file, vfs: VFS, flags: 'c' }); + try { + db.exec(`PRAGMA key = "raw:${keyHex}"`); + try { + db.selectValue('SELECT count(*) FROM sqlite_master'); + } catch { + throw new StoreKeyError(); + } + const pageSize = Number(db.selectValue('PRAGMA page_size')); + if (pageSize !== REQUIRED_PAGE_SIZE) throw new Error(`Refusing an unsafe page size: ${pageSize}`); + db.exec('PRAGMA temp_store = MEMORY'); + db.exec('PRAGMA foreign_keys = OFF'); + db.exec('PRAGMA synchronous = NORMAL'); + } catch (error) { + db.close(); + throw error; + } + return new WasmAdapter(this.sqlite3, db); + } + + remove(file: string): boolean { + let removed = false; + for (const name of this.pool.getFileNames()) { + if (name === file || name === `${file}-journal`) removed = this.pool.unlink(name) || removed; + } + return removed; + } + + /** Lets go of the OPFS handles. Every database must be closed first. */ + pause(): void { + this.pool.pauseVfs(); + } + + async unpause(): Promise { + await this.pool.unpauseVfs(); + } + + files(): string[] { + return this.pool.getFileNames(); + } +} diff --git a/lib/store/area.ts b/lib/store/area.ts new file mode 100644 index 000000000..fa757e37d --- /dev/null +++ b/lib/store/area.ts @@ -0,0 +1,101 @@ +/** + * What one part of the local store (mail, calendar, contacts, files, ...) + * plugs into the engine: its tables, the JMAP methods it answers while the + * server can't be reached, and how it syncs. + * + * Migration id ranges: core 1-99, mail 100-199, outbox 200-299, search + * 300-399, drafts 400-499, calendar 500-599, contacts 600-699, files 700-799, + * settings and reminders 800-899, wipe and lock 900-999. + */ + +import type { JmapTransport, MethodCall, StoreSession } from './engine/transport'; +import type { Migration, StorageAdapter } from './storage'; + +export interface StoreScope { + /** How far back mail is kept, in days. Null keeps everything. */ + days: number | null; + /** Whether attachments are kept. */ + attachments: boolean; + /** The largest attachment kept, and the total for all of them. */ + maxAttachmentBytes: number; + maxTotalBytes: number; +} + +export const DEFAULT_SCOPE: StoreScope = { + days: 90, + attachments: true, + maxAttachmentBytes: 25 * 1024 * 1024, + maxTotalBytes: 2 * 1024 * 1024 * 1024, +}; + +export interface SyncStateRow { + state: string | null; + /** The newest state the server told us about, to know when a run is caught up. */ + target: string | null; + /** The area's own phase name; null when in normal delta mode. */ + phase: string | null; + /** The area's own resume point. */ + cursor: Record | null; +} + +export type StoreEvent = + | { type: 'progress'; account: string; area: string; phase: string; done: number; total: number | null } + | { type: 'synced'; account: string; at: number } + | { type: 'changed'; account: string; collections: string[] } + | { type: 'created'; account: string; jmap: string; collection: string; ids: string[] } + | { type: 'error'; account: string; area: string; message: string }; + +export interface StoreContext { + account: string; + db: StorageAdapter; + session: StoreSession; + scope: StoreScope; + now(): number; + emit(event: StoreEvent): void; +} + +export interface SyncContext extends StoreContext { + transport: JmapTransport; + /** True once the engine is closing or lost the network: stop at the next batch. */ + stopped(): boolean; + syncState(jmap: string, type: string): SyncStateRow; + /** Call inside the transaction that writes the data, so the two can't disagree. */ + setSyncState(jmap: string, type: string, row: Partial): void; +} + +export interface Syncer { + /** A short name for progress and errors. */ + name: string; + /** The JMAP capability an account must advertise for this to run for it. */ + capability: string; + /** Runs one pass for one JMAP account. Must be safe to stop between batches and start again. */ + run(ctx: SyncContext, jmap: string): Promise; +} + +export type MethodResult = Record; + +/** Thrown by a method handler: the client gets a per-call JMAP error. */ +export class MethodError extends Error { + constructor(readonly type: string, readonly description?: string) { + super(description ?? type); + this.name = 'MethodError'; + } +} + +export interface MethodContext extends StoreContext { + /** The account's own default JMAP account for calls that don't say. */ + defaultJmap: string; + /** Whether keyword sort comparators arrive inverted from this client. */ + invertedKeywordSort: boolean; + /** Earlier responses in this request, for creation-id references. */ + createdIds: Map; +} + +export type MethodHandler = (ctx: MethodContext, args: Record, call: MethodCall) => MethodResult | Promise; + +export interface StoreArea { + name: string; + migrations: readonly Migration[]; + methods: Readonly>; + syncers: readonly Syncer[]; +} diff --git a/lib/store/areas.ts b/lib/store/areas.ts new file mode 100644 index 000000000..6bff665ca --- /dev/null +++ b/lib/store/areas.ts @@ -0,0 +1,6 @@ +/** Every part of the local store. New areas go here. */ + +import type { StoreArea } from './area'; +import { mailArea } from './mail'; + +export const STORE_AREAS: readonly StoreArea[] = [mailArea]; diff --git a/lib/store/client/keys.ts b/lib/store/client/keys.ts new file mode 100644 index 000000000..ecd4fe4c2 --- /dev/null +++ b/lib/store/client/keys.ts @@ -0,0 +1,173 @@ +/** + * The per-account database keys, as the page sees them. + * + * Each account's database has a random 32-byte key. It is only ever stored + * wrapped: AES-GCM with a non-extractable key that lives in IndexedDB, then + * any extra layers on top (the desktop app's OS keychain, app lock). Forgetting + * the record makes the file unreadable even if deleting it failed. + */ + +const DB_NAME = 'bulwark-store'; +const DB_VERSION = 1; +const KEYS = 'keys'; +const ACCOUNTS = 'accounts'; +const WRAP_ID = 'wrap'; + +export interface WrapLayer { + /** A name for the layer: 'safeStorage', 'pin', 'prf'. */ + name: string; + wrap(data: Uint8Array): Promise; + unwrap(data: Uint8Array): Promise; +} + +export interface AccountRecord { + account: string; + /** `/a.db`. */ + file: string; + iv: Uint8Array; + wrapped: Uint8Array; + /** Layers applied after the WebCrypto wrap, innermost first. */ + layers: string[]; + createdAt: number; +} + +function promisify(request: IDBRequest): Promise { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +function openDb(): Promise { + const request = indexedDB.open(DB_NAME, DB_VERSION); + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(KEYS)) db.createObjectStore(KEYS, { keyPath: 'id' }); + if (!db.objectStoreNames.contains(ACCOUNTS)) db.createObjectStore(ACCOUNTS, { keyPath: 'account' }); + }; + return promisify(request); +} + +async function withDb(fn: (db: IDBDatabase) => Promise): Promise { + const db = await openDb(); + try { + return await fn(db); + } finally { + db.close(); + } +} + +async function wrapKey(db: IDBDatabase): Promise { + const read = db.transaction(KEYS, 'readonly').objectStore(KEYS).get(WRAP_ID); + const existing = await promisify(read) as { key: CryptoKey } | undefined; + if (existing) return existing.key; + const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); + const tx = db.transaction(KEYS, 'readwrite'); + const store = tx.objectStore(KEYS); + // Another tab may have got there first: keep theirs. + const raced = await promisify(store.get(WRAP_ID)) as { key: CryptoKey } | undefined; + if (raced) return raced.key; + await promisify(store.put({ id: WRAP_ID, key })); + return key; +} + +function toHex(bytes: Uint8Array): string { + return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); +} + +function asBuffer(bytes: Uint8Array): ArrayBuffer { + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; +} + +export function newStoreFileName(): string { + return `/a${toHex(crypto.getRandomValues(new Uint8Array(8)))}.db`; +} + +export async function getAccountRecord(account: string): Promise { + return withDb(async (db) => { + const found = await promisify(db.transaction(ACCOUNTS, 'readonly').objectStore(ACCOUNTS).get(account)) as AccountRecord | undefined; + return found ?? null; + }); +} + +export async function listAccountRecords(): Promise { + return withDb((db) => promisify(db.transaction(ACCOUNTS, 'readonly').objectStore(ACCOUNTS).getAll()) as Promise); +} + +/** The account's key in hex, created on first use. Returns the record too, for the file name. */ +export async function openAccountKey(account: string, layers: readonly WrapLayer[] = []): Promise<{ keyHex: string; record: AccountRecord }> { + return withDb(async (db) => { + const wrapping = await wrapKey(db); + const found = await promisify(db.transaction(ACCOUNTS, 'readonly').objectStore(ACCOUNTS).get(account)) as AccountRecord | undefined; + if (found) { + let data = found.wrapped; + for (const name of [...found.layers].reverse()) { + const layer = layers.find((candidate) => candidate.name === name); + if (!layer) throw new StoreKeyLayerMissingError(name); + data = await layer.unwrap(data); + } + const raw = new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: asBuffer(found.iv) }, wrapping, asBuffer(data))); + const keyHex = toHex(raw); + raw.fill(0); + return { keyHex, record: found }; + } + const raw = crypto.getRandomValues(new Uint8Array(32)); + const iv = crypto.getRandomValues(new Uint8Array(12)); + let wrapped = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, wrapping, raw)); + for (const layer of layers) wrapped = new Uint8Array(await layer.wrap(wrapped)); + const record: AccountRecord = { + account, + file: newStoreFileName(), + iv, + wrapped, + layers: layers.map((layer) => layer.name), + createdAt: Date.now(), + }; + await promisify(db.transaction(ACCOUNTS, 'readwrite').objectStore(ACCOUNTS).put(record)); + const keyHex = toHex(raw); + raw.fill(0); + return { keyHex, record }; + }); +} + +/** Adds or removes wrap layers without touching the database key. */ +export async function rewrapAccountKey( + account: string, + from: readonly WrapLayer[], + to: readonly WrapLayer[], +): Promise { + await withDb(async (db) => { + const found = await promisify(db.transaction(ACCOUNTS, 'readonly').objectStore(ACCOUNTS).get(account)) as AccountRecord | undefined; + if (!found) return; + let data = found.wrapped; + for (const name of [...found.layers].reverse()) { + const layer = from.find((candidate) => candidate.name === name); + if (!layer) throw new StoreKeyLayerMissingError(name); + data = await layer.unwrap(data); + } + for (const layer of to) data = new Uint8Array(await layer.wrap(data)); + const record: AccountRecord = { ...found, wrapped: data, layers: to.map((layer) => layer.name) }; + await promisify(db.transaction(ACCOUNTS, 'readwrite').objectStore(ACCOUNTS).put(record)); + }); +} + +/** Forgets the account's key. The database file can't be read after this. */ +export async function forgetAccountKey(account: string): Promise { + await withDb((db) => promisify(db.transaction(ACCOUNTS, 'readwrite').objectStore(ACCOUNTS).delete(account)).then(() => undefined)); +} + +export class StoreKeyLayerMissingError extends Error { + constructor(readonly layer: string) { + super(`The key needs the ${layer} layer`); + this.name = 'StoreKeyLayerMissingError'; + } +} + +/** Deletes every key at sign-out. Works with no worker running, on any page. */ +export function deleteKeyDatabase(): void { + try { + indexedDB.deleteDatabase(DB_NAME); + } catch { + // Private mode or similar: nothing was stored either. + } +} diff --git a/lib/store/client/store-client.ts b/lib/store/client/store-client.ts new file mode 100644 index 000000000..2b47961a6 --- /dev/null +++ b/lib/store/client/store-client.ts @@ -0,0 +1,261 @@ +/** + * The page's side of the local store. Each tab starts its own worker, which + * waits for the owner lock. Calls go over a BroadcastChannel to whichever + * worker owns the store, including this tab's own, so a tab doesn't need to + * know whether it's the owner. A key never goes over the channel. + */ + +import type { StoreEvent, StoreScope } from '../area'; +import type { JmapResponse, MethodCall, StoreSession } from '../engine/transport'; +import { SQLITE3MC_PUBLIC_DIR } from '../sqlite-version'; +import { + RPC_CHANNEL, + type ChannelMessage, + type OpenAccountParams, + type PageToWorker, + type RpcReply, + type StoreRpc, + type StoreStatus, + type WorkerToPage, +} from '../worker/protocol'; + +const CALL_TIMEOUT_MS = 60_000; +const OWNER_WAIT_MS = 15_000; + +export class StoreOwnerGoneError extends Error { + constructor() { + super('The local store is not available in this browser window'); + this.name = 'StoreOwnerGoneError'; + } +} + +interface Pending { + resolve(value: unknown): void; + reject(error: Error): void; + timer: ReturnType; +} + +export interface StoreClientOptions { + /** NEXT_PUBLIC_BASE_PATH plus the build id, passed in so this works in tests. */ + basePath?: string; + buildId?: string; + onEvent?(event: StoreEvent): void; + /** The tab became the owner: open its accounts. */ + onOwner?(): void; + /** The worker asks for a fresh Authorization header. */ + onNeedAuth?(account: string): void; +} + +export class StoreClient { + readonly clientId = crypto.randomUUID(); + private worker: Worker | null = null; + private channel: BroadcastChannel | null = null; + private owner: string | null = null; + private isOwner = false; + private readonly pending = new Map(); + private readonly ownerWaiters: Array<() => void> = []; + + constructor(private readonly options: StoreClientOptions = {}) {} + + static supported(): boolean { + return typeof Worker === 'function' + && typeof BroadcastChannel === 'function' + && typeof navigator !== 'undefined' + && Boolean(navigator.locks) + && typeof navigator.storage?.getDirectory === 'function' + && typeof FileSystemFileHandle !== 'undefined' + && 'createSyncAccessHandle' in FileSystemFileHandle.prototype; + } + + start(): void { + if (this.worker) return; + const base = this.options.basePath ?? ''; + const query = this.options.buildId ? `?v=${encodeURIComponent(this.options.buildId)}` : ''; + this.channel = new BroadcastChannel(RPC_CHANNEL); + this.channel.onmessage = (event: MessageEvent) => this.onChannel(event.data); + this.worker = new Worker(`${base}/store-worker.js${query}`, { type: 'module' }); + this.worker.onmessage = (event: MessageEvent) => this.onWorker(event.data); + this.post({ + kind: 'init', + clientId: this.clientId, + moduleUrl: new URL(`${base}/${SQLITE3MC_PUBLIC_DIR}/sqlite3.mjs`, location.origin).href, + }); + this.bindLifecycle(); + } + + private bindLifecycle(): void { + // A clean handover when the tab goes. If this doesn't get to run, the lock + // and the handles die with the worker and the next owner retries. + addEventListener('pagehide', (event) => { + if (!event.persisted) void this.release().catch(() => {}); + }); + } + + private post(message: PageToWorker): void { + this.worker?.postMessage(message); + } + + private onWorker(message: WorkerToPage): void { + switch (message.kind) { + case 'owner': + this.isOwner = true; + this.owner = this.clientId; + this.flushOwnerWaiters(); + this.options.onOwner?.(); + break; + case 'result': { + const pending = this.pending.get(message.id); + if (!pending) return; + this.pending.delete(message.id); + clearTimeout(pending.timer); + if (message.error) { + const error = new Error(message.error); + if (message.errorName) error.name = message.errorName; + pending.reject(error); + } else { + pending.resolve(message.result); + } + break; + } + case 'need-auth': + this.options.onNeedAuth?.(message.account); + break; + case 'event': + // The owner's events also reach every other tab over the channel. + this.options.onEvent?.(message.event); + break; + } + } + + private onChannel(message: ChannelMessage): void { + switch (message.kind) { + case 'reply': + if ((message as RpcReply).to === this.clientId) this.settle(message); + break; + case 'event': + if (!this.isOwner) this.options.onEvent?.(message.event); + break; + case 'owner-changed': + this.owner = message.owner; + if (message.owner) this.flushOwnerWaiters(); + break; + default: + break; + } + } + + private settle(message: RpcReply): void { + const pending = this.pending.get(message.id); + if (!pending) return; + this.pending.delete(message.id); + clearTimeout(pending.timer); + if (message.error) pending.reject(new Error(message.error)); + else pending.resolve(message.result); + } + + private flushOwnerWaiters(): void { + for (const waiter of this.ownerWaiters.splice(0)) waiter(); + } + + private track(id: string, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new StoreOwnerGoneError()); + }, timeoutMs); + this.pending.set(id, { resolve: resolve as (value: unknown) => void, reject, timer }); + }); + } + + private async waitForOwner(): Promise { + if (this.owner) return; + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new StoreOwnerGoneError()), OWNER_WAIT_MS); + this.ownerWaiters.push(() => { + clearTimeout(timer); + resolve(); + }); + }); + } + + private async rpc(method: K, params: Parameters[0]): Promise> { + await this.waitForOwner(); + const id = crypto.randomUUID(); + const result = this.track>(id, CALL_TIMEOUT_MS); + this.channel?.postMessage({ kind: 'rpc', id, from: this.clientId, method, params } as ChannelMessage); + return result; + } + + private direct(message: PageToWorker & { id: string }): Promise { + const result = this.track(message.id, CALL_TIMEOUT_MS); + this.post(message); + return result; + } + + // ---- Owner only: these carry keys or manage files ---- + + get ownsStore(): boolean { + return this.isOwner; + } + + openAccount(params: OpenAccountParams): Promise { + return this.direct({ kind: 'open', id: crypto.randomUUID(), params }); + } + + closeAccount(account: string): Promise { + return this.direct({ kind: 'close-account', id: crypto.randomUUID(), account }); + } + + removeAccount(account: string, file: string): Promise { + return this.direct({ kind: 'remove-account', id: crypto.randomUUID(), account, file }); + } + + setAuth(account: string, auth: string | null): void { + this.post({ kind: 'set-auth', account, auth }); + } + + setSession(account: string, session: StoreSession): void { + this.post({ kind: 'set-session', account, session }); + } + + setScope(account: string, scope: StoreScope): void { + this.post({ kind: 'set-scope', account, scope }); + } + + /** Hands the store over: the worker closes everything, then lets go of the lock. */ + async release(): Promise { + if (!this.worker) return; + const worker = this.worker; + if (this.isOwner) { + await this.direct({ kind: 'release', id: crypto.randomUUID() }); + this.isOwner = false; + this.owner = null; + } + worker.terminate(); + this.worker = null; + this.channel?.close(); + this.channel = null; + } + + // ---- Any tab ---- + + answer(account: string, calls: MethodCall[]): Promise { + return this.rpc('answer', { account, calls }); + } + + sync(account: string): Promise { + return this.rpc('sync', { account }); + } + + noteStateChange(account: string, changed: Record>): Promise { + return this.rpc('noteStateChange', { account, changed }); + } + + setOnline(online: boolean): Promise { + return this.rpc('setOnline', { online }); + } + + status(account: string): Promise { + return this.rpc('status', { account }); + } +} diff --git a/lib/store/engine/account-engine.ts b/lib/store/engine/account-engine.ts new file mode 100644 index 000000000..3b460e3a0 --- /dev/null +++ b/lib/store/engine/account-engine.ts @@ -0,0 +1,223 @@ +/** + * One local account in the store: its database, its sync runs, its answers. + * It acts as a JMAP client toward the real server and as a JMAP server toward + * the UI. Nothing here touches the DOM, so tests run it on node:sqlite. + */ + +import { MethodError, type MethodHandler, type StoreArea, type StoreEvent, type StoreScope, type SyncContext, type SyncStateRow, type Syncer } from '../area'; +import { migrate, type StorageAdapter } from '../storage'; +import { answerRequest, buildMethodTable } from './responder'; +import { TransportError, type JmapResponse, type JmapTransport, type MethodCall, type StoreSession } from './transport'; +import { CORE_MIGRATIONS, metaGet, metaSet } from './core-schema'; + +export interface AccountEngineOptions { + account: string; + db: StorageAdapter; + session: StoreSession; + transport: JmapTransport; + areas: readonly StoreArea[]; + scope: StoreScope; + invertedKeywordSort?: boolean; + emit?: (event: StoreEvent) => void; + now?: () => number; + /** Waits before a retry after the server said it's busy. Tests pass a stub. */ + sleep?: (ms: number) => Promise; +} + +const RATE_RETRIES = 5; +const RATE_BACKOFF_MS = 2_000; + +export class AccountEngine { + readonly account: string; + private readonly db: StorageAdapter; + private session: StoreSession; + private scope: StoreScope; + private readonly transport: JmapTransport; + private readonly syncers: readonly Syncer[]; + private readonly methods: ReadonlyMap; + private readonly emitter: (event: StoreEvent) => void; + private readonly now: () => number; + private readonly sleep: (ms: number) => Promise; + private invertedKeywordSort: boolean; + private closed = false; + private online = true; + private running: Promise | null = null; + private again = false; + + constructor(options: AccountEngineOptions) { + this.account = options.account; + this.db = options.db; + this.session = options.session; + this.scope = options.scope; + this.transport = options.transport; + this.syncers = options.areas.flatMap((area) => area.syncers); + this.methods = buildMethodTable(options.areas); + this.emitter = options.emit ?? (() => {}); + this.now = options.now ?? Date.now; + this.sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))); + this.invertedKeywordSort = options.invertedKeywordSort ?? false; + migrate(this.db, [...CORE_MIGRATIONS, ...options.areas.flatMap((area) => area.migrations)]); + metaSet(this.db, 'session', JSON.stringify(options.session)); + } + + /** The session kept from the last time, for a start without a network. */ + static storedSession(db: StorageAdapter): StoreSession | null { + migrate(db, CORE_MIGRATIONS); + const raw = metaGet(db, 'session'); + return raw ? JSON.parse(raw) as StoreSession : null; + } + + setSession(session: StoreSession): void { + this.session = session; + metaSet(this.db, 'session', JSON.stringify(session)); + } + + setScope(scope: StoreScope): void { + this.scope = scope; + metaSet(this.db, 'scope', JSON.stringify(scope)); + } + + setInvertedKeywordSort(inverted: boolean): void { + this.invertedKeywordSort = inverted; + } + + setOnline(online: boolean): void { + this.online = online; + if (online) this.requestSync(); + } + + private syncState(jmap: string, type: string): SyncStateRow { + const row = this.db.get<{ state: string | null; target: string | null; phase: string | null; cursor: string | null }>( + 'SELECT state, target, phase, cursor FROM sync_state WHERE jmap = ? AND type = ?', + [jmap, type], + ); + return { + state: row?.state ?? null, + target: row?.target ?? null, + phase: row?.phase ?? null, + cursor: row?.cursor ? JSON.parse(row.cursor) as Record : null, + }; + } + + private setSyncState(jmap: string, type: string, patch: Partial): void { + const row = { ...this.syncState(jmap, type), ...patch }; + this.db.run( + `INSERT INTO sync_state (jmap, type, state, target, phase, cursor, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (jmap, type) DO UPDATE SET state = excluded.state, target = excluded.target, phase = excluded.phase, + cursor = excluded.cursor, updated_at = excluded.updated_at`, + [jmap, type, row.state, row.target, row.phase, row.cursor ? JSON.stringify(row.cursor) : null, this.now()], + ); + } + + /** What the server pushed: jmap account id to { type: state }. */ + noteStateChange(changed: Record>): void { + this.db.transaction(() => { + for (const [jmap, types] of Object.entries(changed)) { + for (const [type, state] of Object.entries(types)) this.setSyncState(jmap, type, { target: state }); + } + }); + this.requestSync(); + } + + /** Starts a run, or asks the running one to go again when it's done. */ + requestSync(): Promise { + if (this.closed) return Promise.resolve(); + if (this.running) { + this.again = true; + return this.running; + } + this.running = this.runLoop().finally(() => { + this.running = null; + }); + return this.running; + } + + private async runLoop(): Promise { + do { + this.again = false; + await this.runOnce(); + } while (this.again && !this.closed && this.online); + } + + private async runOnce(): Promise { + if (!this.online) return; + const ctx = this.syncContext(); + const jmapAccounts = Object.keys(this.session.accounts); + for (const syncer of this.syncers) { + for (const jmap of jmapAccounts) { + const capabilities = this.session.accounts[jmap].accountCapabilities ?? this.session.capabilities; + if (!(syncer.capability in capabilities)) continue; + if (this.closed || !this.online) return; + if (!(await this.runSyncer(ctx, syncer, jmap))) return; + } + } + if (!this.closed && this.online) this.emitter({ type: 'synced', account: this.account, at: this.now() }); + } + + /** False when the run should end: no network, no credentials. */ + private async runSyncer(ctx: SyncContext, syncer: Syncer, jmap: string): Promise { + for (let attempt = 0; ; attempt += 1) { + try { + await syncer.run(ctx, jmap); + return true; + } catch (error) { + if (error instanceof TransportError) { + if (error.kind === 'network' || error.kind === 'auth') return false; + if (error.kind === 'rate' && attempt < RATE_RETRIES) { + await this.sleep(error.retryAfterMs ?? RATE_BACKOFF_MS * 2 ** attempt); + continue; + } + } + // One part failing doesn't hold up the others. It runs again next time. + this.emitter({ + type: 'error', + account: this.account, + area: syncer.name, + message: error instanceof Error ? error.message : String(error), + }); + return true; + } + } + } + + private syncContext(): SyncContext { + return { + account: this.account, + db: this.db, + session: this.session, + scope: this.scope, + transport: this.transport, + now: this.now, + emit: this.emitter, + stopped: () => this.closed || !this.online, + syncState: (jmap, type) => this.syncState(jmap, type), + setSyncState: (jmap, type, row) => this.setSyncState(jmap, type, row), + }; + } + + /** An answer from the device. Null when nothing in the request could be answered. */ + async answer(calls: MethodCall[]): Promise { + if (this.closed) throw new MethodError('serverUnavailable', 'The local store is closed'); + const primary = this.session.primaryAccounts['urn:ietf:params:jmap:mail'] ?? Object.keys(this.session.accounts)[0]; + return answerRequest( + { + account: this.account, + db: this.db, + session: this.session, + scope: this.scope, + now: this.now, + emit: this.emitter, + defaultJmap: primary, + invertedKeywordSort: this.invertedKeywordSort, + }, + this.methods, + calls, + ); + } + + /** Stops syncing and waits for the current batch. The database stays open for the caller to close. */ + async close(): Promise { + this.closed = true; + await this.running?.catch(() => {}); + } +} diff --git a/lib/store/engine/changes.ts b/lib/store/engine/changes.ts new file mode 100644 index 000000000..a14b7ca17 --- /dev/null +++ b/lib/store/engine/changes.ts @@ -0,0 +1,101 @@ +/** + * The /changes loop, written for how servers really answer (see + * local-data/desktop-offline-facts-parts/stalwart-changes-prf.md): + * + * - sinceState goes last: on Stalwart a malformed state followed by more + * arguments is an HTTP 400 that fails the whole request. + * - hasMoreChanges false doesn't mean caught up. A page walks a fixed range of + * the log, so the loop also runs while newState differs from the state the + * server last pushed. + * - An unusable state comes as cannotCalculateChanges, invalidArguments or that + * HTTP 400. All three mean: start this type over. + */ + +import { JmapMethodError, responseFor, TransportError, type JmapTransport } from './transport'; + +export interface ChangesPage { + created: string[]; + updated: string[]; + destroyed: string[]; + newState: string; + /** Set when only these properties changed (Mailbox counts). */ + updatedProperties: string[] | null; +} + +export interface ChangesLoopOptions { + transport: JmapTransport; + type: string; + jmap: string; + using: string[]; + since: string; + /** The newest state the server pushed, if any. */ + target?: string | null; + maxChanges?: number; + stopped?: () => boolean; + /** Stores one page and its newState. Runs to completion before the next call. */ + apply(page: ChangesPage): Promise; +} + +export type ChangesOutcome = + | { outcome: 'caughtUp'; state: string; pages: number } + | { outcome: 'stopped'; state: string; pages: number } + | { outcome: 'resync'; state: string; pages: number }; + +const MAX_PAGES_PAST_TARGET = 3; + +/** A stored state a server can't have sent: don't send it, it can fail the whole request. */ +export function isUsableState(state: unknown): state is string { + return typeof state === 'string' && state.length > 0 && state.length < 1024 && /^[\w.~+=:/-]+$/.test(state); +} + +function isStateRefusal(error: unknown): boolean { + if (error instanceof JmapMethodError) { + return error.type === 'cannotCalculateChanges' || error.type === 'invalidArguments'; + } + if (error instanceof TransportError) { + return error.kind === 'http' && error.status === 400 && /notRequest/.test(error.problem ?? ''); + } + return false; +} + +export async function runChangesLoop(options: ChangesLoopOptions): Promise { + const { transport, type, jmap, using, target } = options; + const maxChanges = options.maxChanges ?? 500; + let state = options.since; + let pages = 0; + let pastTarget = 0; + if (!isUsableState(state)) return { outcome: 'resync', state, pages }; + for (;;) { + if (options.stopped?.()) return { outcome: 'stopped', state, pages }; + let page: ChangesPage & { hasMoreChanges: boolean }; + try { + const response = await transport.request( + [[`${type}/changes`, { accountId: jmap, maxChanges, sinceState: state }, 'c']], + using, + ); + const [, result] = responseFor(response, 'c'); + page = { + created: result.created ?? [], + updated: result.updated ?? [], + destroyed: result.destroyed ?? [], + newState: String(result.newState), + updatedProperties: Array.isArray(result.updatedProperties) ? result.updatedProperties : null, + hasMoreChanges: Boolean(result.hasMoreChanges), + }; + } catch (error) { + if (isStateRefusal(error)) return { outcome: 'resync', state, pages }; + throw error; + } + const before = state; + await options.apply(page); + state = page.newState; + pages += 1; + if (page.hasMoreChanges) continue; + if (!target || state === target) return { outcome: 'caughtUp', state, pages }; + // Behind the pushed state: the log moved on while we paged. A state that + // stands still means the server has nothing more for this type. + if (state === before) return { outcome: 'caughtUp', state, pages }; + pastTarget += 1; + if (pastTarget >= MAX_PAGES_PAST_TARGET) return { outcome: 'caughtUp', state, pages }; + } +} diff --git a/lib/store/engine/core-schema.ts b/lib/store/engine/core-schema.ts new file mode 100644 index 000000000..938f4baa2 --- /dev/null +++ b/lib/store/engine/core-schema.ts @@ -0,0 +1,57 @@ +import type { Migration, StorageAdapter } from '../storage'; + +export const CORE_MIGRATIONS: readonly Migration[] = [ + { + id: 1, + name: 'core', + sql: ` + CREATE TABLE meta (k TEXT PRIMARY KEY, v TEXT) WITHOUT ROWID; + CREATE TABLE sync_state ( + jmap TEXT NOT NULL, + type TEXT NOT NULL, + state TEXT, + target TEXT, + phase TEXT, + cursor TEXT, + updated_at INTEGER NOT NULL, + PRIMARY KEY (jmap, type) + ) WITHOUT ROWID; + CREATE TABLE object ( + jmap TEXT NOT NULL, + type TEXT NOT NULL, + id TEXT NOT NULL, + json TEXT NOT NULL, + PRIMARY KEY (jmap, type, id) + ) WITHOUT ROWID; + `, + }, +]; + +export function metaGet(db: StorageAdapter, key: string): string | null { + return db.get<{ v: string | null }>('SELECT v FROM meta WHERE k = ?', [key])?.v ?? null; +} + +export function metaSet(db: StorageAdapter, key: string, value: string | null): void { + db.run('INSERT INTO meta (k, v) VALUES (?, ?) ON CONFLICT (k) DO UPDATE SET v = excluded.v', [key, value]); +} + +export function putObject(db: StorageAdapter, jmap: string, type: string, id: string, value: unknown): void { + db.run( + 'INSERT INTO object (jmap, type, id, json) VALUES (?, ?, ?, ?) ON CONFLICT (jmap, type, id) DO UPDATE SET json = excluded.json', + [jmap, type, id, JSON.stringify(value)], + ); +} + +export function deleteObject(db: StorageAdapter, jmap: string, type: string, id: string): void { + db.run('DELETE FROM object WHERE jmap = ? AND type = ? AND id = ?', [jmap, type, id]); +} + +export function getObject>(db: StorageAdapter, jmap: string, type: string, id: string): T | null { + const row = db.get<{ json: string }>('SELECT json FROM object WHERE jmap = ? AND type = ? AND id = ?', [jmap, type, id]); + return row ? JSON.parse(row.json) as T : null; +} + +export function listObjects>(db: StorageAdapter, jmap: string, type: string): T[] { + return db.all<{ json: string }>('SELECT json FROM object WHERE jmap = ? AND type = ?', [jmap, type]) + .map((row) => JSON.parse(row.json) as T); +} diff --git a/lib/store/engine/responder.ts b/lib/store/engine/responder.ts new file mode 100644 index 000000000..be03dc5db --- /dev/null +++ b/lib/store/engine/responder.ts @@ -0,0 +1,89 @@ +/** + * Answers a JMAP request from the local store: the store acting as a server + * toward the UI. Every call gets its own answer, in order, as a server would: + * one the store can't answer gets a per-call error and the rest still work. + */ + +import { MethodError, type MethodContext, type MethodHandler, type MethodResult, type StoreArea } from '../area'; +import type { JmapResponse, MethodCall, MethodResponse } from './transport'; + +class BadReference extends Error {} + +/** RFC 8620 §3.7 result references, with the `*` array step. */ +export function resolvePointer(value: unknown, path: string): unknown { + const steps = path.split('/').slice(1).map((s) => s.replace(/~1/g, '/').replace(/~0/g, '~')); + const walk = (node: unknown, i: number): unknown => { + if (i === steps.length) return node; + const step = steps[i]; + if (step === '*') { + if (!Array.isArray(node)) throw new BadReference(); + return node.flatMap((item) => { + const out = walk(item, i + 1); + return Array.isArray(out) ? out : [out]; + }); + } + if (node === null || typeof node !== 'object') throw new BadReference(); + return walk((node as Record)[step], i + 1); + }; + return walk(value, 0); +} + +function resolveArgs(args: Record, done: readonly MethodResponse[]): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(args)) { + if (!key.startsWith('#')) { + out[key] = value; + continue; + } + const ref = value as { resultOf?: string; name?: string; path?: string }; + const source = done.find(([name, , id]) => id === ref.resultOf && name === ref.name); + if (!source || typeof ref.path !== 'string') throw new BadReference(); + out[key.slice(1)] = resolvePointer(source[1], ref.path); + } + return out; +} + +export function buildMethodTable(areas: readonly StoreArea[]): Map { + const table = new Map(); + for (const area of areas) { + for (const [name, handler] of Object.entries(area.methods)) { + if (table.has(name)) throw new Error(`Two store areas answer ${name}`); + table.set(name, handler); + } + } + return table; +} + +/** Answers are copied by structured clone on the way to the page, so callers can't mutate stored objects. */ +export async function answerRequest( + ctx: Omit, + methods: ReadonlyMap, + calls: readonly MethodCall[], +): Promise { + const done: MethodResponse[] = []; + const full: MethodContext = { ...ctx, createdIds: new Map() }; + let answered = 0; + for (const call of calls) { + const [name, rawArgs, callId] = call; + const handler = methods.get(name); + if (!handler) { + done.push(['error', { type: 'serverUnavailable', description: `${name} needs the server` }, callId]); + continue; + } + try { + const args = resolveArgs(rawArgs, done); + const result: MethodResult = await handler(full, args, call); + done.push([name, result, callId]); + answered += 1; + } catch (error) { + if (error instanceof BadReference) { + done.push(['error', { type: 'invalidResultReference' }, callId]); + } else if (error instanceof MethodError) { + done.push(['error', { type: error.type, description: error.description }, callId]); + } else { + throw error; + } + } + } + return answered === 0 ? null : { methodResponses: done }; +} diff --git a/lib/store/engine/transport.ts b/lib/store/engine/transport.ts new file mode 100644 index 000000000..cc55ff300 --- /dev/null +++ b/lib/store/engine/transport.ts @@ -0,0 +1,142 @@ +/** + * The engine's way to the real JMAP server. Bulk sync runs in the worker with + * its own fetch, so large JSON isn't parsed on the main thread. Auth stays + * with the page: it hands over the header and refreshes it on request. + */ + +export type MethodCall = [string, Record, string]; +// Shaped like the server's answer (any per method, as the callers read it). +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type MethodResponse = [string, any, string]; + +export interface JmapResponse { + methodResponses: MethodResponse[]; + sessionState?: string; +} + +/** The parts of a JMAP session the engine uses. */ +export interface StoreSession { + apiUrl: string; + downloadUrl: string; + uploadUrl: string; + primaryAccounts: Record; + accounts: Record }>; + capabilities: Record; +} + +export type TransportFailure = 'network' | 'auth' | 'rate' | 'http'; + +export class TransportError extends Error { + constructor( + readonly kind: TransportFailure, + message: string, + readonly status?: number, + /** The `type` of an RFC 7807 problem body, when there was one. */ + readonly problem?: string, + readonly retryAfterMs?: number, + ) { + super(message); + this.name = 'TransportError'; + } +} + +export interface JmapTransport { + request(calls: MethodCall[], using: string[]): Promise; + download(jmap: string, blobId: string, type: string, name: string): Promise; + upload(jmap: string, data: Uint8Array, type: string): Promise<{ blobId: string; type: string; size: number }>; +} + +export interface FetchTransportOptions { + session(): StoreSession; + /** The current Authorization header value, or null when the page has none yet. */ + auth(): string | null; + /** Asks the page for a fresh header; resolves null when it can't get one. */ + refreshAuth(): Promise; + fetch?: typeof fetch; +} + +const REQUEST_TIMEOUT_MS = 120_000; + +function fillTemplate(template: string, values: Record): string { + return template.replace(/\{(\w+)\}/g, (whole, key: string) => (key in values ? encodeURIComponent(values[key]) : whole)); +} + +export class FetchTransport implements JmapTransport { + constructor(private readonly options: FetchTransportOptions) {} + + private async fetchWithAuth(url: string, init: RequestInit): Promise { + const doFetch = this.options.fetch ?? fetch; + const send = async (header: string | null): Promise => { + if (!header) throw new TransportError('auth', 'No credentials yet'); + const signal = AbortSignal.timeout(REQUEST_TIMEOUT_MS); + try { + return await doFetch(url, { ...init, signal, headers: { ...init.headers, Authorization: header } }); + } catch (error) { + throw new TransportError('network', error instanceof Error ? error.message : 'Network failure'); + } + }; + let response = await send(this.options.auth()); + if (response.status === 401) { + response = await send(await this.options.refreshAuth()); + if (response.status === 401) throw new TransportError('auth', 'The server refused the credentials', 401); + } + if (response.status === 429 || response.status === 503) { + const seconds = Number(response.headers.get('Retry-After')); + throw new TransportError('rate', `Server busy (${response.status})`, response.status, undefined, Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : undefined); + } + return response; + } + + async request(calls: MethodCall[], using: string[]): Promise { + const response = await this.fetchWithAuth(this.options.session().apiUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ using, methodCalls: calls }), + }); + if (!response.ok) { + let problem: string | undefined; + try { + problem = (await response.json() as { type?: string }).type; + } catch { + // Not a problem document. + } + throw new TransportError('http', `JMAP request failed: ${response.status}`, response.status, problem); + } + return await response.json() as JmapResponse; + } + + async download(jmap: string, blobId: string, type: string, name: string): Promise { + const url = fillTemplate(this.options.session().downloadUrl, { accountId: jmap, blobId, type, name }); + const response = await this.fetchWithAuth(url, { method: 'GET' }); + if (!response.ok) throw new TransportError('http', `Download failed: ${response.status}`, response.status); + return new Uint8Array(await response.arrayBuffer()); + } + + async upload(jmap: string, data: Uint8Array, type: string): Promise<{ blobId: string; type: string; size: number }> { + const url = fillTemplate(this.options.session().uploadUrl, { accountId: jmap }); + const response = await this.fetchWithAuth(url, { + method: 'POST', + headers: { 'Content-Type': type || 'application/octet-stream' }, + body: data as BodyInit, + }); + if (!response.ok) throw new TransportError('http', `Upload failed: ${response.status}`, response.status); + return await response.json() as { blobId: string; type: string; size: number }; + } +} + +/** Finds the response to one call. Throws if the server answered it with an error. */ +export function responseFor(response: JmapResponse, callId: string): MethodResponse { + const found = response.methodResponses.find(([name, , id]) => id === callId && name !== 'error'); + if (found) return found; + const failed = response.methodResponses.find(([, , id]) => id === callId); + if (failed) throw new JmapMethodError(String(failed[1]?.type ?? 'serverFail'), failed[1]?.description); + throw new JmapMethodError('serverFail', `No response for call ${callId}`); +} + +/** The server answered a call with a method-level error. */ +export class JmapMethodError extends Error { + constructor(readonly type: string, description?: string) { + super(description ? `${type}: ${description}` : type); + this.name = 'JmapMethodError'; + } +} diff --git a/lib/store/mail/index.ts b/lib/store/mail/index.ts new file mode 100644 index 000000000..e76d61d28 --- /dev/null +++ b/lib/store/mail/index.ts @@ -0,0 +1,11 @@ +import type { StoreArea } from '../area'; +import { MAIL_METHODS } from './methods'; +import { MAIL_MIGRATIONS } from './schema'; +import { mailSyncer } from './sync'; + +export const mailArea: StoreArea = { + name: 'mail', + migrations: MAIL_MIGRATIONS, + methods: MAIL_METHODS, + syncers: [mailSyncer], +}; diff --git a/lib/store/mail/methods.ts b/lib/store/mail/methods.ts new file mode 100644 index 000000000..e91bdd45c --- /dev/null +++ b/lib/store/mail/methods.ts @@ -0,0 +1,273 @@ +/** Mail reads 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 { textCondition, type TextField } from './search'; + +type Filter = Record; + +interface Sql { sql: string; params: SqlValue[] } + +const TEXT_FIELDS: readonly TextField[] = ['text', 'subject', 'from', 'to', 'body']; + +function jmapOf(ctx: MethodContext, args: Record): string { + return (args.accountId as string | undefined) ?? ctx.defaultJmap; +} + +function filterSql(filter: Filter, jmap: string): Sql { + if (typeof filter.operator === 'string') { + const conditions = Array.isArray(filter.conditions) ? filter.conditions as Filter[] : []; + const parts = conditions.map((c) => filterSql(c, jmap)); + 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)) { + switch (key) { + case 'inMailbox': + conditions.push('EXISTS (SELECT 1 FROM email_mailbox m WHERE m.email_rowid = e.rowid AND m.mailbox_id = ?)'); + params.push(String(value)); + break; + case 'inMailboxOtherThan': { + const ids = (value as string[]).map(String); + conditions.push(`EXISTS (SELECT 1 FROM email_mailbox m WHERE m.email_rowid = e.rowid AND m.mailbox_id NOT IN (${ids.map(() => '?').join(',') || "''"}))`); + params.push(...ids); + break; + } + case 'hasKeyword': + conditions.push('EXISTS (SELECT 1 FROM email_keyword k WHERE k.email_rowid = e.rowid AND k.keyword = ?)'); + params.push(String(value).toLowerCase()); + break; + case 'notKeyword': + conditions.push('NOT EXISTS (SELECT 1 FROM email_keyword k WHERE k.email_rowid = e.rowid AND k.keyword = ?)'); + params.push(String(value).toLowerCase()); + break; + case 'after': + conditions.push('e.received_at >= ?'); + params.push(Date.parse(String(value)) || 0); + break; + case 'before': + conditions.push('e.received_at < ?'); + params.push(Date.parse(String(value)) || 0); + break; + case 'minSize': + conditions.push('e.size >= ?'); + params.push(Number(value)); + break; + case 'maxSize': + conditions.push('e.size < ?'); + params.push(Number(value)); + break; + case 'hasAttachment': + conditions.push(value ? 'e.has_attachment = 1' : 'e.has_attachment = 0'); + break; + case 'id': + conditions.push(`e.id IN (${(value as string[]).map(() => '?').join(',') || "''"})`); + params.push(...(value as string[]).map(String)); + break; + default: + if ((TEXT_FIELDS as readonly string[]).includes(key)) { + const text = textCondition(key as TextField, String(value)); + conditions.push(text.sql); + params.push(...text.params); + break; + } + // cc, bcc, header and the thread keyword filters: the server's job. + throw new MethodError('unsupportedFilter', `${key} isn't available offline`); + } + } + return { sql: conditions.length ? conditions.join(' AND ') : '1', params }; +} + +interface Comparator { property?: string; isAscending?: boolean; keyword?: string } + +function orderSql(ctx: MethodContext, sort: Comparator[]): Sql { + const levels = sort.length > 0 ? sort : [{ property: 'receivedAt', isAscending: false }]; + const parts: string[] = []; + const params: SqlValue[] = []; + for (const level of levels) { + // RFC 8621 says isAscending defaults to true. + let ascending = level.isAscending !== false; + switch (level.property) { + case 'receivedAt': parts.push(`e.received_at ${ascending ? 'ASC' : 'DESC'}`); break; + case 'sentAt': parts.push(`COALESCE(e.sent_at, e.received_at) ${ascending ? 'ASC' : 'DESC'}`); break; + case 'size': parts.push(`e.size ${ascending ? 'ASC' : 'DESC'}`); break; + case 'subject': parts.push(`e.subject_sort COLLATE NOCASE ${ascending ? 'ASC' : 'DESC'}`); break; + case 'from': parts.push(`e.from_sort COLLATE NOCASE ${ascending ? 'ASC' : 'DESC'}`); break; + case 'to': parts.push(`e.to_sort COLLATE NOCASE ${ascending ? 'ASC' : 'DESC'}`); break; + case 'hasKeyword': { + // The client flips this on servers that read it the other way, so a + // stored answer has to read it the way that server would. + if (ctx.invertedKeywordSort) ascending = !ascending; + parts.push(`EXISTS (SELECT 1 FROM email_keyword s WHERE s.email_rowid = e.rowid AND s.keyword = ?) ${ascending ? 'ASC' : 'DESC'}`); + params.push(String(level.keyword ?? '').toLowerCase()); + break; + } + default: + throw new MethodError('unsupportedSort', `${String(level.property)} isn't available offline`); + } + } + parts.push('e.rowid DESC'); + return { sql: parts.join(', '), params }; +} + +/** The one folder a filter limits itself to, so the query can start from that folder's index. */ +function drivingMailbox(filter: Filter | undefined): string | null { + if (!filter) return null; + if (typeof filter.inMailbox === 'string') return filter.inMailbox; + if (filter.operator === 'AND' && Array.isArray(filter.conditions)) { + for (const condition of filter.conditions as Filter[]) { + const found = drivingMailbox(condition); + if (found) return found; + } + } + return null; +} + +const emailQuery: MethodHandler = (ctx, args) => { + const jmap = jmapOf(ctx, args); + const filter = (args.filter ?? undefined) as Filter | undefined; + if (args.anchor !== undefined) throw new MethodError('unsupportedFilter', 'anchor needs the server'); + const where = filter ? filterSql(filter, jmap) : { sql: '1', params: [] }; + const order = orderSql(ctx, (args.sort as Comparator[]) ?? []); + const box = drivingMailbox(filter); + // Starting from the folder's own index keeps small folders fast in a large account. + const from = box + ? 'email_mailbox d INDEXED BY email_mailbox_list CROSS JOIN email e ON e.rowid = d.email_rowid' + : 'email e'; + const base = box + ? { sql: 'd.jmap = ? AND d.mailbox_id = ?', params: [jmap, box] as SqlValue[] } + : { sql: 'e.jmap = ?', params: [jmap] as SqlValue[] }; + const position = Math.max(0, Number(args.position ?? 0)); + const limit = typeof args.limit === 'number' && args.limit > 0 ? args.limit : -1; + const collapse = Boolean(args.collapseThreads); + const group = collapse ? 'GROUP BY COALESCE(e.thread_id, e.id)' : ''; + const ids = ctx.db.all<{ id: string }>( + `SELECT e.id FROM ${from} WHERE ${base.sql} AND (${where.sql}) ${group} ORDER BY ${order.sql} LIMIT ? OFFSET ?`, + [...base.params, ...where.params, ...order.params, limit, position], + ).map((row) => String(row.id)); + const result: MethodResult = { + accountId: jmap, + canCalculateChanges: false, + // Local answers aren't a position in the server's log, so this is a token only. + queryState: 'local', + position, + ids, + }; + if (args.calculateTotal) { + const count = collapse ? 'COUNT(DISTINCT COALESCE(e.thread_id, e.id))' : 'COUNT(*)'; + result.total = Number(ctx.db.get<{ n: number }>( + `SELECT ${count} AS n FROM ${from} WHERE ${base.sql} AND (${where.sql})`, + [...base.params, ...where.params], + )?.n ?? 0); + } + return result; +}; + +const BODY_PROPERTIES = new Set(['bodyValues', 'textBody', 'htmlBody', 'bodyStructure', 'headers', 'attachments', 'bcc', 'replyTo', 'messageId', 'inReplyTo', 'references']); + +function pick(email: Record, properties: string[] | undefined): Record { + if (!properties) return email; + const out: Record = { id: email.id }; + for (const property of properties) if (property in email) out[property] = email[property]; + return out; +} + +const emailGet: MethodHandler = (ctx, args) => { + const jmap = jmapOf(ctx, args); + if (!Array.isArray(args.ids)) throw new MethodError('requestTooLarge', 'ids: null needs the server'); + const properties = args.properties as string[] | undefined; + const wantsValues = Boolean(args.fetchTextBodyValues || args.fetchHTMLBodyValues || args.fetchAllBodyValues) || Boolean(properties?.includes('bodyValues')); + const wantsBody = wantsValues || !properties || properties.some((p) => BODY_PROPERTIES.has(p)); + const list: Record[] = []; + const notFound: string[] = []; + for (const id of args.ids as string[]) { + const row = ctx.db.get<{ rowid: number; json: string; body_state: number }>( + 'SELECT rowid, json, body_state FROM email WHERE jmap = ? AND id = ?', + [jmap, id], + ); + if (!row) { + notFound.push(id); + continue; + } + const email = JSON.parse(row.json) as Record; + if (!wantsBody) { + list.push(pick(email, properties)); + continue; + } + const body = ctx.db.get<{ json: string }>('SELECT json FROM email_body WHERE email_rowid = ?', [row.rowid]); + // Asked for the text and it isn't here: as if the message weren't there, not an empty message. + if (!body && wantsValues) { + notFound.push(id); + continue; + } + list.push(pick({ ...(body ? JSON.parse(body.json) as object : {}), ...email }, properties)); + } + const state = ctx.db.get<{ state: string | null }>("SELECT state FROM sync_state WHERE jmap = ? AND type = 'Email'", [jmap])?.state; + return { accountId: jmap, ...(state ? { state } : {}), list, notFound }; +}; + +const threadGet: MethodHandler = (ctx, args) => { + const jmap = jmapOf(ctx, args); + const ids = Array.isArray(args.ids) ? [...new Set(args.ids as string[])] : []; + const list: Array<{ id: string; emailIds: string[] }> = []; + const notFound: string[] = []; + for (const id of ids) { + const stored = ctx.db.get<{ email_ids: string }>('SELECT email_ids FROM thread WHERE jmap = ? AND id = ?', [jmap, id]); + if (stored) { + list.push({ id, emailIds: JSON.parse(stored.email_ids) as string[] }); + continue; + } + const rows = ctx.db.all<{ id: string }>('SELECT id FROM email WHERE jmap = ? AND thread_id = ? ORDER BY received_at ASC', [jmap, id]); + if (rows.length > 0) list.push({ id, emailIds: rows.map((row) => String(row.id)) }); + else notFound.push(id); + } + return { accountId: jmap, list, notFound }; +}; + +const mailboxGet: MethodHandler = (ctx, args) => { + const jmap = jmapOf(ctx, args); + const boxes = listObjects>(ctx.db, jmap, 'Mailbox'); + if (boxes.length === 0) throw new MethodError('serverUnavailable', 'Folders not kept yet'); + const ids = Array.isArray(args.ids) ? new Set(args.ids as string[]) : null; + const chosen = ids ? boxes.filter((box) => ids.has(box.id as string)) : boxes; + const state = ctx.db.get<{ state: string | null }>("SELECT state FROM sync_state WHERE jmap = ? AND type = 'Mailbox'", [jmap])?.state; + return { + accountId: jmap, + ...(state ? { state } : {}), + list: chosen.map((box) => pick(box, args.properties as string[] | undefined)), + notFound: ids ? [...ids].filter((id) => !boxes.some((box) => box.id === id)) : [], + }; +}; + +const mailboxQuery: MethodHandler = (ctx, args) => { + const jmap = jmapOf(ctx, args); + const boxes = listObjects>(ctx.db, jmap, 'Mailbox'); + if (boxes.length === 0) throw new MethodError('serverUnavailable', 'Folders not kept yet'); + const filter = (args.filter ?? {}) as { role?: string }; + const chosen = filter.role ? boxes.filter((box) => box.role === filter.role) : boxes; + const position = typeof args.position === 'number' ? args.position : 0; + const limit = typeof args.limit === 'number' ? args.limit : chosen.length; + return { + accountId: jmap, + queryState: 'local', + canCalculateChanges: false, + position, + ids: chosen.slice(position, position + limit).map((box) => box.id), + ...(args.calculateTotal ? { total: chosen.length } : {}), + }; +}; + +export const MAIL_METHODS: Readonly> = { + 'Email/query': emailQuery, + 'Email/get': emailGet, + 'Thread/get': threadGet, + 'Mailbox/get': mailboxGet, + 'Mailbox/query': mailboxQuery, +}; diff --git a/lib/store/mail/records.ts b/lib/store/mail/records.ts new file mode 100644 index 000000000..b0e292718 --- /dev/null +++ b/lib/store/mail/records.ts @@ -0,0 +1,147 @@ +/** How an Email object becomes rows, and back. */ + +import type { StorageAdapter } from '../storage'; + +export const MAIL_USING = ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail']; + +export const EMAIL_LIST_PROPERTIES = [ + 'id', 'threadId', 'mailboxIds', 'keywords', 'size', 'receivedAt', 'sentAt', 'from', 'to', 'cc', + 'subject', 'preview', 'hasAttachment', 'blobId', 'header:Authentication-Results:all', +]; + +/** What opening a message asks for, past the list properties. */ +export const EMAIL_BODY_PROPERTIES = [ + 'bcc', 'replyTo', 'textBody', 'htmlBody', 'bodyValues', 'attachments', 'messageId', 'inReplyTo', + 'references', 'headers', 'bodyStructure', +]; + +export type EmailObject = Record & { id: string }; + +interface Address { name?: string | null; email?: string | null } + +function firstAddress(value: unknown): string | null { + const first = Array.isArray(value) ? value[0] as Address | undefined : undefined; + const text = first?.name || first?.email; + return text ? text.toLowerCase() : null; +} + +function addressText(value: unknown): string { + if (!Array.isArray(value)) return ''; + return (value as Address[]).map((a) => [a.name, a.email].filter(Boolean).join(' ')).join(' '); +} + +function time(value: unknown): number | null { + if (typeof value !== 'string') return null; + const ms = Date.parse(value); + return Number.isFinite(ms) ? ms : null; +} + +/** The subject without Re:, Fwd: and the like, as the client sorts it. */ +export function baseSubject(subject: string): string { + return subject.replace(/^(\s*(re|aw|fw|fwd|wg)\s*(\[\d+\])?\s*:\s*)+/i, '').trim().toLowerCase(); +} + +/** Inserts or updates one message from its list properties. Keeps a body already stored. */ +export function upsertEmail(db: StorageAdapter, jmap: string, email: EmailObject, gen: number): number { + const receivedAt = time(email.receivedAt) ?? 0; + const subject = typeof email.subject === 'string' ? email.subject : ''; + const existing = db.get<{ rowid: number }>('SELECT rowid FROM email WHERE jmap = ? AND id = ?', [jmap, email.id]); + const values = [ + typeof email.threadId === 'string' ? email.threadId : null, + receivedAt, + time(email.sentAt), + typeof email.size === 'number' ? email.size : 0, + email.hasAttachment ? 1 : 0, + baseSubject(subject), + firstAddress(email.from), + firstAddress(email.to), + JSON.stringify(email), + gen, + ]; + let rowid: number; + if (existing) { + rowid = Number(existing.rowid); + db.run( + `UPDATE email SET thread_id = ?, received_at = ?, sent_at = ?, size = ?, has_attachment = ?, subject_sort = ?, + from_sort = ?, to_sort = ?, json = ?, gen = ? WHERE rowid = ?`, + [...values, rowid], + ); + db.run('DELETE FROM email_mailbox WHERE email_rowid = ?', [rowid]); + db.run('DELETE FROM email_keyword WHERE email_rowid = ?', [rowid]); + } else { + rowid = db.run( + `INSERT INTO email (thread_id, received_at, sent_at, size, has_attachment, subject_sort, from_sort, to_sort, json, gen, jmap, id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [...values, jmap, email.id], + ).lastInsertRowid; + indexEmail(db, rowid, email, ''); + } + for (const [box, on] of Object.entries((email.mailboxIds ?? {}) as Record)) { + if (on) db.run('INSERT INTO email_mailbox (email_rowid, jmap, mailbox_id, received_at) VALUES (?, ?, ?, ?)', [rowid, jmap, box, receivedAt]); + } + for (const [keyword, on] of Object.entries((email.keywords ?? {}) as Record)) { + if (on) db.run('INSERT INTO email_keyword (email_rowid, keyword) VALUES (?, ?)', [rowid, keyword.toLowerCase()]); + } + return rowid; +} + +export function deleteEmail(db: StorageAdapter, jmap: string, id: string): void { + const row = db.get<{ rowid: number }>('SELECT rowid FROM email WHERE jmap = ? AND id = ?', [jmap, id]); + if (!row) return; + deleteEmailRow(db, Number(row.rowid)); +} + +export function deleteEmailRow(db: StorageAdapter, rowid: number): void { + db.run('DELETE FROM email_fts_word WHERE rowid = ?', [rowid]); + db.run('DELETE FROM email_fts_tri WHERE rowid = ?', [rowid]); + // Explicit rather than ON DELETE CASCADE: foreign keys stay off for bulk speed. + db.run('DELETE FROM email_mailbox WHERE email_rowid = ?', [rowid]); + db.run('DELETE FROM email_keyword WHERE email_rowid = ?', [rowid]); + db.run('DELETE FROM email_body WHERE email_rowid = ?', [rowid]); + db.run('DELETE FROM email WHERE rowid = ?', [rowid]); +} + +/** Plain text of a message for the search index: the text part, or the HTML part without its tags. */ +export function bodyText(email: Record): string { + const values = (email.bodyValues ?? {}) as Record; + const parts = (key: string) => ((email[key] ?? []) as Array<{ partId?: string; type?: string }>); + const text = parts('textBody') + .filter((part) => part.type !== 'text/html') + .map((part) => values[part.partId ?? '']?.value ?? '') + .join('\n'); + if (text.trim()) return text; + return parts('htmlBody') + .map((part) => values[part.partId ?? '']?.value ?? '') + .join('\n') + .replace(/<(style|script)[\s\S]*?<\/\1>/gi, ' ') + .replace(/<[^>]+>/g, ' ') + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/\s+/g, ' '); +} + +/** A long message past this is rare and only bloats the index. */ +const INDEX_BODY_CHARS = 200_000; + +export function indexEmail(db: StorageAdapter, rowid: number, email: Record, body: string): void { + const subject = typeof email.subject === 'string' ? email.subject : ''; + const from = addressText(email.from); + const to = [addressText(email.to), addressText(email.cc), addressText(email.bcc)].filter(Boolean).join(' '); + const text = body.slice(0, INDEX_BODY_CHARS); + for (const table of ['email_fts_word', 'email_fts_tri']) { + db.run(`INSERT INTO ${table} (rowid, subject, from_addr, to_addr, body) VALUES (?, ?, ?, ?, ?)`, [rowid, subject, from, to, text]); + } +} + +export function storeBody(db: StorageAdapter, rowid: number, listJson: string, full: Record): void { + const list = JSON.parse(listJson) as Record; + const body: Record = {}; + for (const [key, value] of Object.entries(full)) if (!(key in list)) body[key] = value; + db.run('INSERT INTO email_body (email_rowid, json) VALUES (?, ?) ON CONFLICT (email_rowid) DO UPDATE SET json = excluded.json', [rowid, JSON.stringify(body)]); + db.run('UPDATE email SET body_state = 1 WHERE rowid = ?', [rowid]); + db.run('DELETE FROM email_fts_word WHERE rowid = ?', [rowid]); + db.run('DELETE FROM email_fts_tri WHERE rowid = ?', [rowid]); + indexEmail(db, rowid, { ...list, ...body }, bodyText(full)); +} diff --git a/lib/store/mail/schema.ts b/lib/store/mail/schema.ts new file mode 100644 index 000000000..52ef16dde --- /dev/null +++ b/lib/store/mail/schema.ts @@ -0,0 +1,65 @@ +import type { Migration } from '../storage'; + +export const MAIL_MIGRATIONS: readonly Migration[] = [ + { + id: 100, + name: 'mail', + sql: ` + CREATE TABLE email ( + rowid INTEGER PRIMARY KEY, + jmap TEXT NOT NULL, + id TEXT NOT NULL, + thread_id TEXT, + received_at INTEGER NOT NULL, + sent_at INTEGER, + size INTEGER NOT NULL DEFAULT 0, + has_attachment INTEGER NOT NULL DEFAULT 0, + subject_sort TEXT, + from_sort TEXT, + to_sort TEXT, + json TEXT NOT NULL, + -- 0 no body yet, 1 kept, 2 the server had none to give + body_state INTEGER NOT NULL DEFAULT 0, + gen INTEGER NOT NULL DEFAULT 0, + UNIQUE (jmap, id) + ); + CREATE INDEX email_received ON email (jmap, received_at DESC); + CREATE INDEX email_thread ON email (jmap, thread_id); + CREATE INDEX email_backfill ON email (jmap, body_state, received_at DESC); + CREATE TABLE email_mailbox ( + email_rowid INTEGER NOT NULL REFERENCES email (rowid) ON DELETE CASCADE, + jmap TEXT NOT NULL, + mailbox_id TEXT NOT NULL, + received_at INTEGER NOT NULL, + PRIMARY KEY (email_rowid, mailbox_id) + ) WITHOUT ROWID; + CREATE INDEX email_mailbox_list ON email_mailbox (jmap, mailbox_id, received_at DESC); + CREATE TABLE email_keyword ( + email_rowid INTEGER NOT NULL REFERENCES email (rowid) ON DELETE CASCADE, + keyword TEXT NOT NULL, + PRIMARY KEY (email_rowid, keyword) + ) WITHOUT ROWID; + CREATE INDEX email_keyword_lookup ON email_keyword (keyword, email_rowid); + CREATE TABLE email_body ( + email_rowid INTEGER PRIMARY KEY REFERENCES email (rowid) ON DELETE CASCADE, + json TEXT NOT NULL + ); + CREATE TABLE thread ( + jmap TEXT NOT NULL, + id TEXT NOT NULL, + email_ids TEXT NOT NULL, + PRIMARY KEY (jmap, id) + ) WITHOUT ROWID; + CREATE VIRTUAL TABLE email_fts_word USING fts5( + subject, from_addr, to_addr, body, + content = '', contentless_delete = 1, + tokenize = 'unicode61 remove_diacritics 2' + ); + CREATE VIRTUAL TABLE email_fts_tri USING fts5( + subject, from_addr, to_addr, body, + content = '', contentless_delete = 1, + tokenize = 'trigram remove_diacritics 1' + ); + `, + }, +]; diff --git a/lib/store/mail/search.ts b/lib/store/mail/search.ts new file mode 100644 index 000000000..57b94f849 --- /dev/null +++ b/lib/store/mail/search.ts @@ -0,0 +1,59 @@ +/** Full-text matching over the two FTS indexes. */ + +import type { SqlValue } from '../storage'; + +export type TextField = 'text' | 'subject' | 'from' | 'to' | 'body'; + +const COLUMNS: Record = { + text: null, + subject: 'subject', + from: 'from_addr', + to: 'to_addr', + body: 'body', +}; + +function quote(term: string): string { + return `"${term.replace(/"/g, '""')}"`; +} + +/** The terms of a search box value: quoted phrases stay whole, `word*` keeps its star. */ +export function splitTerms(input: string): string[] { + const out: string[] = []; + for (const match of input.matchAll(/"([^"]+)"|(\S+)/g)) out.push(match[1] ?? match[2]); + return out; +} + +/** + * One term as SQL over rowids. The word index finds words and word starts, + * the trigram index finds a term inside a longer word, which is how German + * compounds are found ("Rechnung" in "Zahlungserinnerungsrechnung"). + */ +function termRowids(field: TextField, term: string): { sql: string; params: SqlValue[] } { + const column = COLUMNS[field]; + const star = term.endsWith('*'); + const clean = star ? term.slice(0, -1) : term; + const scope = column ? `${column} : ` : ''; + const word = `${scope}${quote(clean)}${' *'.slice(1)}`; + const parts = ['SELECT rowid FROM email_fts_word WHERE email_fts_word MATCH ?']; + const params: SqlValue[] = [word]; + // Trigrams need three characters. Shorter terms use the word index alone. + if ([...clean].length >= 3) { + parts.push('SELECT rowid FROM email_fts_tri WHERE email_fts_tri MATCH ?'); + params.push(`${scope}${quote(clean)}`); + } + return { sql: parts.join(' UNION '), params }; +} + +/** SQL for a text condition, as a condition on `e.rowid`. Every term has to match. */ +export function textCondition(field: TextField, input: string): { sql: string; params: SqlValue[] } { + const terms = splitTerms(input); + if (terms.length === 0) return { sql: '1', params: [] }; + const conditions: string[] = []; + const params: SqlValue[] = []; + for (const term of terms) { + const part = termRowids(field, term); + conditions.push(`e.rowid IN (${part.sql})`); + params.push(...part.params); + } + return { sql: conditions.join(' AND '), params }; +} diff --git a/lib/store/mail/sync.ts b/lib/store/mail/sync.ts new file mode 100644 index 000000000..21ad9bb0d --- /dev/null +++ b/lib/store/mail/sync.ts @@ -0,0 +1,323 @@ +/** + * Mail sync for one JMAP account. + * + * A full sync captures the state first, then pages through the window, so a + * change that lands during it is picked up by the delta afterwards. Its cursor + * lives in sync_state, written in the same transaction as the page, so a + * reload continues where it stopped. Bodies come after the list, newest first. + */ + +import type { SyncContext, Syncer } from '../area'; +import { deleteObject, putObject } from '../engine/core-schema'; +import { runChangesLoop } from '../engine/changes'; +import { responseFor, type MethodCall } from '../engine/transport'; +import { + deleteEmail, + deleteEmailRow, + EMAIL_BODY_PROPERTIES, + EMAIL_LIST_PROPERTIES, + MAIL_USING, + storeBody, + upsertEmail, + type EmailObject, +} from './records'; + +const PAGE = 500; +const PAIRS_PER_REQUEST = 4; +const BODY_BATCH_IDS = 100; +const BODY_BATCH_BYTES = 8 * 1024 * 1024; +const MAX_BODY_VALUE_BYTES = 1_000_000; +const DELTA_BATCH = 500; + +function windowFilter(ctx: SyncContext): Record | undefined { + if (ctx.scope.days === null) return undefined; + return { after: new Date(ctx.now() - ctx.scope.days * 86_400_000).toISOString() }; +} + +function inWindow(ctx: SyncContext, email: EmailObject): boolean { + if (ctx.scope.days === null) return true; + return (Date.parse(String(email.receivedAt)) || 0) >= ctx.now() - ctx.scope.days * 86_400_000; +} + +// ---- Mailboxes ---- + +async function fullMailboxes(ctx: SyncContext, jmap: string): Promise { + const response = await ctx.transport.request([['Mailbox/get', { accountId: jmap, ids: null }, 'm']], MAIL_USING); + const [, result] = responseFor(response, 'm'); + ctx.db.transaction(() => { + ctx.db.run("DELETE FROM object WHERE jmap = ? AND type = 'Mailbox'", [jmap]); + for (const box of result.list as Array<{ id: string }>) putObject(ctx.db, jmap, 'Mailbox', box.id, box); + ctx.setSyncState(jmap, 'Mailbox', { state: String(result.state), phase: null, cursor: null }); + }); +} + +async function syncMailboxes(ctx: SyncContext, jmap: string): Promise { + const { state, target } = ctx.syncState(jmap, 'Mailbox'); + if (!state) return fullMailboxes(ctx, jmap); + const outcome = await runChangesLoop({ + transport: ctx.transport, + type: 'Mailbox', + jmap, + using: MAIL_USING, + since: state, + target, + stopped: ctx.stopped, + apply: async (page) => { + const ids = [...page.created, ...page.updated]; + const list: Array<{ id: string }> = []; + for (let i = 0; i < ids.length; i += PAGE) { + const response = await ctx.transport.request([['Mailbox/get', { accountId: jmap, ids: ids.slice(i, i + PAGE) }, 'm']], MAIL_USING); + list.push(...responseFor(response, 'm')[1].list); + } + ctx.db.transaction(() => { + for (const box of list) putObject(ctx.db, jmap, 'Mailbox', box.id, box); + for (const id of page.destroyed) deleteObject(ctx.db, jmap, 'Mailbox', id); + ctx.setSyncState(jmap, 'Mailbox', { state: page.newState }); + }); + }, + }); + if (outcome.outcome === 'resync') await fullMailboxes(ctx, jmap); +} + +// ---- Email list ---- + +interface ListCursor { position: number; gen: number; captured: string } + +async function startFullList(ctx: SyncContext, jmap: string): Promise { + const response = await ctx.transport.request([['Email/get', { accountId: jmap, ids: [], properties: ['id'] }, 's']], MAIL_USING); + const captured = String(responseFor(response, 's')[1].state); + const gen = (ctx.db.get<{ gen: number | null }>('SELECT MAX(gen) AS gen FROM email WHERE jmap = ?', [jmap])?.gen ?? 0) + 1; + const cursor: ListCursor = { position: 0, gen: Number(gen), captured }; + ctx.db.transaction(() => ctx.setSyncState(jmap, 'Email', { state: null, phase: 'list', cursor: { ...cursor } })); + return cursor; +} + +async function runFullList(ctx: SyncContext, jmap: string, start: ListCursor): Promise { + const cursor = { ...start }; + const filter = windowFilter(ctx); + let total: number | null = null; + for (;;) { + if (ctx.stopped()) return false; + const calls: MethodCall[] = []; + for (let i = 0; i < PAIRS_PER_REQUEST; i += 1) { + calls.push(['Email/query', { + accountId: jmap, + ...(filter ? { filter } : {}), + sort: [{ property: 'receivedAt', isAscending: false }], + position: cursor.position + i * PAGE, + limit: PAGE, + calculateTotal: i === 0, + }, `q${i}`]); + calls.push(['Email/get', { + accountId: jmap, + '#ids': { resultOf: `q${i}`, name: 'Email/query', path: '/ids' }, + properties: EMAIL_LIST_PROPERTIES, + }, `g${i}`]); + } + const response = await ctx.transport.request(calls, MAIL_USING); + let done = false; + let got = 0; + const lists: EmailObject[][] = []; + for (let i = 0; i < PAIRS_PER_REQUEST; i += 1) { + const [, query] = responseFor(response, `q${i}`); + if (i === 0 && typeof query.total === 'number') total = query.total; + lists.push(responseFor(response, `g${i}`)[1].list as EmailObject[]); + got += (query.ids as string[]).length; + if ((query.ids as string[]).length < PAGE) { + done = true; + break; + } + } + cursor.position += got; + ctx.db.transaction(() => { + for (const list of lists) for (const email of list) upsertEmail(ctx.db, jmap, email, cursor.gen); + if (done) { + // Whatever this pass didn't see is gone from the server or the window. + const stale = ctx.db.all<{ rowid: number }>('SELECT rowid FROM email WHERE jmap = ? AND gen < ?', [jmap, cursor.gen]); + for (const row of stale) deleteEmailRow(ctx.db, Number(row.rowid)); + ctx.setSyncState(jmap, 'Email', { state: cursor.captured, phase: null, cursor: null }); + } else { + ctx.setSyncState(jmap, 'Email', { cursor: { ...cursor } }); + } + }); + ctx.emit({ type: 'progress', account: ctx.account, area: 'mail', phase: 'list', done: cursor.position, total }); + if (done) { + ctx.emit({ type: 'changed', account: ctx.account, collections: ['Email'] }); + return true; + } + } +} + +async function syncEmailDelta(ctx: SyncContext, jmap: string, since: string, target: string | null): Promise<'done' | 'resync' | 'stopped'> { + const outcome = await runChangesLoop({ + transport: ctx.transport, + type: 'Email', + jmap, + using: MAIL_USING, + since, + target, + stopped: ctx.stopped, + apply: async (page) => { + const ids = [...page.created, ...page.updated]; + const fetched: EmailObject[] = []; + for (let i = 0; i < ids.length; i += DELTA_BATCH) { + const response = await ctx.transport.request([['Email/get', { + accountId: jmap, + ids: ids.slice(i, i + DELTA_BATCH), + properties: EMAIL_LIST_PROPERTIES, + }, 'g']], MAIL_USING); + fetched.push(...responseFor(response, 'g')[1].list as EmailObject[]); + } + const created = new Set(page.created); + const newIds: string[] = []; + ctx.db.transaction(() => { + for (const email of fetched) { + const known = ctx.db.get('SELECT 1 AS x FROM email WHERE jmap = ? AND id = ?', [jmap, email.id]); + // An old message changing doesn't pull it into the window. + if (!known && !inWindow(ctx, email)) continue; + const gen = Number(ctx.db.get<{ gen: number | null }>('SELECT MAX(gen) AS gen FROM email WHERE jmap = ?', [jmap])?.gen ?? 0); + upsertEmail(ctx.db, jmap, email, gen); + if (!known && created.has(email.id)) newIds.push(email.id); + } + for (const id of page.destroyed) deleteEmail(ctx.db, jmap, id); + ctx.setSyncState(jmap, 'Email', { state: page.newState }); + }); + if (newIds.length > 0) ctx.emit({ type: 'created', account: ctx.account, jmap, collection: 'Email', ids: newIds }); + if (ids.length + page.destroyed.length > 0) ctx.emit({ type: 'changed', account: ctx.account, collections: ['Email'] }); + }, + }); + if (outcome.outcome === 'resync') return 'resync'; + return outcome.outcome === 'stopped' ? 'stopped' : 'done'; +} + +// ---- Threads ---- + +async function fetchThreads(ctx: SyncContext, jmap: string, ids: string[]): Promise> { + const out: Array<{ id: string; emailIds: string[] }> = []; + for (let i = 0; i < ids.length; i += PAGE) { + const response = await ctx.transport.request([['Thread/get', { accountId: jmap, ids: ids.slice(i, i + PAGE) }, 't']], MAIL_USING); + out.push(...responseFor(response, 't')[1].list); + } + return out; +} + +async function syncThreads(ctx: SyncContext, jmap: string): Promise { + const { state, target } = ctx.syncState(jmap, 'Thread'); + const fullThreads = async () => { + const response = await ctx.transport.request([['Thread/get', { accountId: jmap, ids: [] }, 's']], MAIL_USING); + const captured = String(responseFor(response, 's')[1].state); + const known = ctx.db.all<{ id: string }>( + `SELECT DISTINCT e.thread_id AS id FROM email e WHERE e.jmap = ? AND e.thread_id IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM thread t WHERE t.jmap = e.jmap AND t.id = e.thread_id)`, + [jmap], + ).map((row) => String(row.id)); + for (let i = 0; i < known.length; i += PAGE) { + if (ctx.stopped()) return; + const list = await fetchThreads(ctx, jmap, known.slice(i, i + PAGE)); + ctx.db.transaction(() => { + for (const thread of list) ctx.db.run('INSERT OR REPLACE INTO thread (jmap, id, email_ids) VALUES (?, ?, ?)', [jmap, thread.id, JSON.stringify(thread.emailIds)]); + }); + ctx.emit({ type: 'progress', account: ctx.account, area: 'mail', phase: 'threads', done: i + list.length, total: known.length }); + } + ctx.db.transaction(() => ctx.setSyncState(jmap, 'Thread', { state: captured })); + }; + if (!state) return fullThreads(); + const outcome = await runChangesLoop({ + transport: ctx.transport, + type: 'Thread', + jmap, + using: MAIL_USING, + since: state, + target, + stopped: ctx.stopped, + apply: async (page) => { + const list = await fetchThreads(ctx, jmap, [...page.created, ...page.updated]); + ctx.db.transaction(() => { + for (const thread of list) ctx.db.run('INSERT OR REPLACE INTO thread (jmap, id, email_ids) VALUES (?, ?, ?)', [jmap, thread.id, JSON.stringify(thread.emailIds)]); + for (const id of page.destroyed) ctx.db.run('DELETE FROM thread WHERE jmap = ? AND id = ?', [jmap, id]); + ctx.setSyncState(jmap, 'Thread', { state: page.newState }); + }); + }, + }); + if (outcome.outcome === 'resync') { + ctx.db.transaction(() => { + ctx.db.run('DELETE FROM thread WHERE jmap = ?', [jmap]); + ctx.setSyncState(jmap, 'Thread', { state: null }); + }); + await fullThreads(); + } +} + +// ---- Bodies ---- + +async function backfillBodies(ctx: SyncContext, jmap: string): Promise { + let done = 0; + const totalRow = ctx.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM email WHERE jmap = ? AND body_state = 0', [jmap]); + const total = Number(totalRow?.n ?? 0); + for (;;) { + if (ctx.stopped()) return; + const candidates = ctx.db.all<{ rowid: number; id: string; size: number; json: string }>( + 'SELECT rowid, id, size, json FROM email WHERE jmap = ? AND body_state = 0 ORDER BY received_at DESC LIMIT ?', + [jmap, BODY_BATCH_IDS], + ); + if (candidates.length === 0) return; + let bytes = 0; + const batch: typeof candidates = []; + for (const row of candidates) { + if (batch.length > 0 && bytes + Number(row.size) > BODY_BATCH_BYTES) break; + bytes += Number(row.size); + batch.push(row); + } + const response = await ctx.transport.request([['Email/get', { + accountId: jmap, + ids: batch.map((row) => String(row.id)), + properties: ['id', ...EMAIL_BODY_PROPERTIES], + fetchTextBodyValues: true, + fetchHTMLBodyValues: true, + fetchAllBodyValues: true, + maxBodyValueBytes: MAX_BODY_VALUE_BYTES, + }, 'b']], MAIL_USING); + const list = responseFor(response, 'b')[1].list as EmailObject[]; + const byId = new Map(list.map((email) => [email.id, email])); + ctx.db.transaction(() => { + for (const row of batch) { + const full = byId.get(String(row.id)); + if (full) storeBody(ctx.db, Number(row.rowid), String(row.json), full); + else ctx.db.run('UPDATE email SET body_state = 2 WHERE rowid = ?', [row.rowid]); + } + }); + done += batch.length; + ctx.emit({ type: 'progress', account: ctx.account, area: 'mail', phase: 'bodies', done, total }); + } +} + +// ---- The whole pass ---- + +export const mailSyncer: Syncer = { + name: 'mail', + capability: 'urn:ietf:params:jmap:mail', + async run(ctx, jmap) { + await syncMailboxes(ctx, jmap); + if (ctx.stopped()) return; + + const email = ctx.syncState(jmap, 'Email'); + let cursor = email.phase === 'list' ? email.cursor as unknown as ListCursor : null; + if (!cursor && !email.state) cursor = await startFullList(ctx, jmap); + if (!cursor && email.state) { + const outcome = await syncEmailDelta(ctx, jmap, email.state, email.target); + if (outcome === 'stopped') return; + if (outcome === 'resync') cursor = await startFullList(ctx, jmap); + } + if (cursor) { + const finished = await runFullList(ctx, jmap, cursor); + if (!finished) return; + // Catch what changed while paging. + const after = ctx.syncState(jmap, 'Email'); + if (after.state && (await syncEmailDelta(ctx, jmap, after.state, after.target)) === 'stopped') return; + } + if (ctx.stopped()) return; + await syncThreads(ctx, jmap); + if (ctx.stopped()) return; + await backfillBodies(ctx, jmap); + }, +}; diff --git a/lib/store/sqlite-version.ts b/lib/store/sqlite-version.ts new file mode 100644 index 000000000..1b6ee653c --- /dev/null +++ b/lib/store/sqlite-version.ts @@ -0,0 +1,3 @@ +/** The vendored SQLite3MultipleCiphers build. Change it with scripts/fetch-sqlite3mc.mjs. */ +export const SQLITE3MC_VERSION = '2.6.1-3.54.0'; +export const SQLITE3MC_PUBLIC_DIR = `sqlite3mc/${SQLITE3MC_VERSION}`; diff --git a/lib/store/storage.ts b/lib/store/storage.ts new file mode 100644 index 000000000..e8cde1216 --- /dev/null +++ b/lib/store/storage.ts @@ -0,0 +1,73 @@ +/** + * The small storage interface the local store is written against. + * + * Synchronous on purpose: SQLite3MC in a worker and node:sqlite in the tests + * both are, and a transaction then can't hold an await by accident. A native + * or mobile adapter runs the engine next to its database, not across a + * message boundary. + */ + +export type SqlValue = string | number | bigint | null | Uint8Array; +export type SqlRow = Record; + +export interface StorageAdapter { + /** Runs one or more statements without parameters. */ + exec(sql: string): void; + /** Runs one statement. */ + run(sql: string, params?: readonly SqlValue[]): { changes: number; lastInsertRowid: number }; + all(sql: string, params?: readonly SqlValue[]): T[]; + get(sql: string, params?: readonly SqlValue[]): T | undefined; + /** Commits when fn returns, rolls back when it throws. Nested calls join the outer one. */ + transaction(fn: () => T): T; + close(): void; +} + +/** Shared by the adapters: BEGIN/COMMIT around fn, and a guard against async bodies. */ +export function runTransaction( + state: { depth: number }, + exec: (sql: string) => void, + fn: () => T, +): T { + if (state.depth > 0) return fn(); + exec('BEGIN IMMEDIATE'); + state.depth += 1; + try { + const result = fn(); + if (result && typeof (result as { then?: unknown }).then === 'function') { + throw new Error('A store transaction must be synchronous'); + } + exec('COMMIT'); + return result; + } catch (error) { + exec('ROLLBACK'); + throw error; + } finally { + state.depth -= 1; + } +} + +export interface Migration { + /** Unique, ascending. Ranges per area are listed in lib/store/area.ts. */ + id: number; + name: string; + sql: string; +} + +/** Applies what's missing, in id order, each in its own transaction. */ +export function migrate(db: StorageAdapter, migrations: readonly Migration[]): number[] { + db.exec('CREATE TABLE IF NOT EXISTS schema_migration (id INTEGER PRIMARY KEY, name TEXT NOT NULL, at INTEGER NOT NULL)'); + const done = new Set(db.all<{ id: number }>('SELECT id FROM schema_migration').map((row) => Number(row.id))); + const ids = new Set(); + const applied: number[] = []; + for (const migration of [...migrations].sort((a, b) => a.id - b.id)) { + if (ids.has(migration.id)) throw new Error(`Duplicate store migration id ${migration.id}`); + ids.add(migration.id); + if (done.has(migration.id)) continue; + db.transaction(() => { + db.exec(migration.sql); + db.run('INSERT INTO schema_migration (id, name, at) VALUES (?, ?, ?)', [migration.id, migration.name, Date.now()]); + }); + applied.push(migration.id); + } + return applied; +} diff --git a/lib/store/worker/host.ts b/lib/store/worker/host.ts new file mode 100644 index 000000000..25e63e270 --- /dev/null +++ b/lib/store/worker/host.ts @@ -0,0 +1,168 @@ +/** + * What runs in the owner tab's worker: one database and engine per account. + * The list of areas is the only thing that decides what the store answers. + */ + +import type { StoreArea, StoreEvent } from '../area'; +import { AccountEngine } from '../engine/account-engine'; +import { FetchTransport, type StoreSession } from '../engine/transport'; +import type { StorageAdapter } from '../storage'; +import type { OpenAccountParams, StoreRpc, StoreStatus } from './protocol'; + +export interface HostStorage { + open(file: string, keyHex: string): Promise; + remove(file: string): boolean; +} + +interface OpenAccount { + engine: AccountEngine; + db: StorageAdapter; + file: string; + auth: string | null; + pendingAuth: Array<(header: string | null) => void>; + session: StoreSession; +} + +export interface StoreHostOptions { + areas: readonly StoreArea[]; + storage: HostStorage; + emit(event: StoreEvent): void; + needAuth(account: string): void; +} + +export class StoreHost { + private readonly accounts = new Map(); + private online = true; + private readonly synced = new Map(); + + constructor(private readonly options: StoreHostOptions) {} + + async openAccount(params: OpenAccountParams): Promise { + const existing = this.accounts.get(params.account); + if (existing) { + if (params.session) this.setSession(params.account, params.session); + this.setAuth(params.account, params.auth); + existing.engine.setScope(params.scope); + existing.engine.setInvertedKeywordSort(params.invertedKeywordSort); + void existing.engine.requestSync(); + return; + } + const db = await this.options.storage.open(params.file, params.keyHex); + const session = params.session ?? AccountEngine.storedSession(db); + if (!session) { + db.close(); + throw new Error('No session for this account yet'); + } + const account: OpenAccount = { db, file: params.file, auth: params.auth, pendingAuth: [], session, engine: undefined as unknown as AccountEngine }; + const transport = new FetchTransport({ + session: () => account.session, + auth: () => account.auth, + refreshAuth: () => new Promise((resolve) => { + account.pendingAuth.push(resolve); + this.options.needAuth(params.account); + }), + }); + account.engine = new AccountEngine({ + account: params.account, + db, + session, + transport, + areas: this.options.areas, + scope: params.scope, + invertedKeywordSort: params.invertedKeywordSort, + emit: (event) => { + if (event.type === 'synced') this.synced.set(event.account, event.at); + this.options.emit(event); + }, + }); + account.engine.setOnline(this.online); + this.accounts.set(params.account, account); + void account.engine.requestSync(); + } + + setAuth(account: string, auth: string | null): void { + const open = this.accounts.get(account); + if (!open) return; + open.auth = auth; + for (const resolve of open.pendingAuth.splice(0)) resolve(auth); + } + + setSession(account: string, session: StoreSession): void { + const open = this.accounts.get(account); + if (!open) return; + open.session = session; + open.engine.setSession(session); + } + + setScope(account: string, scope: Parameters[0]): void { + const open = this.accounts.get(account); + if (!open) return; + open.engine.setScope(scope); + void open.engine.requestSync(); + } + + async closeAccount(account: string): Promise { + const open = this.accounts.get(account); + if (!open) return; + this.accounts.delete(account); + await open.engine.close(); + for (const resolve of open.pendingAuth.splice(0)) resolve(null); + open.db.close(); + } + + async removeAccount(account: string, file: string): Promise { + await this.closeAccount(account); + this.synced.delete(account); + return this.options.storage.remove(file); + } + + async closeAll(): Promise { + await Promise.all([...this.accounts.keys()].map((account) => this.closeAccount(account))); + } + + /** The calls other tabs can make. */ + async call(method: keyof StoreRpc, params: unknown): Promise { + switch (method) { + case 'answer': { + const { account, calls } = params as Parameters[0]; + const open = this.accounts.get(account); + if (!open) throw new Error('The local store is not open for this account'); + return open.engine.answer(calls); + } + case 'sync': { + const { account } = params as Parameters[0]; + await this.accounts.get(account)?.engine.requestSync(); + return undefined; + } + case 'noteStateChange': { + const { account, changed } = params as Parameters[0]; + this.accounts.get(account)?.engine.noteStateChange(changed); + return undefined; + } + case 'setOnline': { + const { online } = params as Parameters[0]; + this.online = online; + for (const open of this.accounts.values()) open.engine.setOnline(online); + return undefined; + } + case 'status': { + const { account } = params as Parameters[0]; + return this.status(account); + } + default: + throw new Error(`Unknown store call ${String(method)}`); + } + } + + private status(account: string): StoreStatus | null { + const open = this.accounts.get(account); + if (!open) return null; + return { + account, + open: true, + emails: Number(open.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM email')?.n ?? 0), + bodies: Number(open.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM email_body')?.n ?? 0), + lastSyncedAt: this.synced.get(account) ?? null, + }; + } +} diff --git a/lib/store/worker/protocol.ts b/lib/store/worker/protocol.ts new file mode 100644 index 000000000..8b3257648 --- /dev/null +++ b/lib/store/worker/protocol.ts @@ -0,0 +1,76 @@ +/** Messages between the page and the store worker. */ + +import type { StoreEvent, StoreScope } from '../area'; +import type { JmapResponse, MethodCall, StoreSession } from '../engine/transport'; + +export const RPC_CHANNEL = 'bulwark-store-rpc'; +export const OWNER_LOCK = 'bulwark-store-owner'; + +export interface OpenAccountParams { + account: string; + /** `/a.db`, no account data: the name is plaintext in the OPFS slot. */ + file: string; + /** 32 bytes in hex. Only ever sent to this tab's own worker, never over the channel. */ + keyHex: string; + session: StoreSession | null; + scope: StoreScope; + invertedKeywordSort: boolean; + /** The page's current Authorization header. */ + auth: string | null; +} + +/** What other tabs can ask for, over the channel. */ +export interface StoreRpc { + answer(params: { account: string; calls: MethodCall[] }): JmapResponse | null; + sync(params: { account: string }): void; + noteStateChange(params: { account: string; changed: Record> }): void; + setOnline(params: { online: boolean }): void; + status(params: { account: string }): StoreStatus | null; +} + +export interface StoreStatus { + account: string; + open: boolean; + emails: number; + bodies: number; + lastSyncedAt: number | null; +} + +export type RpcRequest = { + kind: 'rpc'; + id: string; + from: string; + method: K; + params: Parameters[0]; +}; + +export type RpcReply = { + kind: 'reply'; + id: string; + to: string; + result?: unknown; + error?: string; +}; + +export type ChannelMessage = + | RpcRequest + | RpcReply + | { kind: 'event'; event: StoreEvent } + | { kind: 'owner-changed'; owner: string | null }; + +/** From this tab's page to its own worker, never on the channel. */ +export type PageToWorker = + | { kind: 'init'; clientId: string; moduleUrl: string } + | { kind: 'open'; id: string; params: OpenAccountParams } + | { kind: 'close-account'; id: string; account: string } + | { kind: 'remove-account'; id: string; account: string; file: string } + | { kind: 'set-auth'; account: string; auth: string | null } + | { kind: 'set-session'; account: string; session: StoreSession } + | { kind: 'set-scope'; account: string; scope: StoreScope } + | { kind: 'release'; id: string }; + +export type WorkerToPage = + | { kind: 'owner' } + | { kind: 'result'; id: string; result?: unknown; error?: string; errorName?: string } + | { kind: 'need-auth'; account: string } + | { kind: 'event'; event: StoreEvent }; diff --git a/lib/store/worker/worker.ts b/lib/store/worker/worker.ts new file mode 100644 index 000000000..003340c82 --- /dev/null +++ b/lib/store/worker/worker.ts @@ -0,0 +1,147 @@ +/** + * The store worker. Every tab has one, parked on the owner lock, and only the + * holder loads the wasm and touches OPFS. The lock is held for the worker's + * whole life, so handing over means: close, pause the VFS, then let go. + * + * Built to public/store-worker.js by scripts/build-store-worker.mjs. + */ + +import { WasmStorage } from '../adapters/wasm'; +import { STORE_AREAS } from '../areas'; +import { RPC_CHANNEL, OWNER_LOCK, type ChannelMessage, type PageToWorker, type WorkerToPage } from './protocol'; +import { StoreHost } from './host'; + +const scope = self as unknown as { + postMessage(message: WorkerToPage): void; + addEventListener(type: 'message', listener: (event: MessageEvent) => void): void; + close(): void; +}; + +let clientId = ''; +let moduleUrl = ''; +let storage: WasmStorage | null = null; +let host: StoreHost | null = null; +let channel: BroadcastChannel | null = null; +let releaseLock: (() => void) | null = null; + +function reply(id: string, result?: unknown): void { + scope.postMessage({ kind: 'result', id, result }); +} + +function replyError(id: string, error: unknown): void { + scope.postMessage({ + kind: 'result', + id, + error: error instanceof Error ? error.message : String(error), + errorName: error instanceof Error ? error.name : undefined, + }); +} + +function requireHost(): StoreHost { + if (!host) throw new Error('This tab does not own the local store'); + return host; +} + +async function becomeOwner(): Promise { + storage = await WasmStorage.install(moduleUrl); + host = new StoreHost({ + areas: STORE_AREAS, + storage, + emit: (event) => { + scope.postMessage({ kind: 'event', event }); + channel?.postMessage({ kind: 'event', event } satisfies ChannelMessage); + }, + needAuth: (account) => scope.postMessage({ kind: 'need-auth', account }), + }); + channel = new BroadcastChannel(RPC_CHANNEL); + channel.onmessage = async (event: MessageEvent) => { + const message = event.data; + if (message.kind !== 'rpc') return; + try { + const result = await requireHost().call(message.method, message.params); + channel?.postMessage({ kind: 'reply', id: message.id, to: message.from, result } satisfies ChannelMessage); + } catch (error) { + channel?.postMessage({ + kind: 'reply', + id: message.id, + to: message.from, + error: error instanceof Error ? error.message : String(error), + } satisfies ChannelMessage); + } + }; + channel.postMessage({ kind: 'owner-changed', owner: clientId } satisfies ChannelMessage); + scope.postMessage({ kind: 'owner' }); +} + +async function release(): Promise { + // The order matters: the OPFS handles have to be free before the lock is. + channel?.postMessage({ kind: 'owner-changed', owner: null } satisfies ChannelMessage); + await host?.closeAll(); + host = null; + storage?.pause(); + storage = null; + channel?.close(); + channel = null; + releaseLock?.(); + releaseLock = null; +} + +scope.addEventListener('message', async (event) => { + const message = event.data; + switch (message.kind) { + case 'init': + clientId = message.clientId; + moduleUrl = message.moduleUrl; + void navigator.locks.request(OWNER_LOCK, () => new Promise((resolve) => { + releaseLock = resolve; + becomeOwner().catch((error) => { + scope.postMessage({ + kind: 'event', + event: { type: 'error', account: '', area: 'store', message: error instanceof Error ? error.message : String(error) }, + }); + resolve(); + }); + })); + break; + case 'open': + try { + await requireHost().openAccount(message.params); + reply(message.id); + } catch (error) { + replyError(message.id, error); + } + break; + case 'close-account': + try { + await requireHost().closeAccount(message.account); + reply(message.id); + } catch (error) { + replyError(message.id, error); + } + break; + case 'remove-account': + try { + reply(message.id, await requireHost().removeAccount(message.account, message.file)); + } catch (error) { + replyError(message.id, error); + } + break; + case 'set-auth': + host?.setAuth(message.account, message.auth); + break; + case 'set-session': + host?.setSession(message.account, message.session); + break; + case 'set-scope': + host?.setScope(message.account, message.scope); + break; + case 'release': + try { + await release(); + reply(message.id); + } catch (error) { + replyError(message.id, error); + } + break; + } +}); diff --git a/next.config.ts b/next.config.ts index e57a5f3af..1ce8a7714 100644 --- a/next.config.ts +++ b/next.config.ts @@ -140,6 +140,11 @@ const nextConfig: NextConfig = { { key: "Cache-Control", value: "public, max-age=31536000, immutable" }, ], }, + { + // The local store's database engine, in a folder named after its version. + source: "/sqlite3mc/:path*", + headers: [{ key: "Cache-Control", value: "public, max-age=31536000, immutable" }], + }, // The upload routes proxy.ts's matcher leaves out, with the // headers it sets on every other API response. ...["/api/webdav", "/api/plugin-proxy", "/api/wopi/files/:fileId/contents"].map((source) => ({ diff --git a/package.json b/package.json index 9c1abb8ac..52adefb78 100644 --- a/package.json +++ b/package.json @@ -22,8 +22,8 @@ "typescript" ], "scripts": { - "dev": "node scripts/build-pst-worker.mjs && node scripts/build-plugin-sandbox.mjs --watch -- next dev --turbopack", - "build": "node scripts/build-pst-worker.mjs && node scripts/build-plugin-sandbox.mjs && next build --turbopack && node scripts/standalone-guard.mjs", + "dev": "node scripts/build-pst-worker.mjs && node scripts/build-store-worker.mjs && node scripts/build-plugin-sandbox.mjs --watch -- next dev --turbopack", + "build": "node scripts/build-pst-worker.mjs && node scripts/build-store-worker.mjs && node scripts/build-plugin-sandbox.mjs && next build --turbopack && node scripts/standalone-guard.mjs", "build:lite": "node scripts/lite/build.mjs", "lite:serve": "node scripts/lite/serve.mjs out 4173", "test:lite-smoke": "playwright test -c scripts/lite/smoke/playwright.config.ts", diff --git a/proxy.ts b/proxy.ts index eb767eb03..4e47be5e8 100644 --- a/proxy.ts +++ b/proxy.ts @@ -86,7 +86,7 @@ export const config = { // /en/mail/folder/inbox/statement.pdf a real signed-in page, and it rendered // without a CSP or any other security header (GHSA-xvjh-v9c6-qcvc). Only what // is genuinely static may skip locale routing and the headers. -const STATIC_ASSET_PATTERN = /^\/[^/]+\.[^/]+$|^\/(?:branding|notification|demo|plugin-sandbox-assets)\//; +const STATIC_ASSET_PATTERN = /^\/[^/]+\.[^/]+$|^\/(?:branding|notification|demo|plugin-sandbox-assets|sqlite3mc)\//; export function isStaticAssetPath(pathname: string): boolean { return STATIC_ASSET_PATTERN.test(pathname); diff --git a/scripts/build-store-worker.mjs b/scripts/build-store-worker.mjs new file mode 100644 index 000000000..835bc10e6 --- /dev/null +++ b/scripts/build-store-worker.mjs @@ -0,0 +1,46 @@ +// Builds the local store's worker (lib/store/worker/worker.ts) into +// public/store-worker.js and copies the vendored SQLite3MultipleCiphers build +// next to it, in a folder named after its version: emscripten looks for the +// literal name sqlite3.wasm, so the files can't have hashed names. +// Both go through proxy.ts's static asset rule, which keeps the page CSP off +// them: wasm compiles under the worker's own response headers. +// +// node scripts/build-store-worker.mjs (npm run build, npm run dev) + +import { build } from 'esbuild'; +import { copyFileSync, mkdirSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { SQLITE3MC, SQLITE3MC_VENDOR_DIR, verifyVendored } from './fetch-sqlite3mc.mjs'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +export const STORE_WORKER_OUTFILE = path.join(root, 'public', 'store-worker.js'); +export const SQLITE3MC_PUBLIC_DIR = path.join(root, 'public', 'sqlite3mc', SQLITE3MC.version); + +export async function buildStoreWorker({ outfile = STORE_WORKER_OUTFILE, publicDir = SQLITE3MC_PUBLIC_DIR } = {}) { + verifyVendored(); + mkdirSync(publicDir, { recursive: true }); + for (const name of [...Object.keys(SQLITE3MC.files), 'LICENSE']) { + copyFileSync(path.join(SQLITE3MC_VENDOR_DIR, name), path.join(publicDir, name)); + } + const result = await build({ + entryPoints: [path.join(root, 'lib/store/worker/worker.ts')], + bundle: true, + format: 'esm', + platform: 'browser', + target: 'es2022', + outfile, + minify: true, + legalComments: 'none', + alias: { '@': root }, + define: { 'process.env.NODE_ENV': '"production"' }, + metafile: true, + logLevel: 'warning', + }); + return { bytes: Object.values(result.metafile.outputs).reduce((n, o) => n + o.bytes, 0) }; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const { bytes } = await buildStoreWorker(); + console.log(`[build-store-worker] ${path.relative(root, STORE_WORKER_OUTFILE)} ${bytes}`); +} diff --git a/scripts/fetch-sqlite3mc.mjs b/scripts/fetch-sqlite3mc.mjs new file mode 100644 index 000000000..f119f3df7 --- /dev/null +++ b/scripts/fetch-sqlite3mc.mjs @@ -0,0 +1,79 @@ +// Fetches the pinned SQLite3MultipleCiphers WASM build into vendor/sqlite3mc/. +// Run it by hand when moving to a new version, and commit the result, so a +// Docker build needs no access to GitHub. Every download is checked against the +// pinned sha256: a changed artifact fails here. +// +// node scripts/fetch-sqlite3mc.mjs downloads and verifies +// node scripts/fetch-sqlite3mc.mjs --check verifies what is committed +// +// Fixes for bugs #291-#294 are on trunk, not in a release yet. Check the rules +// in lib/store/adapters/wasm.ts when moving to a newer build, and never go below 2.6.0. + +import { createHash } from 'node:crypto'; +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { unzipSync } from 'fflate'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +export const SQLITE3MC = { + version: '2.6.1-3.54.0', + tag: 'v2.6.1', + zip: 'sqlite3mc-2.6.1-sqlite-3.54.0-wasm.zip', + zipSha256: 'aba121490fd6123bcbef8404ee26df9b26c0301063781e2aa31f05aed6f48384', + innerDir: 'sqlite3mc-wasm-3540000/jswasm', + files: { + 'sqlite3.mjs': '5308c7cc857408697c657a8d314e647a6f31c4aee67974ab8efd80c1a07161e0', + 'sqlite3.wasm': '0b5a4bfff35f1ec87bf6f68ea293f425b86cbd661b77628a955cb827c9fbf524', + }, +}; + +export const SQLITE3MC_VENDOR_DIR = path.join(root, 'vendor', 'sqlite3mc', SQLITE3MC.version); + +function sha256(data) { + return createHash('sha256').update(data).digest('hex'); +} + +/** Throws unless the committed files match their pins. */ +export function verifyVendored(dir = SQLITE3MC_VENDOR_DIR) { + for (const [name, expected] of Object.entries(SQLITE3MC.files)) { + const actual = sha256(readFileSync(path.join(dir, name))); + if (actual !== expected) throw new Error(`[sqlite3mc] ${name}: expected ${expected}, got ${actual}`); + } + readFileSync(path.join(dir, 'LICENSE')); +} + +async function download(url) { + const response = await fetch(url); + if (!response.ok) throw new Error(`[sqlite3mc] ${url}: ${response.status}`); + return Buffer.from(await response.arrayBuffer()); +} + +async function fetchAndVendor() { + const base = `https://github.com/utelle/SQLite3MultipleCiphers/releases/download/${SQLITE3MC.tag}`; + const zip = await download(`${base}/${SQLITE3MC.zip}`); + const actual = sha256(zip); + if (actual !== SQLITE3MC.zipSha256) throw new Error(`[sqlite3mc] zip: expected ${SQLITE3MC.zipSha256}, got ${actual}`); + const entries = unzipSync(zip); + mkdirSync(SQLITE3MC_VENDOR_DIR, { recursive: true }); + for (const name of Object.keys(SQLITE3MC.files)) { + const entry = entries[`${SQLITE3MC.innerDir}/${name}`]; + if (!entry) throw new Error(`[sqlite3mc] ${name} is not in the zip`); + writeFileSync(path.join(SQLITE3MC_VENDOR_DIR, name), entry); + } + // The zip has no licence file. + const license = await download(`https://raw.githubusercontent.com/utelle/SQLite3MultipleCiphers/${SQLITE3MC.tag}/LICENSE`); + writeFileSync(path.join(SQLITE3MC_VENDOR_DIR, 'LICENSE'), license); + verifyVendored(); + console.log(`[fetch-sqlite3mc] vendored ${SQLITE3MC.version}`); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + if (process.argv.includes('--check')) { + verifyVendored(); + console.log('[fetch-sqlite3mc] ok'); + } else { + await fetchAndVendor(); + } +} diff --git a/scripts/lite/lib.mjs b/scripts/lite/lib.mjs index 559c09eca..18bdc6cdf 100644 --- a/scripts/lite/lib.mjs +++ b/scripts/lite/lib.mjs @@ -43,6 +43,9 @@ export const LITE_REMOVED_PATHS = [ "app/(main)/protocol", "e2e", "integration", + // The local store: Lite has no offline mode. A dev checkout may hold these. + "public/store-worker.js", + "public/sqlite3mc", ]; /** Test trees are pruned too: they import the routes removed above. */ diff --git a/vendor/sqlite3mc/2.6.1-3.54.0/LICENSE b/vendor/sqlite3mc/2.6.1-3.54.0/LICENSE new file mode 100644 index 000000000..2fff29de4 --- /dev/null +++ b/vendor/sqlite3mc/2.6.1-3.54.0/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019-2026 Ulrich Telle + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/vendor/sqlite3mc/2.6.1-3.54.0/sqlite3.mjs b/vendor/sqlite3mc/2.6.1-3.54.0/sqlite3.mjs new file mode 100644 index 000000000..4ea31fb03 --- /dev/null +++ b/vendor/sqlite3mc/2.6.1-3.54.0/sqlite3.mjs @@ -0,0 +1,14727 @@ +/* @preserve +** +** LICENSE for the sqlite3 WebAssembly/JavaScript APIs. +** +** This bundle (typically released as sqlite3.js or sqlite3.mjs) +** is an amalgamation of JavaScript source code from two projects: +** +** 1) https://emscripten.org: the Emscripten "glue code" is covered by +** the terms of the MIT license and University of Illinois/NCSA +** Open Source License, as described at: +** +** https://emscripten.org/docs/introducing_emscripten/emscripten_license.html +** +** 2) https://sqlite.org: all code and documentation labeled as being +** from this source are released under the same terms as the sqlite3 +** C library: +** +** 2022-10-16 +** +** The author disclaims copyright to this source code. In place of a +** legal notice, here is a blessing: +** +** * May you do good and not evil. +** * May you find forgiveness for yourself and forgive others. +** * May you share freely, never taking more than you give. +*/ +/* @preserve +** This code was built from sqlite3 version... +** +** SQLITE_VERSION "3.54.0" +** SQLITE_VERSION_NUMBER 3054000 +** SQLITE_SOURCE_ID "2026-10-09 15:46:58 be8d059e9a49089ab2dce5ed26dd87aaf598fdc5fbe0b107c0dd758464bcd5e3" +** +** Emscripten SDK: 6.0.3 +*/ + + + + + +async function sqlite3InitModule(moduleArg = {}) { + var Module = moduleArg; + + + + + + + + + + + + + + + + + + + + + +var ENVIRONMENT_IS_WEB = !!globalThis.window; +var ENVIRONMENT_IS_WORKER = !!globalThis.WorkerGlobalScope; + + +var ENVIRONMENT_IS_NODE = globalThis.process?.versions?.node && globalThis.process?.type != 'renderer'; +var ENVIRONMENT_IS_SHELL = !ENVIRONMENT_IS_WEB && !ENVIRONMENT_IS_NODE && !ENVIRONMENT_IS_WORKER; + + + + + + +(function(Module){ + const sIMS = + globalThis.sqlite3InitModuleState + || Object.assign(Object.create(null),{ + + debugModule: function(){ + console.warn("globalThis.sqlite3InitModuleState is missing",arguments); + } + }); + delete globalThis.sqlite3InitModuleState; + sIMS.debugModule('pre-js.js sqlite3InitModuleState =',sIMS); + + + Module['locateFile'] = function(path, prefix) { + if( this.emscriptenLocateFile instanceof Function ){ + + return this.emscriptenLocateFile(path, prefix); + } + return new URL(path, import.meta.url).href; + }.bind(sIMS); + + + Module['instantiateWasm'] = function callee(imports, onSuccess){ + if( this.emscriptenInstantiateWasm instanceof Function ){ + + return this.emscriptenInstantiateWasm(imports, onSuccess); + } + const sims = this; + const finalThen = (arg)=>{ + arg.imports = imports; + sims.instantiateWasm = arg; + onSuccess(arg.instance, arg.module); + }; + const uri = Module.locateFile( + sims.wasmFilename, ( + ('undefined'===typeof scriptDirectory) + ? "" : scriptDirectory) + ); + sims.debugModule("instantiateWasm() uri =", uri, "sIMS =",this); + const wfetch = ()=>fetch(uri, {credentials: 'same-origin'}); + const loadWasm = WebAssembly.instantiateStreaming + ? async ()=> + WebAssembly + .instantiateStreaming(wfetch(), imports) + .then(finalThen) + : async ()=> + wfetch() + .then(response => response.arrayBuffer()) + .then(bytes => WebAssembly.instantiate(bytes, imports)) + .then(finalThen) + return loadWasm(); + }.bind(sIMS); +})(Module); + + + + +var programArgs = []; +var thisProgram = './this.program'; +var quit_ = (status, toThrow) => { + throw toThrow; +}; + +var _scriptName = import.meta.url; + + +var scriptDirectory = ''; +function locateFile(path) { + if (Module['locateFile']) { + return Module['locateFile'](path, scriptDirectory); + } + return scriptDirectory + path; +} + + +var readAsync, readBinary; + + + + +if (ENVIRONMENT_IS_WEB || ENVIRONMENT_IS_WORKER) { + try { + scriptDirectory = new URL('.', _scriptName).href; + } catch { + + + } + + { + +if (ENVIRONMENT_IS_WORKER) { + readBinary = (url) => { + var xhr = new XMLHttpRequest(); + xhr.open('GET', url, false); + xhr.responseType = 'arraybuffer'; + xhr.send(null); + return new Uint8Array((xhr.response)); + }; + } + + readAsync = async (url) => { + var response = await fetch(url, { credentials: 'same-origin' }); + if (response.ok) { + return response.arrayBuffer(); + } + throw new Error(response.status + ' : ' + response.url); + }; + + } +} else +{ +} + +var out = console.log.bind(console); +var err = console.error.bind(console); + + + + + + + + + + + + + + +var wasmBinary; + + + + + + + + + +var ABORT = false; + + + + +var EXITSTATUS; + + + + + + +function assert(condition, text) { + if (!condition) { + + + + abort(text); + } +} + + +var isFileURI = (filename) => filename.startsWith('file://'); + + + + +class EmscriptenEH {} + +class EmscriptenSjLj extends EmscriptenEH {} + + + + + + +var runtimeInitialized = false; + + + + + +function getMemoryBuffer() { + return wasmMemory.buffer; +} + +function updateMemoryViews() { + + + if (HEAP8?.buffer?.resizable) return; + var b = getMemoryBuffer(); + HEAP8 = new Int8Array(b); + HEAP16 = new Int16Array(b); + HEAPU8 = new Uint8Array(b); + HEAPU16 = new Uint16Array(b); + HEAP32 = new Int32Array(b); + HEAPU32 = new Uint32Array(b); + HEAPF32 = new Float32Array(b); + HEAPF64 = new Float64Array(b); + HEAP64 = new BigInt64Array(b); + HEAPU64 = new BigUint64Array(b); +} + + + + + + + +function initMemory() { + + + + if (Module['wasmMemory']) { + wasmMemory = Module['wasmMemory']; + } else + { + var INITIAL_MEMORY = Module['INITIAL_MEMORY'] || 8388608; + + + wasmMemory = new WebAssembly.Memory({ + 'initial': INITIAL_MEMORY / 65536, + + + + + + 'maximum': 32768, + }); + } + + updateMemoryViews(); +} + + + + + + +function preRun() { + var preRun = Module['preRun']; + if (preRun) { + if (typeof preRun == 'function') preRun = [preRun]; + onPreRuns.push(...preRun); + } + + callRuntimeCallbacks(onPreRuns); + +} + +function initRuntime() { + runtimeInitialized = true; + + + if (!Module['noFSInit'] && !FS.initialized) FS.init(); +TTY.init(); + + + wasmExports['__wasm_call_ctors'](); + + + FS.ignorePermissions = false; + + +} + +function postRun() { + + var postRun = Module['postRun']; + if (postRun) { + if (typeof postRun == 'function') postRun = [postRun]; + onPostRuns.push(...postRun); + } + + + callRuntimeCallbacks(onPostRuns); + +} + + +function abort(what) { + Module['onAbort']?.(what); + + what = `Aborted(${what})`; + + + err(what); + + ABORT = true; + + what += '. Build with -sASSERTIONS for more info.'; + + + + + + + + + + + + + + + + var e = new WebAssembly.RuntimeError(what); + + + + + throw e; +} + +var wasmBinaryFile; + +function findWasmBinary() { + + if (Module['locateFile']) { + return locateFile('sqlite3.wasm'); + } + + + return new URL('sqlite3.wasm', import.meta.url).href; + +} + +function getBinarySync(file) { + if (readBinary) { + return readBinary(file); + } + + + throw 'both async and sync fetching of the wasm failed'; +} + +async function getWasmBinary(binaryFile) { + + if (!wasmBinary) { + + try { + var response = await readAsync(binaryFile); + return new Uint8Array(response); + } catch { + + } + } + + + return getBinarySync(binaryFile); +} + +async function instantiateArrayBuffer(binaryFile, imports) { + try { + var binary = await getWasmBinary(binaryFile); + var instance = await WebAssembly.instantiate(binary, imports); + return instance; + } catch (reason) { + err(`failed to asynchronously prepare wasm: ${reason}`); + + abort(reason); + } +} + +async function instantiateAsync(binary, binaryFile, imports) { + if (!binary + ) { + try { + var response = fetch(binaryFile, { credentials: 'same-origin' }); + var instantiationResult = await WebAssembly.instantiateStreaming(response, imports); + return instantiationResult; + } catch (reason) { + + + err(`wasm streaming compile failed: ${reason}`); + err('falling back to ArrayBuffer instantiation'); + + }; + } + return instantiateArrayBuffer(binaryFile, imports); +} + +function getWasmImports() { + + var imports = { + 'env': wasmImports, + 'wasi_snapshot_preview1': wasmImports, + }; + return imports; +} + + + +async function createWasm() { + + + + function receiveInstance(instance) { + wasmExports = instance.exports; + + assignWasmExports(wasmExports); + + return wasmExports; + } + + + function receiveInstantiationResult(result) { + + + + + return receiveInstance(result['instance']); + } + + var info = getWasmImports(); + + + + + + + + var instantiateWasm = Module['instantiateWasm']; + if (instantiateWasm) { + return new Promise((resolve) => { + instantiateWasm(info, (inst) => resolve(receiveInstance(inst))); + }); + } + + wasmBinaryFile ??= findWasmBinary(); + var result = await instantiateAsync(wasmBinary, wasmBinaryFile, info); + var exports = receiveInstantiationResult(result); + return exports; +} + + + + + + + class ExitStatus { + name = 'ExitStatus'; + constructor(status) { + this.message = `Program terminated with exit(${status})`; + this.status = status; + } + } + + + var HEAP16; + + + var HEAP32; + + + var HEAP64; + + + var HEAP8; + + + var HEAPF32; + + + var HEAPF64; + + + var HEAPU16; + + + var HEAPU32; + + + var HEAPU64; + + + var HEAPU8; + + var callRuntimeCallbacks = (callbacks) => { + while (callbacks.length > 0) { + + callbacks.shift()(Module); + } + }; + var onPostRuns = []; + var addOnPostRun = (cb) => onPostRuns.push(cb); + + var onPreRuns = []; + var addOnPreRun = (cb) => onPreRuns.push(cb); + + + + + function getValue(ptr, type = 'i8') { + if (type.endsWith('*')) type = '*'; + switch (type) { + case 'i1': return HEAP8[ptr]; + case 'i8': return HEAP8[ptr]; + case 'i16': return HEAP16[((ptr)>>1)]; + case 'i32': return HEAP32[((ptr)>>2)]; + case 'i64': return HEAP64[((ptr)>>3)]; + case 'float': return HEAPF32[((ptr)>>2)]; + case 'double': return HEAPF64[((ptr)>>3)]; + case '*': return HEAPU32[((ptr)>>2)]; + default: abort(`invalid type for getValue: ${type}`); + } + } + + var noExitRuntime = true; + + + + function setValue(ptr, value, type = 'i8') { + if (type.endsWith('*')) type = '*'; + switch (type) { + case 'i1': HEAP8[ptr] = value; break; + case 'i8': HEAP8[ptr] = value; break; + case 'i16': HEAP16[((ptr)>>1)] = value; break; + case 'i32': HEAP32[((ptr)>>2)] = value; break; + case 'i64': HEAP64[((ptr)>>3)] = BigInt(value); break; + case 'float': HEAPF32[((ptr)>>2)] = value; break; + case 'double': HEAPF64[((ptr)>>3)] = value; break; + case '*': HEAPU32[((ptr)>>2)] = value; break; + default: abort(`invalid type for setValue: ${type}`); + } + } + + var stackRestore = (val) => __emscripten_stack_restore(val); + + var stackSave = () => _emscripten_stack_get_current(); + + var wasmMemory; + + var PATH = { + isAbs:(path) => path.charAt(0) === '/', + splitPath:(filename) => { + var splitPathRe = /^(\/?|)([\s\S]*?)((?:\.{1,2}|[^\/]+?|)(\.[^.\/]*|))(?:[\/]*)$/; + return splitPathRe.exec(filename).slice(1); + }, + normalizeArray:(parts, allowAboveRoot) => { + + var up = 0; + for (var i = parts.length - 1; i >= 0; i--) { + var last = parts[i]; + if (last === '.') { + parts.splice(i, 1); + } else if (last === '..') { + parts.splice(i, 1); + up++; + } else if (up) { + parts.splice(i, 1); + up--; + } + } + + if (allowAboveRoot) { + for (; up; up--) { + parts.unshift('..'); + } + } + return parts; + }, + normalize:(path) => { + var isAbsolute = PATH.isAbs(path), + trailingSlash = path.slice(-1) === '/'; + + path = PATH.normalizeArray(path.split('/').filter((p) => !!p), !isAbsolute).join('/'); + if (!path && !isAbsolute) { + path = '.'; + } + if (path && trailingSlash) { + path += '/'; + } + return (isAbsolute ? '/' : '') + path; + }, + dirname:(path) => { + var result = PATH.splitPath(path), + root = result[0], + dir = result[1]; + if (!root && !dir) { + + return '.'; + } + if (dir) { + + dir = dir.slice(0, -1); + } + return root + dir; + }, + basename:(path) => path && path.match(/([^\/]+|\/)\/*$/)[1], +join:(...paths) => PATH.normalize(paths.join('/')), +join2:(l, r) => PATH.normalize(l + '/' + r), +}; + +var initRandomFill = () => { + + return (view) => (crypto.getRandomValues(view), 0); + }; +var randomFill = (view) => (randomFill = initRandomFill())(view); + + + +var PATH_FS = { +resolve:(...args) => { + var resolvedPath = '', + resolvedAbsolute = false; + for (var i = args.length - 1; i >= -1 && !resolvedAbsolute; i--) { + var path = (i >= 0) ? args[i] : FS.cwd(); + + if (typeof path != 'string') { + throw new TypeError('Arguments to path.resolve must be strings'); + } else if (!path) { + return ''; + } + resolvedPath = path + '/' + resolvedPath; + resolvedAbsolute = PATH.isAbs(path); + } + + + resolvedPath = PATH.normalizeArray(resolvedPath.split('/').filter((p) => !!p), !resolvedAbsolute).join('/'); + return ((resolvedAbsolute ? '/' : '') + resolvedPath) || '.'; + }, +relative:(from, to) => { + from = PATH_FS.resolve(from).slice(1); + to = PATH_FS.resolve(to).slice(1); + function trim(arr) { + var start = 0; + for (; start < arr.length; start++) { + if (arr[start] !== '') break; + } + var end = arr.length - 1; + for (; end >= 0; end--) { + if (arr[end] !== '') break; + } + if (start > end) return []; + return arr.slice(start, end - start + 1); + } + var fromParts = trim(from.split('/')); + var toParts = trim(to.split('/')); + var length = Math.min(fromParts.length, toParts.length); + var samePartsLength = length; + for (var i = 0; i < length; i++) { + if (fromParts[i] !== toParts[i]) { + samePartsLength = i; + break; + } + } + var outputParts = []; + for (var i = samePartsLength; i < fromParts.length; i++) { + outputParts.push('..'); + } + outputParts = outputParts.concat(toParts.slice(samePartsLength)); + return outputParts.join('/'); + }, +}; + + +var UTF8Decoder = new TextDecoder(); + + + + var findStringEnd = (heapOrArray, idx, maxBytesToRead, ignoreNul) => { + var maxIdx = idx + maxBytesToRead; + if (ignoreNul) return maxIdx; + + + + + while (heapOrArray[idx] && !(idx >= maxIdx)) ++idx; + return idx; + }; + + + var UTF8ArrayToString = (heapOrArray, idx = 0, maxBytesToRead, ignoreNul) => { + + var endPtr = findStringEnd(heapOrArray, idx, maxBytesToRead, ignoreNul); + + return UTF8Decoder.decode(heapOrArray.buffer ? heapOrArray.subarray(idx, endPtr) : new Uint8Array(heapOrArray.slice(idx, endPtr))); + }; + + var FS_stdin_getChar_buffer = []; + + var lengthBytesUTF8 = (str) => { + var len = 0; + for (var i = 0; i < str.length; ++i) { + + + + + var c = str.charCodeAt(i); + if (c <= 0x7F) { + len++; + } else if (c <= 0x7FF) { + len += 2; + } else if (c >= 0xD800 && c <= 0xDFFF) { + len += 4; ++i; + } else { + len += 3; + } + } + return len; + }; + + var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { + + + if (!(maxBytesToWrite > 0)) + return 0; + + var startIdx = outIdx; + var endIdx = outIdx + maxBytesToWrite - 1; + for (var i = 0; i < str.length; ++i) { + + + + var u = str.codePointAt(i); + if (u <= 0x7F) { + if (outIdx >= endIdx) break; + heap[outIdx++] = u; + } else if (u <= 0x7FF) { + if (outIdx + 1 >= endIdx) break; + heap[outIdx++] = 0xC0 | (u >> 6); + heap[outIdx++] = 0x80 | (u & 63); + } else if (u <= 0xFFFF) { + if (outIdx + 2 >= endIdx) break; + heap[outIdx++] = 0xE0 | (u >> 12); + heap[outIdx++] = 0x80 | ((u >> 6) & 63); + heap[outIdx++] = 0x80 | (u & 63); + } else { + if (outIdx + 3 >= endIdx) break; + heap[outIdx++] = 0xF0 | (u >> 18); + heap[outIdx++] = 0x80 | ((u >> 12) & 63); + heap[outIdx++] = 0x80 | ((u >> 6) & 63); + heap[outIdx++] = 0x80 | (u & 63); + + + i++; + } + } + + heap[outIdx] = 0; + return outIdx - startIdx; + }; + + var intArrayFromString = (stringy, dontAddNull, length) => { + var len = length > 0 ? length : lengthBytesUTF8(stringy)+1; + var u8array = new Array(len); + var numBytesWritten = stringToUTF8Array(stringy, u8array, 0, u8array.length); + if (dontAddNull) u8array.length = numBytesWritten; + return u8array; + }; + var FS_stdin_getChar = () => { + if (!FS_stdin_getChar_buffer.length) { + var result = null; + if (globalThis.window?.prompt) { + + result = window.prompt('Input: '); + if (result !== null) { + result += '\n'; + } + } else + {} + if (!result) { + return null; + } + FS_stdin_getChar_buffer = intArrayFromString(result, true); + } + return FS_stdin_getChar_buffer.shift(); + }; + var TTY = { + ttys:[], + init() { + + + + + + + + + }, + shutdown() { + + + + + + + + + + }, + register(dev, ops) { + TTY.ttys[dev] = { input: [], output: [], ops: ops }; + FS.registerDevice(dev, TTY.stream_ops); + }, + stream_ops:{ + open(stream) { + var tty = TTY.ttys[stream.node.rdev]; + if (!tty) { + throw new FS.ErrnoError(43); + } + stream.tty = tty; + stream.seekable = false; + }, + close(stream) { + + stream.tty.ops.fsync(stream.tty); + }, + fsync(stream) { + stream.tty.ops.fsync(stream.tty); + }, + read(stream, buffer, offset, length, pos ) { + if (!stream.tty || !stream.tty.ops.get_char) { + throw new FS.ErrnoError(60); + } + var bytesRead = 0; + for (var i = 0; i < length; i++) { + var result; + try { + result = stream.tty.ops.get_char(stream.tty); + } catch (e) { + throw new FS.ErrnoError(29); + } + if (result === undefined && bytesRead === 0) { + throw new FS.ErrnoError(6); + } + if (result === null || result === undefined) break; + bytesRead++; + buffer[offset+i] = result; + } + if (bytesRead) { + stream.node.atime = Date.now(); + } + return bytesRead; + }, + write(stream, buffer, offset, length, pos) { + if (!stream.tty || !stream.tty.ops.put_char) { + throw new FS.ErrnoError(60); + } + try { + for (var i = 0; i < length; i++) { + stream.tty.ops.put_char(stream.tty, buffer[offset+i]); + } + } catch (e) { + throw new FS.ErrnoError(29); + } + if (length) { + stream.node.mtime = stream.node.ctime = Date.now(); + } + return i; + }, + }, + default_tty_ops:{ + get_char(tty) { + return FS_stdin_getChar(); + }, + put_char(tty, val) { + if (val === null || val === 10) { + out(UTF8ArrayToString(tty.output)); + tty.output = []; + } else { + if (val != 0) tty.output.push(val); + } + }, + fsync(tty) { + if (tty.output?.length > 0) { + out(UTF8ArrayToString(tty.output)); + tty.output = []; + } + }, + ioctl_tcgets(tty) { + + return { + c_iflag: 25856, + c_oflag: 5, + c_cflag: 191, + c_lflag: 35387, + c_cc: [ + 0x03, 0x1c, 0x7f, 0x15, 0x04, 0x00, 0x01, 0x00, 0x11, 0x13, 0x1a, 0x00, + 0x12, 0x0f, 0x17, 0x16, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + ] + }; + }, + ioctl_tcsets(tty, optional_actions, data) { + + return 0; + }, + ioctl_tiocgwinsz(tty) { + return [24, 80]; + }, + }, + default_tty1_ops:{ + put_char(tty, val) { + if (val === null || val === 10) { + err(UTF8ArrayToString(tty.output)); + tty.output = []; + } else { + if (val != 0) tty.output.push(val); + } + }, + fsync(tty) { + if (tty.output?.length > 0) { + err(UTF8ArrayToString(tty.output)); + tty.output = []; + } + }, + }, + }; + + + var zeroMemory = (ptr, size) => HEAPU8.fill(0, ptr, ptr + size); + + var alignMemory = (size, alignment) => { + return Math.ceil(size / alignment) * alignment; + }; + var mmapAlloc = (size) => { + size = alignMemory(size, 65536); + var ptr = _emscripten_builtin_memalign(65536, size); + if (ptr) zeroMemory(ptr, size); + return ptr; + }; + var MEMFS = { + ops_table:null, + mount(mount) { + return MEMFS.createNode(null, '/', 16895, 0); + }, + createNode(parent, name, mode, dev) { + if (FS.isBlkdev(mode) || FS.isFIFO(mode)) { + + throw new FS.ErrnoError(63); + } + MEMFS.ops_table ||= { + dir: { + node: { + getattr: MEMFS.node_ops.getattr, + setattr: MEMFS.node_ops.setattr, + lookup: MEMFS.node_ops.lookup, + mknod: MEMFS.node_ops.mknod, + rename: MEMFS.node_ops.rename, + unlink: MEMFS.node_ops.unlink, + rmdir: MEMFS.node_ops.rmdir, + readdir: MEMFS.node_ops.readdir, + symlink: MEMFS.node_ops.symlink + }, + stream: { + llseek: MEMFS.stream_ops.llseek + } + }, + file: { + node: { + getattr: MEMFS.node_ops.getattr, + setattr: MEMFS.node_ops.setattr + }, + stream: { + llseek: MEMFS.stream_ops.llseek, + read: MEMFS.stream_ops.read, + write: MEMFS.stream_ops.write, + mmap: MEMFS.stream_ops.mmap, + msync: MEMFS.stream_ops.msync + } + }, + link: { + node: { + getattr: MEMFS.node_ops.getattr, + setattr: MEMFS.node_ops.setattr, + readlink: MEMFS.node_ops.readlink + }, + stream: {} + }, + chrdev: { + node: { + getattr: MEMFS.node_ops.getattr, + setattr: MEMFS.node_ops.setattr + }, + stream: FS.chrdev_stream_ops + } + }; + var node = FS.createNode(parent, name, mode, dev); + if (FS.isDir(node.mode)) { + node.node_ops = MEMFS.ops_table.dir.node; + node.stream_ops = MEMFS.ops_table.dir.stream; + node.contents = {}; + } else if (FS.isFile(node.mode)) { + node.node_ops = MEMFS.ops_table.file.node; + node.stream_ops = MEMFS.ops_table.file.stream; + + + node.usedBytes = 0; + + + + + node.contents = MEMFS.emptyFileContents ??= new Uint8Array(0); + } else if (FS.isLink(node.mode)) { + node.node_ops = MEMFS.ops_table.link.node; + node.stream_ops = MEMFS.ops_table.link.stream; + } else if (FS.isChrdev(node.mode)) { + node.node_ops = MEMFS.ops_table.chrdev.node; + node.stream_ops = MEMFS.ops_table.chrdev.stream; + } + node.atime = node.mtime = node.ctime = Date.now(); + + if (parent) { + parent.contents[name] = node; + parent.atime = parent.mtime = parent.ctime = node.atime; + } + return node; + }, + getFileDataAsTypedArray(node) { + return node.contents.subarray(0, node.usedBytes); + }, + expandFileStorage(node, newCapacity) { + var prevCapacity = node.contents.length; + if (prevCapacity >= newCapacity) return; + + + + + + var CAPACITY_DOUBLING_MAX = 1024 * 1024; + newCapacity = Math.max(newCapacity, (prevCapacity * (prevCapacity < CAPACITY_DOUBLING_MAX ? 2.0 : 1.125)) >>> 0); + if (prevCapacity) newCapacity = Math.max(newCapacity, 256); + var oldContents = MEMFS.getFileDataAsTypedArray(node); + node.contents = new Uint8Array(newCapacity); + node.contents.set(oldContents); + }, + resizeFileStorage(node, newSize) { + if (node.usedBytes == newSize) return; + var oldContents = node.contents; + node.contents = new Uint8Array(newSize); + node.contents.set(oldContents.subarray(0, Math.min(newSize, node.usedBytes))); + node.usedBytes = newSize; + }, + node_ops:{ + getattr(node) { + var attr = {}; + + attr.dev = FS.isChrdev(node.mode) ? node.id : 1; + attr.ino = node.id; + attr.mode = node.mode; + attr.nlink = 1; + attr.uid = 0; + attr.gid = 0; + attr.rdev = node.rdev; + if (FS.isDir(node.mode)) { + attr.size = 4096; + } else if (FS.isFile(node.mode)) { + attr.size = node.usedBytes; + } else if (FS.isLink(node.mode)) { + attr.size = node.link.length; + } else { + attr.size = 0; + } + attr.atime = new Date(node.atime); + attr.mtime = new Date(node.mtime); + attr.ctime = new Date(node.ctime); + + + attr.blksize = 4096; + attr.blocks = Math.ceil(attr.size / attr.blksize); + return attr; + }, + setattr(node, attr) { + for (const key of ["mode", "atime", "mtime", "ctime"]) { + if (attr[key] != null) { + node[key] = attr[key]; + } + } + if (attr.size !== undefined) { + MEMFS.resizeFileStorage(node, attr.size); + } + }, + lookup(parent, name) { + + + if (!MEMFS.doesNotExistError) { + MEMFS.doesNotExistError = new FS.ErrnoError(44); + + MEMFS.doesNotExistError.stack = ''; + } + throw MEMFS.doesNotExistError; + }, + mknod(parent, name, mode, dev) { + return MEMFS.createNode(parent, name, mode, dev); + }, + rename(old_node, new_dir, new_name) { + var new_node; + try { + new_node = FS.lookupNode(new_dir, new_name); + } catch (e) {} + if (new_node) { + if (FS.isDir(old_node.mode)) { + + for (var i in new_node.contents) { + throw new FS.ErrnoError(55); + } + } + FS.hashRemoveNode(new_node); + } + + delete old_node.parent.contents[old_node.name]; + new_dir.contents[new_name] = old_node; + old_node.name = new_name; + new_dir.ctime = new_dir.mtime = old_node.parent.ctime = old_node.parent.mtime = Date.now(); + }, + unlink(parent, name) { + delete parent.contents[name]; + parent.ctime = parent.mtime = Date.now(); + }, + rmdir(parent, name) { + var node = FS.lookupNode(parent, name); + for (var i in node.contents) { + throw new FS.ErrnoError(55); + } + delete parent.contents[name]; + parent.ctime = parent.mtime = Date.now(); + }, + readdir(node) { + return ['.', '..', ...Object.keys(node.contents)]; + }, + symlink(parent, newname, oldpath) { + var node = MEMFS.createNode(parent, newname, 0o777 | 40960, 0); + node.link = oldpath; + return node; + }, + readlink(node) { + if (!FS.isLink(node.mode)) { + throw new FS.ErrnoError(28); + } + return node.link; + }, + }, + stream_ops:{ + read(stream, buffer, offset, length, position) { + var contents = stream.node.contents; + if (position >= stream.node.usedBytes) return 0; + var size = Math.min(stream.node.usedBytes - position, length); + buffer.set(contents.subarray(position, position + size), offset); + return size; + }, + write(stream, buffer, offset, length, position, canOwn) { + + + + + if (buffer.buffer === HEAP8.buffer) { + canOwn = false; + } + + if (!length) return 0; + var node = stream.node; + node.mtime = node.ctime = Date.now(); + + if (canOwn) { + node.contents = buffer.subarray(offset, offset + length); + node.usedBytes = length; + } else if (node.usedBytes === 0 && position === 0) { + node.contents = buffer.slice(offset, offset + length); + node.usedBytes = length; + } else { + MEMFS.expandFileStorage(node, position+length); + + node.contents.set(buffer.subarray(offset, offset + length), position); + node.usedBytes = Math.max(node.usedBytes, position + length); + } + return length; + }, + llseek(stream, offset, whence) { + var position = offset; + if (whence === 1) { + position += stream.position; + } else if (whence === 2) { + if (FS.isFile(stream.node.mode)) { + position += stream.node.usedBytes; + } + } + if (position < 0) { + throw new FS.ErrnoError(28); + } + return position; + }, + mmap(stream, length, position, prot, flags) { + if (!FS.isFile(stream.node.mode)) { + throw new FS.ErrnoError(43); + } + var ptr; + var allocated; + var contents = stream.node.contents; + + if (!(flags & 2) && contents.buffer === HEAP8.buffer) { + + + allocated = false; + ptr = contents.byteOffset; + } else { + allocated = true; + ptr = mmapAlloc(length); + if (!ptr) { + throw new FS.ErrnoError(48); + } + if (contents) { + + if (position > 0 || position + length < contents.length) { + if (contents.subarray) { + contents = contents.subarray(position, position + length); + } else { + contents = Array.prototype.slice.call(contents, position, position + length); + } + } + HEAP8.set(contents, ptr); + } + } + return { ptr, allocated }; + }, + msync(stream, buffer, offset, length, mmapFlags) { + MEMFS.stream_ops.write(stream, buffer, 0, length, offset, false); + + return 0; + }, + }, + }; + + var FS_modeStringToFlags = (str) => { + if (typeof str != 'string') return str; + var flagModes = { + 'r': 0, + 'r+': 2, + 'w': 512 | 64 | 1, + 'w+': 512 | 64 | 2, + 'a': 1024 | 64 | 1, + 'a+': 1024 | 64 | 2, + }; + var flags = flagModes[str]; + if (typeof flags == 'undefined') { + throw new Error(`Unknown file open mode: ${str}`); + } + return flags; + }; + + var FS_fileDataToTypedArray = (data) => { + if (typeof data == 'string') { + data = intArrayFromString(data, true); + } + if (!data.subarray) { + data = new Uint8Array(data); + } + return data; + }; + + var FS_getMode = (canRead, canWrite) => { + var mode = 0; + if (canRead) mode |= 292 | 73; + if (canWrite) mode |= 146; + return mode; + }; + + + var asyncLoad = async (url) => { + var arrayBuffer = await readAsync(url); + return new Uint8Array(arrayBuffer); + }; + + + var FS_createDataFile = (...args) => FS.createDataFile(...args); + + var getUniqueRunDependency = (id) => { + return id; + }; + + var dependenciesPromise = null; + var resolveRunDependencies = async () => dependenciesPromise; + var runDependencies = 0; + + + var dependenciesPromiseResolve = null; + var removeRunDependency = (id) => { + runDependencies--; + + Module['monitorRunDependencies']?.(runDependencies); + + if (!runDependencies) { + dependenciesPromiseResolve(); + } + }; + + + var addRunDependency = (id) => { + if (!runDependencies) { + dependenciesPromise = new Promise((resolve) => dependenciesPromiseResolve = resolve); + } + runDependencies++; + + Module['monitorRunDependencies']?.(runDependencies); + + }; + + + var preloadPlugins = []; + var FS_handledByPreloadPlugin = async (byteArray, fullname) => { + + if (typeof Browser != 'undefined') Browser.init(); + + for (var plugin of preloadPlugins) { + if (plugin['canHandle'](fullname)) { + return plugin['handle'](byteArray, fullname); + } + } + + + return byteArray; + }; + var FS_preloadFile = async (parent, name, url, canRead, canWrite, dontCreateFile, canOwn, preFinish) => { + + + var fullname = name ? PATH_FS.resolve(PATH.join2(parent, name)) : parent; + var dep = getUniqueRunDependency(`cp ${fullname}`); + addRunDependency(dep); + + try { + var byteArray = url; + if (typeof url == 'string') { + byteArray = await asyncLoad(url); + } + + byteArray = await FS_handledByPreloadPlugin(byteArray, fullname); + preFinish?.(); + if (!dontCreateFile) { + FS_createDataFile(parent, name, byteArray, canRead, canWrite, canOwn); + } + } finally { + removeRunDependency(dep); + } + }; + var FS_createPreloadedFile = (parent, name, url, canRead, canWrite, onload, onerror, dontCreateFile, canOwn, preFinish) => { + FS_preloadFile(parent, name, url, canRead, canWrite, dontCreateFile, canOwn, preFinish).then(onload).catch(onerror); + }; + var FS = { + root:null, + mounts:[], + devices:{ + }, + streams:[], + nextInode:1, + nameTable:null, + currentPath:"/", + initialized:false, + ignorePermissions:true, + filesystems:null, + syncFSRequests:0, + ErrnoError:class { + name = 'ErrnoError'; + + + + + + + constructor(errno) { + this.errno = errno; + } + }, + FSStream:class { + shared = {}; + get object() { + return this.node; + } + set object(val) { + this.node = val; + } + get isRead() { + return (this.flags & 2097155) !== 1; + } + get isWrite() { + return (this.flags & 2097155) !== 0; + } + get isAppend() { + return (this.flags & 1024); + } + get flags() { + return this.shared.flags; + } + set flags(val) { + this.shared.flags = val; + } + get position() { + return this.shared.position; + } + set position(val) { + this.shared.position = val; + } + }, + FSNode:class { + node_ops = {}; + stream_ops = {}; + readMode = 292 | 73; + writeMode = 146; + mounted = null; + constructor(parent, name, mode, rdev) { + if (!parent) { + parent = this; + } + this.parent = parent; + this.mount = parent.mount; + this.id = FS.nextInode++; + this.name = name; + this.mode = mode; + this.rdev = rdev; + this.atime = this.mtime = this.ctime = Date.now(); + } + get read() { + return (this.mode & this.readMode) === this.readMode; + } + set read(val) { + val ? this.mode |= this.readMode : this.mode &= ~this.readMode; + } + get write() { + return (this.mode & this.writeMode) === this.writeMode; + } + set write(val) { + val ? this.mode |= this.writeMode : this.mode &= ~this.writeMode; + } + get isFolder() { + return FS.isDir(this.mode); + } + get isDevice() { + return FS.isChrdev(this.mode); + } + + + + + + + addListener(cb, exclusive = false) { + var entry = {cb, exclusive}; + var listeners = (this.listeners ??= new Set()); + listeners.add(entry); + return {listeners, entry}; + } + notifyListeners(flags) { + + + + + + + + + + + + + + if (!this.listeners) return; + + + + + var excl; + for (var entry of this.listeners) { + if (entry.exclusive) (excl ||= []).push(entry); + else entry.cb(flags); + } + if (excl) { + var i = (this.exclTurn || 0) % excl.length; + this.exclTurn = i + 1; + excl[i].cb(flags); + } + } + }, + lookupPath(path, opts = {}) { + if (!path) { + throw new FS.ErrnoError(44); + } + opts.follow_mount ??= true + + if (!PATH.isAbs(path)) { + path = FS.cwd() + '/' + path; + } + + + linkloop: for (var nlinks = 0; nlinks < 40; nlinks++) { + + var parts = path.split('/').filter((p) => !!p); + + + var current = FS.root; + var current_path = '/'; + + for (var i = 0; i < parts.length; i++) { + var islast = (i === parts.length-1); + if (islast && opts.parent) { + + break; + } + + if (parts[i] === '.') { + continue; + } + + if (parts[i] === '..') { + current_path = PATH.dirname(current_path); + if (FS.isRoot(current)) { + path = current_path + '/' + parts.slice(i + 1).join('/'); + + + nlinks--; + continue linkloop; + } else { + current = current.parent; + } + continue; + } + + current_path = PATH.join2(current_path, parts[i]); + try { + current = FS.lookupNode(current, parts[i]); + } catch (e) { + + + + if ((e?.errno === 44) && islast && opts.noent_okay) { + return { path: current_path }; + } + throw e; + } + + + if (FS.isMountpoint(current) && (!islast || opts.follow_mount)) { + current = current.mounted.root; + } + + + + if (FS.isLink(current.mode) && (!islast || opts.follow)) { + if (!current.node_ops.readlink) { + throw new FS.ErrnoError(52); + } + var link = current.node_ops.readlink(current); + if (!PATH.isAbs(link)) { + link = PATH.dirname(current_path) + '/' + link; + } + path = link + '/' + parts.slice(i + 1).join('/'); + continue linkloop; + } + } + return { path: current_path, node: current }; + } + throw new FS.ErrnoError(32); + }, + getPath(node) { + var path; + while (true) { + if (FS.isRoot(node)) { + var mount = node.mount.mountpoint; + if (!path) return mount; + return mount[mount.length-1] !== '/' ? `${mount}/${path}` : mount + path; + } + path = path ? `${node.name}/${path}` : node.name; + node = node.parent; + } + }, + hashName(parentid, name) { + var hash = 0; + + for (var i = 0; i < name.length; i++) { + hash = ((hash << 5) - hash + name.charCodeAt(i)) | 0; + } + return ((parentid + hash) >>> 0) % FS.nameTable.length; + }, + hashAddNode(node) { + var hash = FS.hashName(node.parent.id, node.name); + node.name_next = FS.nameTable[hash]; + FS.nameTable[hash] = node; + }, + hashRemoveNode(node) { + var hash = FS.hashName(node.parent.id, node.name); + if (FS.nameTable[hash] === node) { + FS.nameTable[hash] = node.name_next; + } else { + var current = FS.nameTable[hash]; + while (current) { + if (current.name_next === node) { + current.name_next = node.name_next; + break; + } + current = current.name_next; + } + } + }, + lookupNode(parent, name) { + var errCode = FS.mayLookup(parent); + if (errCode) { + throw new FS.ErrnoError(errCode); + } + var hash = FS.hashName(parent.id, name); + for (var node = FS.nameTable[hash]; node; node = node.name_next) { + var nodeName = node.name; + if (node.parent.id === parent.id && nodeName === name) { + return node; + } + } + + return FS.lookup(parent, name); + }, + createNode(parent, name, mode, rdev) { + var node = new FS.FSNode(parent, name, mode, rdev); + + FS.hashAddNode(node); + + return node; + }, + destroyNode(node) { + FS.hashRemoveNode(node); + }, + isRoot(node) { + return node === node.parent; + }, + isMountpoint(node) { + return !!node.mounted; + }, + isFile(mode) { + return (mode & 61440) === 32768; + }, + isDir(mode) { + return (mode & 61440) === 16384; + }, + isLink(mode) { + return (mode & 61440) === 40960; + }, + isChrdev(mode) { + return (mode & 61440) === 8192; + }, + isBlkdev(mode) { + return (mode & 61440) === 24576; + }, + isFIFO(mode) { + return (mode & 61440) === 4096; + }, + isSocket(mode) { + return (mode & 49152) === 49152; + }, + flagsToPermissionString(flag) { + var perms = ['r', 'w', 'rw'][flag & 3]; + if ((flag & 512)) { + perms += 'w'; + } + return perms; + }, + nodePermissions(node, perms) { + if (FS.ignorePermissions) { + return 0; + } + + if (perms.includes('r') && !(node.mode & 292)) { + return 2; + } + if (perms.includes('w') && !(node.mode & 146)) { + return 2; + } + if (perms.includes('x') && !(node.mode & 73)) { + return 2; + } + return 0; + }, + mayLookup(dir) { + if (!FS.isDir(dir.mode)) return 54; + var errCode = FS.nodePermissions(dir, 'x'); + if (errCode) return errCode; + if (!dir.node_ops.lookup) return 2; + return 0; + }, + mayCreate(dir, name) { + if (!FS.isDir(dir.mode)) { + return 54; + } + try { + var node = FS.lookupNode(dir, name); + return 20; + } catch (e) { + } + return FS.nodePermissions(dir, 'wx'); + }, + mayDelete(dir, name, isdir) { + var node; + try { + node = FS.lookupNode(dir, name); + } catch (e) { + return e.errno; + } + var errCode = FS.nodePermissions(dir, 'wx'); + if (errCode) { + return errCode; + } + if (isdir) { + if (!FS.isDir(node.mode)) { + return 54; + } + if (FS.isRoot(node) || FS.getPath(node) === FS.cwd()) { + return 10; + } + } else if (FS.isDir(node.mode)) { + return 31; + } + return 0; + }, + mayOpen(node, flags) { + if (!node) { + return 44; + } + if (FS.isLink(node.mode)) { + return 32; + } + var mode = FS.flagsToPermissionString(flags); + if (FS.isDir(node.mode)) { + + + if (mode !== 'r' || (flags & (512 | 64))) { + return 31; + } + } + return FS.nodePermissions(node, mode); + }, + checkOpExists(op, err) { + if (!op) { + throw new FS.ErrnoError(err); + } + return op; + }, + MAX_OPEN_FDS:4096, + nextfd() { + for (var fd = 0; fd <= FS.MAX_OPEN_FDS; fd++) { + if (!FS.streams[fd]) { + return fd; + } + } + throw new FS.ErrnoError(33); + }, + getStreamChecked(fd) { + var stream = FS.getStream(fd); + if (!stream) { + throw new FS.ErrnoError(8); + } + return stream; + }, + getStream:(fd) => FS.streams[fd], + createStream(stream, fd = -1) { + + + stream = Object.assign(new FS.FSStream(), stream); + if (fd == -1) { + fd = FS.nextfd(); + } + stream.fd = fd; + FS.streams[fd] = stream; + return stream; + }, + closeStream(fd) { + FS.streams[fd] = null; + }, + dupStream(origStream, fd = -1) { + var stream = FS.createStream(origStream, fd); + stream.stream_ops?.dup?.(stream); + return stream; + }, + doSetAttr(stream, node, attr) { + var setattr = stream?.stream_ops.setattr; + var arg = setattr ? stream : node; + setattr ??= node.node_ops.setattr; + FS.checkOpExists(setattr, 63) + try { + setattr(arg, attr); + } catch (e) { + if (e instanceof RangeError) { + throw new FS.ErrnoError(22); + } + throw e; + } + }, + chrdev_stream_ops:{ + open(stream) { + var device = FS.getDevice(stream.node.rdev); + + stream.stream_ops = device.stream_ops; + + stream.stream_ops.open?.(stream); + }, + llseek() { + throw new FS.ErrnoError(70); + }, + }, + major:(dev) => ((dev) >> 8), + minor:(dev) => ((dev) & 0xff), + makedev:(ma, mi) => ((ma) << 8 | (mi)), + registerDevice(dev, ops) { + FS.devices[dev] = { stream_ops: ops }; + }, + getDevice:(dev) => FS.devices[dev], + getMounts(mount) { + var mounts = []; + var check = [mount]; + + while (check.length) { + var m = check.pop(); + + mounts.push(m); + + check.push(...m.mounts); + } + + return mounts; + }, + syncfs(populate, callback) { + if (typeof populate == 'function') { + callback = populate; + populate = false; + } + + FS.syncFSRequests++; + + if (FS.syncFSRequests > 1) { + err(`warning: ${FS.syncFSRequests} FS.syncfs operations in flight at once, probably just doing extra work`); + } + + var mounts = FS.getMounts(FS.root.mount); + var completed = 0; + + function doCallback(errCode) { + FS.syncFSRequests--; + return callback(errCode); + } + + function done(errCode) { + if (errCode) { + if (!done.errored) { + done.errored = true; + return doCallback(errCode); + } + return; + } + if (++completed >= mounts.length) { + doCallback(null); + } + }; + + + for (var mount of mounts) { + if (mount.type.syncfs) { + mount.type.syncfs(mount, populate, done); + } else { + done(null); + } + } + }, + mount(type, opts, mountpoint) { + var root = mountpoint === '/'; + var pseudo = !mountpoint; + var node; + + if (root && FS.root) { + throw new FS.ErrnoError(10); + } else if (!root && !pseudo) { + var lookup = FS.lookupPath(mountpoint, { follow_mount: false }); + + mountpoint = lookup.path; + node = lookup.node; + + if (FS.isMountpoint(node)) { + throw new FS.ErrnoError(10); + } + + if (!FS.isDir(node.mode)) { + throw new FS.ErrnoError(54); + } + } + + var mount = { + type, + opts, + mountpoint, + mounts: [] + }; + + + var mountRoot = type.mount(mount); + mountRoot.mount = mount; + mount.root = mountRoot; + + if (root) { + FS.root = mountRoot; + } else if (node) { + + node.mounted = mount; + + + if (node.mount) { + node.mount.mounts.push(mount); + } + } + + return mountRoot; + }, + unmount(mountpoint) { + var lookup = FS.lookupPath(mountpoint, { follow_mount: false }); + + if (!FS.isMountpoint(lookup.node)) { + throw new FS.ErrnoError(28); + } + + + var node = lookup.node; + var mount = node.mounted; + var mounts = FS.getMounts(mount); + + for (var [hash, current] of Object.entries(FS.nameTable)) { + while (current) { + var next = current.name_next; + + if (mounts.includes(current.mount)) { + FS.destroyNode(current); + } + + current = next; + } + } + + + node.mounted = null; + + + var idx = node.mount.mounts.indexOf(mount); + node.mount.mounts.splice(idx, 1); + }, + lookup(parent, name) { + return parent.node_ops.lookup(parent, name); + }, + mknod(path, mode, dev) { + var lookup = FS.lookupPath(path, { parent: true }); + var parent = lookup.node; + var name = PATH.basename(path); + if (!name) { + throw new FS.ErrnoError(28); + } + if (name === '.' || name === '..') { + throw new FS.ErrnoError(20); + } + var errCode = FS.mayCreate(parent, name); + if (errCode) { + throw new FS.ErrnoError(errCode); + } + if (!parent.node_ops.mknod) { + throw new FS.ErrnoError(63); + } + return parent.node_ops.mknod(parent, name, mode, dev); + }, + statfs(path) { + return FS.statfsNode(FS.lookupPath(path, {follow: true}).node); + }, + statfsStream(stream) { + + + + return FS.statfsNode(stream.node); + }, + statfsNode(node) { + + + + var rtn = { + bsize: 4096, + frsize: 4096, + blocks: 1e6, + bfree: 5e5, + bavail: 5e5, + files: FS.nextInode, + ffree: FS.nextInode - 1, + fsid: 42, + flags: 2, + namelen: 255, + }; + + if (node.node_ops.statfs) { + Object.assign(rtn, node.node_ops.statfs(node.mount.opts.root)); + } + return rtn; + }, + create(path, mode = 0o666) { + mode &= 4095; + mode |= 32768; + return FS.mknod(path, mode, 0); + }, + mkdir(path, mode = 0o777) { + mode &= 511 | 512; + mode |= 16384; + return FS.mknod(path, mode, 0); + }, + mkdirTree(path, mode) { + var dirs = path.split('/'); + var d = ''; + for (var dir of dirs) { + if (!dir) continue; + if (d || PATH.isAbs(path)) d += '/'; + d += dir; + try { + FS.mkdir(d, mode); + } catch(e) { + if (e.errno != 20) throw e; + } + } + }, + mkdev(path, mode, dev) { + if (typeof dev == 'undefined') { + dev = mode; + mode = 0o666; + } + mode |= 8192; + return FS.mknod(path, mode, dev); + }, + symlink(oldpath, newpath) { + if (!PATH_FS.resolve(oldpath)) { + throw new FS.ErrnoError(44); + } + var lookup = FS.lookupPath(newpath, { parent: true }); + var parent = lookup.node; + if (!parent) { + throw new FS.ErrnoError(44); + } + var newname = PATH.basename(newpath); + var errCode = FS.mayCreate(parent, newname); + if (errCode) { + throw new FS.ErrnoError(errCode); + } + if (!parent.node_ops.symlink) { + throw new FS.ErrnoError(63); + } + return parent.node_ops.symlink(parent, newname, oldpath); + }, + link(oldpath, newpath, flags) { + var lookup = FS.lookupPath(newpath, { parent: true }); + var parent = lookup.node; + if (!parent) { + throw new FS.ErrnoError(44); + } + var newname = PATH.basename(newpath); + var errCode = FS.mayCreate(parent, newname); + if (errCode) { + throw new FS.ErrnoError(errCode); + } + + + + if (!parent.node_ops.link) { + throw new FS.ErrnoError(34); + } + return parent.node_ops.link(parent, newname, oldpath, flags); + }, + rename(old_path, new_path) { + var old_dirname = PATH.dirname(old_path); + var new_dirname = PATH.dirname(new_path); + var old_name = PATH.basename(old_path); + var new_name = PATH.basename(new_path); + + var lookup, old_dir, new_dir; + + + lookup = FS.lookupPath(old_path, { parent: true }); + old_dir = lookup.node; + lookup = FS.lookupPath(new_path, { parent: true }); + new_dir = lookup.node; + + if (!old_dir || !new_dir) throw new FS.ErrnoError(44); + + if (old_dir.mount !== new_dir.mount) { + throw new FS.ErrnoError(75); + } + + var old_node = FS.lookupNode(old_dir, old_name); + + var relative = PATH_FS.relative(old_path, new_dirname); + if (relative.charAt(0) !== '.') { + throw new FS.ErrnoError(28); + } + + relative = PATH_FS.relative(new_path, old_dirname); + if (relative.charAt(0) !== '.') { + throw new FS.ErrnoError(55); + } + + var new_node; + try { + new_node = FS.lookupNode(new_dir, new_name); + } catch (e) { + + } + + if (old_node === new_node) { + return; + } + + var isdir = FS.isDir(old_node.mode); + var errCode = FS.mayDelete(old_dir, old_name, isdir); + if (errCode) { + throw new FS.ErrnoError(errCode); + } + + + errCode = new_node ? + FS.mayDelete(new_dir, new_name, isdir) : + FS.mayCreate(new_dir, new_name); + if (errCode) { + throw new FS.ErrnoError(errCode); + } + if (!old_dir.node_ops.rename) { + throw new FS.ErrnoError(63); + } + if (FS.isMountpoint(old_node) || (new_node && FS.isMountpoint(new_node))) { + throw new FS.ErrnoError(10); + } + + if (new_dir !== old_dir) { + errCode = FS.nodePermissions(old_dir, 'w'); + if (errCode) { + throw new FS.ErrnoError(errCode); + } + } + + FS.hashRemoveNode(old_node); + + try { + old_dir.node_ops.rename(old_node, new_dir, new_name); + + + old_node.parent = new_dir; + } catch (e) { + throw e; + } finally { + + + FS.hashAddNode(old_node); + } + }, + rmdir(path) { + var lookup = FS.lookupPath(path, { parent: true }); + var parent = lookup.node; + var name = PATH.basename(path); + var node = FS.lookupNode(parent, name); + var errCode = FS.mayDelete(parent, name, true); + if (errCode) { + throw new FS.ErrnoError(errCode); + } + if (!parent.node_ops.rmdir) { + throw new FS.ErrnoError(63); + } + if (FS.isMountpoint(node)) { + throw new FS.ErrnoError(10); + } + parent.node_ops.rmdir(parent, name); + FS.destroyNode(node); + }, + readdir(path) { + var lookup = FS.lookupPath(path, { follow: true }); + var node = lookup.node; + var readdir = FS.checkOpExists(node.node_ops.readdir, 54); + return readdir(node); + }, + unlink(path) { + var lookup = FS.lookupPath(path, { parent: true }); + var parent = lookup.node; + if (!parent) { + throw new FS.ErrnoError(44); + } + var name = PATH.basename(path); + var node = FS.lookupNode(parent, name); + var errCode = FS.mayDelete(parent, name, false); + if (errCode) { + + + + throw new FS.ErrnoError(errCode); + } + if (!parent.node_ops.unlink) { + throw new FS.ErrnoError(63); + } + if (FS.isMountpoint(node)) { + throw new FS.ErrnoError(10); + } + parent.node_ops.unlink(parent, name); + FS.destroyNode(node); + }, + readlink(path) { + var lookup = FS.lookupPath(path); + var link = lookup.node; + if (!link) { + throw new FS.ErrnoError(44); + } + if (!link.node_ops.readlink) { + throw new FS.ErrnoError(28); + } + return link.node_ops.readlink(link); + }, + stat(path, dontFollow) { + var lookup = FS.lookupPath(path, { follow: !dontFollow }); + var node = lookup.node; + var getattr = FS.checkOpExists(node.node_ops.getattr, 63); + return getattr(node); + }, + fstat(fd) { + var stream = FS.getStreamChecked(fd); + var node = stream.node; + var getattr = stream.stream_ops.getattr; + var arg = getattr ? stream : node; + getattr ??= node.node_ops.getattr; + FS.checkOpExists(getattr, 63) + return getattr(arg); + }, + lstat(path) { + return FS.stat(path, true); + }, + doChmod(stream, node, mode, dontFollow) { + FS.doSetAttr(stream, node, { + mode: (mode & 4095) | (node.mode & ~4095), + ctime: Date.now(), + dontFollow + }); + }, + chmod(path, mode, dontFollow) { + var node; + if (typeof path == 'string') { + var lookup = FS.lookupPath(path, { follow: !dontFollow }); + node = lookup.node; + } else { + node = path; + } + FS.doChmod(null, node, mode, dontFollow); + }, + lchmod(path, mode) { + FS.chmod(path, mode, true); + }, + fchmod(fd, mode) { + var stream = FS.getStreamChecked(fd); + FS.doChmod(stream, stream.node, mode, false); + }, + doChown(stream, node, dontFollow) { + FS.doSetAttr(stream, node, { + timestamp: Date.now(), + dontFollow + + }); + }, + chown(path, uid, gid, dontFollow) { + var node; + if (typeof path == 'string') { + var lookup = FS.lookupPath(path, { follow: !dontFollow }); + node = lookup.node; + } else { + node = path; + } + FS.doChown(null, node, dontFollow); + }, + lchown(path, uid, gid) { + FS.chown(path, uid, gid, true); + }, + fchown(fd, uid, gid) { + var stream = FS.getStreamChecked(fd); + FS.doChown(stream, stream.node, false); + }, + doTruncate(stream, node, len) { + if (FS.isDir(node.mode)) { + throw new FS.ErrnoError(31); + } + if (!FS.isFile(node.mode)) { + throw new FS.ErrnoError(28); + } + var errCode = FS.nodePermissions(node, 'w'); + if (errCode) { + throw new FS.ErrnoError(errCode); + } + FS.doSetAttr(stream, node, { + size: len, + timestamp: Date.now() + }); + }, + truncate(path, len) { + if (len < 0) { + throw new FS.ErrnoError(28); + } + var node; + if (typeof path == 'string') { + var lookup = FS.lookupPath(path, { follow: true }); + node = lookup.node; + } else { + node = path; + } + FS.doTruncate(null, node, len); + }, + ftruncate(fd, len) { + var stream = FS.getStreamChecked(fd); + if (len < 0 || (stream.flags & 2097155) === 0) { + throw new FS.ErrnoError(28); + } + FS.doTruncate(stream, stream.node, len); + }, + utime(path, atime, mtime, dontFollow) { + var lookup = FS.lookupPath(path, { follow: !dontFollow }); + FS.doSetAttr(null, lookup.node, { + atime: atime, + mtime: mtime, + dontFollow + }); + }, + open(path, flags, mode = 0o666) { + if (path === "") { + throw new FS.ErrnoError(44); + } + flags = FS_modeStringToFlags(flags); + if ((flags & 64)) { + mode = (mode & 4095) | 32768; + } else { + mode = 0; + } + var node; + var isDirPath; + if (typeof path == 'object') { + node = path; + } else { + isDirPath = path.endsWith("/"); + + + + var lookup = FS.lookupPath(path, { + follow: !(flags & 131072), + noent_okay: true + }); + node = lookup.node; + path = lookup.path; + } + + var created = false; + if ((flags & 64)) { + if (node) { + + if ((flags & 128)) { + throw new FS.ErrnoError(20); + } + } else if (isDirPath) { + throw new FS.ErrnoError(31); + } else { + + + + + node = FS.mknod(path, mode | 0o777, 0); + created = true; + } + } + if (!node) { + throw new FS.ErrnoError(44); + } + + if (FS.isChrdev(node.mode)) { + flags &= ~512; + } + + if ((flags & 65536) && !FS.isDir(node.mode)) { + throw new FS.ErrnoError(54); + } + + + + if (!created) { + var errCode = FS.mayOpen(node, flags); + if (errCode) { + throw new FS.ErrnoError(errCode); + } + } + + if ((flags & 512) && !created) { + FS.truncate(node, 0); + } + + flags &= ~(128 | 512 | 131072); + + + var stream = FS.createStream({ + node, + path: FS.getPath(node), + flags, + seekable: true, + position: 0, + stream_ops: node.stream_ops, + + ungotten: [], + error: false + }); + + if (stream.stream_ops.open) { + stream.stream_ops.open(stream); + } + if (created) { + FS.chmod(node, mode & 0o777); + } + return stream; + }, + close(stream) { + if (FS.isClosed(stream)) { + throw new FS.ErrnoError(8); + } + if (stream.getdents) stream.getdents = null; + + + + + stream.node?.notifyListeners(32); + try { + if (stream.stream_ops.close) { + stream.stream_ops.close(stream); + } + } catch (e) { + throw e; + } finally { + FS.closeStream(stream.fd); + } + stream.fd = null; + }, + isClosed(stream) { + return stream.fd === null; + }, + llseek(stream, offset, whence) { + if (FS.isClosed(stream)) { + throw new FS.ErrnoError(8); + } + if (!stream.seekable || !stream.stream_ops.llseek) { + throw new FS.ErrnoError(70); + } + if (whence != 0 && whence != 1 && whence != 2) { + throw new FS.ErrnoError(28); + } + stream.position = stream.stream_ops.llseek(stream, offset, whence); + stream.ungotten = []; + return stream.position; + }, + read(stream, buffer, offset, length, position) { + if (length < 0 || position < 0) { + throw new FS.ErrnoError(28); + } + if (FS.isClosed(stream)) { + throw new FS.ErrnoError(8); + } + if ((stream.flags & 2097155) === 1) { + throw new FS.ErrnoError(8); + } + if (FS.isDir(stream.node.mode)) { + throw new FS.ErrnoError(31); + } + if (!stream.stream_ops.read) { + throw new FS.ErrnoError(28); + } + var seeking = typeof position != 'undefined'; + if (!seeking) { + position = stream.position; + } else if (!stream.seekable) { + throw new FS.ErrnoError(70); + } + var bytesRead = stream.stream_ops.read(stream, buffer, offset, length, position); + if (!seeking) stream.position += bytesRead; + return bytesRead; + }, + write(stream, buffer, offset, length, position, canOwn) { + if (length < 0 || position < 0) { + throw new FS.ErrnoError(28); + } + if (FS.isClosed(stream)) { + throw new FS.ErrnoError(8); + } + if ((stream.flags & 2097155) === 0) { + throw new FS.ErrnoError(8); + } + if (FS.isDir(stream.node.mode)) { + throw new FS.ErrnoError(31); + } + if (!stream.stream_ops.write) { + throw new FS.ErrnoError(28); + } + if (stream.seekable && stream.flags & 1024) { + + FS.llseek(stream, 0, 2); + } + var seeking = typeof position != 'undefined'; + if (!seeking) { + position = stream.position; + } else if (!stream.seekable) { + throw new FS.ErrnoError(70); + } + var bytesWritten = stream.stream_ops.write(stream, buffer, offset, length, position, canOwn); + if (!seeking) stream.position += bytesWritten; + return bytesWritten; + }, + mmap(stream, length, position, prot, flags) { + + + + + + + if ((prot & 2) !== 0 + && (flags & 2) === 0 + && (stream.flags & 2097155) !== 2) { + throw new FS.ErrnoError(2); + } + if ((stream.flags & 2097155) === 1) { + throw new FS.ErrnoError(2); + } + if (!stream.stream_ops.mmap) { + throw new FS.ErrnoError(43); + } + if (!length) { + throw new FS.ErrnoError(28); + } + return stream.stream_ops.mmap(stream, length, position, prot, flags); + }, + msync(stream, buffer, offset, length, mmapFlags) { + if (!stream.stream_ops.msync) { + return 0; + } + return stream.stream_ops.msync(stream, buffer, offset, length, mmapFlags); + }, + ioctl(stream, cmd, arg) { + if (!stream.stream_ops.ioctl) { + throw new FS.ErrnoError(59); + } + return stream.stream_ops.ioctl(stream, cmd, arg); + }, + readFile(path, opts = {}) { + opts.flags = opts.flags ?? 0; + opts.encoding = opts.encoding ?? 'binary'; + if (opts.encoding !== 'utf8' && opts.encoding !== 'binary') { + abort(`Invalid encoding type "${opts.encoding}"`); + } + var stream = FS.open(path, opts.flags); + var stat = FS.stat(path); + var length = stat.size; + var buf = new Uint8Array(length); + FS.read(stream, buf, 0, length, 0); + if (opts.encoding === 'utf8') { + buf = UTF8ArrayToString(buf); + } + FS.close(stream); + return buf; + }, + writeFile(path, data, opts = {}) { + opts.flags = opts.flags ?? 577; + var stream = FS.open(path, opts.flags, opts.mode); + data = FS_fileDataToTypedArray(data); + FS.write(stream, data, 0, data.byteLength, undefined, opts.canOwn); + FS.close(stream); + }, + cwd:() => FS.currentPath, + chdir(path) { + var lookup = FS.lookupPath(path, { follow: true }); + if (lookup.node === null) { + throw new FS.ErrnoError(44); + } + if (!FS.isDir(lookup.node.mode)) { + throw new FS.ErrnoError(54); + } + var errCode = FS.nodePermissions(lookup.node, 'x'); + if (errCode) { + throw new FS.ErrnoError(errCode); + } + FS.currentPath = lookup.path; + }, + createDefaultDirectories() { + FS.mkdir('/tmp'); + FS.mkdir('/home'); + FS.mkdir('/home/web_user'); + }, + createDefaultDevices() { + + FS.mkdir('/dev'); + + FS.registerDevice(FS.makedev(1, 3), { + read: () => 0, + write: (stream, buffer, offset, length, pos) => length, + llseek: () => 0, + }); + FS.mkdev('/dev/null', FS.makedev(1, 3)); + + + + TTY.register(FS.makedev(5, 0), TTY.default_tty_ops); + TTY.register(FS.makedev(6, 0), TTY.default_tty1_ops); + FS.mkdev('/dev/tty', FS.makedev(5, 0)); + FS.mkdev('/dev/tty1', FS.makedev(6, 0)); + + + var randomBuffer = new Uint8Array(1024), randomLeft = 0; + var randomByte = () => { + if (randomLeft === 0) { + randomFill(randomBuffer); + randomLeft = randomBuffer.byteLength; + } + return randomBuffer[--randomLeft]; + }; + FS.createDevice('/dev', 'random', randomByte); + FS.createDevice('/dev', 'urandom', randomByte); + + + FS.mkdir('/dev/shm'); + FS.mkdir('/dev/shm/tmp'); + }, + createSpecialDirectories() { + + + FS.mkdir('/proc'); + var proc_self = FS.mkdir('/proc/self'); + FS.mkdir('/proc/self/fd'); + FS.mount({ + mount() { + var node = FS.createNode(proc_self, 'fd', 16895, 73); + node.stream_ops = { + llseek: MEMFS.stream_ops.llseek, + }; + node.node_ops = { + lookup(parent, name) { + var fd = +name; + var stream = FS.getStreamChecked(fd); + var ret = { + parent: null, + mount: { mountpoint: 'fake' }, + node_ops: { readlink: () => stream.path }, + id: fd + 1, + }; + ret.parent = ret; + return ret; + }, + readdir() { + return Array.from(FS.streams.entries()) + .filter(([k, v]) => v) + .map(([k, v]) => k.toString()); + } + }; + return node; + } + }, {}, '/proc/self/fd'); + }, + createStandardStreams(input, output, error) { + + + + + + + + + if (input) { + FS.createDevice('/dev', 'stdin', input); + } else { + FS.symlink('/dev/tty', '/dev/stdin'); + } + if (output) { + FS.createDevice('/dev', 'stdout', null, output); + } else { + FS.symlink('/dev/tty', '/dev/stdout'); + } + if (error) { + FS.createDevice('/dev', 'stderr', null, error); + } else { + FS.symlink('/dev/tty1', '/dev/stderr'); + } + + + var stdin = FS.open('/dev/stdin', 0); + var stdout = FS.open('/dev/stdout', 1); + var stderr = FS.open('/dev/stderr', 1); + }, + staticInit() { + FS.nameTable = new Array(4096); + + FS.mount(MEMFS, {}, '/'); + + FS.createDefaultDirectories(); + FS.createDefaultDevices(); + FS.createSpecialDirectories(); + + FS.filesystems = { + 'MEMFS': MEMFS, + }; + }, + init(input, output, error) { + FS.initialized = true; + + + input ??= Module['stdin']; + output ??= Module['stdout']; + error ??= Module['stderr']; + + FS.createStandardStreams(input, output, error); + }, + quit() { + FS.initialized = false; + + + for (var stream of FS.streams) { + if (stream) { + FS.close(stream); + } + } + }, + findObject(path, dontResolveLastLink) { + var ret = FS.analyzePath(path, dontResolveLastLink); + if (!ret.exists) { + return null; + } + return ret.object; + }, + analyzePath(path, dontResolveLastLink) { + + try { + var lookup = FS.lookupPath(path, { follow: !dontResolveLastLink }); + path = lookup.path; + } catch (e) { + } + var ret = { + isRoot: false, exists: false, error: 0, name: null, path: null, object: null, + parentExists: false, parentPath: null, parentObject: null + }; + try { + var lookup = FS.lookupPath(path, { parent: true }); + ret.parentExists = true; + ret.parentPath = lookup.path; + ret.parentObject = lookup.node; + ret.name = PATH.basename(path); + lookup = FS.lookupPath(path, { follow: !dontResolveLastLink }); + ret.exists = true; + ret.path = lookup.path; + ret.object = lookup.node; + ret.name = lookup.node.name; + ret.isRoot = lookup.path === '/'; + } catch (e) { + ret.error = e.errno; + }; + return ret; + }, + createPath(parent, path, canRead, canWrite) { + parent = typeof parent == 'string' ? parent : FS.getPath(parent); + var parts = path.split('/').reverse(); + while (parts.length) { + var part = parts.pop(); + if (!part) continue; + var current = PATH.join2(parent, part); + try { + FS.mkdir(current); + } catch (e) { + if (e.errno != 20) throw e; + } + parent = current; + } + return current; + }, + createFile(parent, name, properties, canRead, canWrite) { + var path = PATH.join2(typeof parent == 'string' ? parent : FS.getPath(parent), name); + var mode = FS_getMode(canRead, canWrite); + return FS.create(path, mode); + }, + createDataFile(parent, name, data, canRead, canWrite, canOwn) { + var path = name; + if (parent) { + parent = typeof parent == 'string' ? parent : FS.getPath(parent); + path = name ? PATH.join2(parent, name) : parent; + } + var mode = FS_getMode(canRead, canWrite); + var node = FS.create(path, mode); + if (data) { + data = FS_fileDataToTypedArray(data); + + FS.chmod(node, mode | 146); + var stream = FS.open(node, 577); + FS.write(stream, data, 0, data.length, 0, canOwn); + FS.close(stream); + FS.chmod(node, mode); + } + }, + createDevice(parent, name, input, output) { + var path = PATH.join2(typeof parent == 'string' ? parent : FS.getPath(parent), name); + var mode = FS_getMode(!!input, !!output); + FS.createDevice.major ??= 64; + var dev = FS.makedev(FS.createDevice.major++, 0); + + + FS.registerDevice(dev, { + open(stream) { + stream.seekable = false; + }, + close(stream) { + + if (output?.buffer?.length) { + output(10); + } + }, + read(stream, buffer, offset, length, pos ) { + var bytesRead = 0; + for (var i = 0; i < length; i++) { + var result; + try { + result = input(); + } catch (e) { + throw new FS.ErrnoError(29); + } + if (result === undefined && bytesRead === 0) { + throw new FS.ErrnoError(6); + } + if (result === null || result === undefined) break; + bytesRead++; + buffer[offset+i] = result; + } + if (bytesRead) { + stream.node.atime = Date.now(); + } + return bytesRead; + }, + write(stream, buffer, offset, length, pos) { + for (var i = 0; i < length; i++) { + try { + output(buffer[offset+i]); + } catch (e) { + throw new FS.ErrnoError(29); + } + } + if (length) { + stream.node.mtime = stream.node.ctime = Date.now(); + } + return i; + } + }); + return FS.mkdev(path, mode, dev); + }, + forceLoadFile(obj) { + if (obj.isDevice || obj.isFolder || obj.link || obj.contents) return true; + if (globalThis.XMLHttpRequest) { + abort("Lazy loading should have been performed (contents set) in createLazyFile, but it was not. Lazy loading only works in web workers. Use --embed-file or --preload-file in emcc on the main thread."); + } else { + try { + obj.contents = readBinary(obj.url); + } catch (e) { + throw new FS.ErrnoError(29); + } + } + }, + createLazyFile(parent, name, url, canRead, canWrite) { + + + class LazyUint8Array { + lengthKnown = false; + chunks = []; + get(idx) { + if (idx > this.length-1 || idx < 0) { + return undefined; + } + var chunkOffset = idx % this.chunkSize; + var chunkNum = (idx / this.chunkSize)|0; + return this.getter(chunkNum)[chunkOffset]; + } + setDataGetter(getter) { + this.getter = getter; + } + cacheLength() { + + var xhr = new XMLHttpRequest(); + xhr.open('HEAD', url, false); + xhr.send(null); + if (!(xhr.status >= 200 && xhr.status < 300 || xhr.status === 304)) abort("Couldn't load " + url + ". Status: " + xhr.status); + var datalength = Number(xhr.getResponseHeader("Content-length")); + var header; + var hasByteServing = (header = xhr.getResponseHeader("Accept-Ranges")) && header === "bytes"; + var usesGzip = (header = xhr.getResponseHeader("Content-Encoding")) && header === "gzip"; + + var chunkSize = 1024*1024; + + if (!hasByteServing) chunkSize = datalength; + + + var doXHR = (from, to) => { + if (from > to) abort(`invalid range (${from}, ${to}) or no bytes requested!`); + if (to > datalength-1) abort(`only ${datalength} bytes available! programmer error!`); + + + var xhr = new XMLHttpRequest(); + xhr.open('GET', url, false); + if (datalength !== chunkSize) xhr.setRequestHeader("Range", "bytes=" + from + "-" + to); + + + xhr.responseType = 'arraybuffer'; + if (xhr.overrideMimeType) { + xhr.overrideMimeType('text/plain; charset=x-user-defined'); + } + + xhr.send(null); + if (!(xhr.status >= 200 && xhr.status < 300 || xhr.status === 304)) abort("Couldn't load " + url + ". Status: " + xhr.status); + if (xhr.response !== undefined) { + return new Uint8Array((xhr.response || [])); + } + return intArrayFromString(xhr.responseText ?? '', true); + }; + var lazyArray = this; + lazyArray.setDataGetter((chunkNum) => { + var start = chunkNum * chunkSize; + var end = (chunkNum+1) * chunkSize - 1; + end = Math.min(end, datalength-1); + if (typeof lazyArray.chunks[chunkNum] == 'undefined') { + lazyArray.chunks[chunkNum] = doXHR(start, end); + } + if (typeof lazyArray.chunks[chunkNum] == 'undefined') abort('doXHR failed!'); + return lazyArray.chunks[chunkNum]; + }); + + if (usesGzip || !datalength) { + + chunkSize = datalength = 1; + datalength = this.getter(0).length; + chunkSize = datalength; + out("LazyFiles on gzip forces download of the whole file when length is accessed"); + } + + this._length = datalength; + this._chunkSize = chunkSize; + this.lengthKnown = true; + } + get length() { + if (!this.lengthKnown) { + this.cacheLength(); + } + return this._length; + } + get chunkSize() { + if (!this.lengthKnown) { + this.cacheLength(); + } + return this._chunkSize; + } + } + + if (globalThis.XMLHttpRequest) { + if (!ENVIRONMENT_IS_WORKER) abort('Cannot do synchronous binary XHRs outside webworkers in modern browsers. Use --embed-file or --preload-file in emcc'); + var lazyArray = new LazyUint8Array(); + var properties = { isDevice: false, contents: lazyArray }; + } else { + var properties = { isDevice: false, url: url }; + } + + var node = FS.createFile(parent, name, properties, canRead, canWrite); + + + + if (properties.contents) { + node.contents = properties.contents; + } else if (properties.url) { + node.contents = null; + node.url = properties.url; + } + + Object.defineProperties(node, { + usedBytes: { + get: function() { return this.contents.length; } + } + }); + + var stream_ops = {}; + for (const [key, fn] of Object.entries(node.stream_ops)) { + stream_ops[key] = (...args) => { + FS.forceLoadFile(node); + return fn(...args); + }; + } + function writeChunks(stream, buffer, offset, length, position) { + var contents = stream.node.contents; + if (position >= contents.length) + return 0; + var size = Math.min(contents.length - position, length); + if (contents.slice) { + for (var i = 0; i < size; i++) { + buffer[offset + i] = contents[position + i]; + } + } else { + for (var i = 0; i < size; i++) { + buffer[offset + i] = contents.get(position + i); + } + } + return size; + } + + stream_ops.read = (stream, buffer, offset, length, position) => { + FS.forceLoadFile(node); + return writeChunks(stream, buffer, offset, length, position) + }; + + stream_ops.mmap = (stream, length, position, prot, flags) => { + FS.forceLoadFile(node); + var ptr = mmapAlloc(length); + if (!ptr) { + throw new FS.ErrnoError(48); + } + writeChunks(stream, HEAP8, ptr, length, position); + return { ptr, allocated: true }; + }; + node.stream_ops = stream_ops; + return node; + }, + }; + + + + + var UTF8ToString = (ptr, maxBytesToRead, ignoreNul) => { + if (!ptr) return ''; + var end = findStringEnd(HEAPU8, ptr, maxBytesToRead, ignoreNul); + return UTF8Decoder.decode(HEAPU8.subarray(ptr, end)); + }; + var SYSCALLS = { + currentUmask:18, + calculateAt(dirfd, path, allowEmpty) { + if (PATH.isAbs(path)) { + return path; + } + + var dir; + if (dirfd === -100) { + dir = FS.cwd(); + } else { + var dirstream = SYSCALLS.getStreamFromFD(dirfd); + dir = dirstream.path; + } + if (path.length == 0) { + if (!allowEmpty) { + throw new FS.ErrnoError(44);; + } + return dir; + } + return dir + '/' + path; + }, + writeStat(buf, stat) { + HEAPU32[((buf)>>2)] = stat.dev; + HEAPU32[(((buf)+(4))>>2)] = stat.mode; + HEAPU32[(((buf)+(8))>>2)] = stat.nlink; + HEAPU32[(((buf)+(12))>>2)] = stat.uid; + HEAPU32[(((buf)+(16))>>2)] = stat.gid; + HEAPU32[(((buf)+(20))>>2)] = stat.rdev; + HEAP64[(((buf)+(24))>>3)] = BigInt(stat.size); + HEAP32[(((buf)+(32))>>2)] = 4096; + HEAP32[(((buf)+(36))>>2)] = stat.blocks; + var atime = stat.atime.getTime(); + var mtime = stat.mtime.getTime(); + var ctime = stat.ctime.getTime(); + HEAP64[(((buf)+(40))>>3)] = BigInt(Math.floor(atime / 1000)); + HEAPU32[(((buf)+(48))>>2)] = (atime % 1000) * 1000 * 1000; + HEAP64[(((buf)+(56))>>3)] = BigInt(Math.floor(mtime / 1000)); + HEAPU32[(((buf)+(64))>>2)] = (mtime % 1000) * 1000 * 1000; + HEAP64[(((buf)+(72))>>3)] = BigInt(Math.floor(ctime / 1000)); + HEAPU32[(((buf)+(80))>>2)] = (ctime % 1000) * 1000 * 1000; + HEAP64[(((buf)+(88))>>3)] = BigInt(stat.ino); + return 0; + }, + writeStatFs(buf, stats) { + HEAPU32[(((buf)+(4))>>2)] = stats.bsize; + HEAPU32[(((buf)+(60))>>2)] = stats.bsize; + HEAP64[(((buf)+(8))>>3)] = BigInt(stats.blocks); + HEAP64[(((buf)+(16))>>3)] = BigInt(stats.bfree); + HEAP64[(((buf)+(24))>>3)] = BigInt(stats.bavail); + HEAP64[(((buf)+(32))>>3)] = BigInt(stats.files); + HEAP64[(((buf)+(40))>>3)] = BigInt(stats.ffree); + HEAPU32[(((buf)+(48))>>2)] = stats.fsid; + HEAPU32[(((buf)+(64))>>2)] = stats.flags; + HEAPU32[(((buf)+(56))>>2)] = stats.namelen; + }, + doMsync(addr, stream, len, flags, offset) { + if (!FS.isFile(stream.node.mode)) { + throw new FS.ErrnoError(43); + } + if (flags & 2) { + + return 0; + } + var buffer = HEAPU8.subarray(addr, addr + len); + FS.msync(stream, buffer, offset, len, flags); + }, + getStreamFromFD(fd) { + var stream = FS.getStreamChecked(fd); + return stream; + }, + varargs:undefined, + getStr(ptr) { + var ret = UTF8ToString(ptr); + return ret; + }, + }; + function ___syscall_chmod(path, mode) { + try { + + path = SYSCALLS.getStr(path); + FS.chmod(path, mode); + return 0; + } catch (e) { + if (typeof FS == 'undefined' || !(e.name === 'ErrnoError')) throw e; + return -e.errno; + } + } + + + function ___syscall_faccessat(dirfd, path, amode, flags) { + try { + + path = SYSCALLS.getStr(path); + path = SYSCALLS.calculateAt(dirfd, path); + if (amode & ~7) { + + return -28; + } + var lookup = FS.lookupPath(path, { follow: true }); + var node = lookup.node; + if (!node) { + return -44; + } + var perms = ''; + if (amode & 4) perms += 'r'; + if (amode & 2) perms += 'w'; + if (amode & 1) perms += 'x'; + if (perms && FS.nodePermissions(node, perms)) { + return -2; + } + return 0; + } catch (e) { + if (typeof FS == 'undefined' || !(e.name === 'ErrnoError')) throw e; + return -e.errno; + } + } + + + function ___syscall_fchmod(fd, mode) { + try { + + FS.fchmod(fd, mode); + return 0; + } catch (e) { + if (typeof FS == 'undefined' || !(e.name === 'ErrnoError')) throw e; + return -e.errno; + } + } + + + function ___syscall_fchown32(fd, owner, group) { + try { + + FS.fchown(fd, owner, group); + return 0; + } catch (e) { + if (typeof FS == 'undefined' || !(e.name === 'ErrnoError')) throw e; + return -e.errno; + } + } + + + var syscallGetVarargI = () => { + + var ret = HEAP32[((+SYSCALLS.varargs)>>2)]; + SYSCALLS.varargs += 4; + return ret; + }; + var syscallGetVarargP = syscallGetVarargI; + + + function ___syscall_fcntl64(fd, cmd, varargs) { + SYSCALLS.varargs = varargs; + try { + + var stream = SYSCALLS.getStreamFromFD(fd); + switch (cmd) { + case 0: { + var arg = syscallGetVarargI(); + if (arg < 0) { + return -28; + } + while (FS.streams[arg]) { + arg++; + } + var newStream; + newStream = FS.dupStream(stream, arg); + return newStream.fd; + } + case 1: + case 2: + return 0; + case 3: + return stream.flags; + case 4: { + var arg = syscallGetVarargI(); + var mask = 289792; + stream.flags = (stream.flags & ~mask) | (arg & mask); + return 0; + } + case 12: { + var arg = syscallGetVarargP(); + var offset = 0; + + HEAP16[(((arg)+(offset))>>1)] = 2; + return 0; + } + case 13: + case 14: + + + + + return 0; + } + return -28; + } catch (e) { + if (typeof FS == 'undefined' || !(e.name === 'ErrnoError')) throw e; + return -e.errno; + } + } + + + function ___syscall_fstat64(fd, buf) { + try { + + return SYSCALLS.writeStat(buf, FS.fstat(fd)); + } catch (e) { + if (typeof FS == 'undefined' || !(e.name === 'ErrnoError')) throw e; + return -e.errno; + } + } + + + var INT53_MAX = 9007199254740992; + + var INT53_MIN = -9007199254740992; + var bigintToI53Checked = (num) => (num < INT53_MIN || num > INT53_MAX) ? NaN : Number(num); + function ___syscall_ftruncate64(fd, length) { + length = bigintToI53Checked(length); + + + try { + + if (isNaN(length)) return -22; + FS.ftruncate(fd, length); + return 0; + } catch (e) { + if (typeof FS == 'undefined' || !(e.name === 'ErrnoError')) throw e; + return -e.errno; + } + ; + } + + + var stringToUTF8 = (str, outPtr, maxBytesToWrite) => { + return stringToUTF8Array(str, HEAPU8, outPtr, maxBytesToWrite); + }; + function ___syscall_getcwd(buf, size) { + try { + + if (size === 0) return -28; + var cwd = FS.cwd(); + var cwdLengthInBytes = lengthBytesUTF8(cwd) + 1; + if (size < cwdLengthInBytes) return -68; + stringToUTF8(cwd, buf, size); + return cwdLengthInBytes; + } catch (e) { + if (typeof FS == 'undefined' || !(e.name === 'ErrnoError')) throw e; + return -e.errno; + } + } + + + + function ___syscall_ioctl(fd, op, varargs) { + SYSCALLS.varargs = varargs; + try { + + var stream = SYSCALLS.getStreamFromFD(fd); + switch (op) { + case 21509: { + if (!stream.tty) return -59; + return 0; + } + case 21505: { + if (!stream.tty) return -59; + if (stream.tty.ops.ioctl_tcgets) { + var termios = stream.tty.ops.ioctl_tcgets(stream); + var argp = syscallGetVarargP(); + HEAP32[((argp)>>2)] = termios.c_iflag || 0; + HEAP32[(((argp)+(4))>>2)] = termios.c_oflag || 0; + HEAP32[(((argp)+(8))>>2)] = termios.c_cflag || 0; + HEAP32[(((argp)+(12))>>2)] = termios.c_lflag || 0; + for (var i = 0; i < 32; i++) { + HEAP8[(argp + i)+(17)] = termios.c_cc[i] || 0; + } + return 0; + } + return 0; + } + case 21510: + case 21511: + case 21512: { + if (!stream.tty) return -59; + return 0; + } + case 21506: + case 21507: + case 21508: { + if (!stream.tty) return -59; + if (stream.tty.ops.ioctl_tcsets) { + var argp = syscallGetVarargP(); + var c_iflag = HEAP32[((argp)>>2)]; + var c_oflag = HEAP32[(((argp)+(4))>>2)]; + var c_cflag = HEAP32[(((argp)+(8))>>2)]; + var c_lflag = HEAP32[(((argp)+(12))>>2)]; + var c_cc = [] + for (var i = 0; i < 32; i++) { + c_cc.push(HEAP8[(argp + i)+(17)]); + } + return stream.tty.ops.ioctl_tcsets(stream.tty, op, { c_iflag, c_oflag, c_cflag, c_lflag, c_cc }); + } + return 0; + } + case 21519: { + if (!stream.tty) return -59; + var argp = syscallGetVarargP(); + HEAP32[((argp)>>2)] = 0; + return 0; + } + case 21520: { + if (!stream.tty) return -59; + return -28; + } + case 21537: + case 21531: { + var argp = syscallGetVarargP(); + return FS.ioctl(stream, op, argp); + } + case 21523: { + + + if (!stream.tty) return -59; + if (stream.tty.ops.ioctl_tiocgwinsz) { + var winsize = stream.tty.ops.ioctl_tiocgwinsz(stream.tty); + var argp = syscallGetVarargP(); + HEAP16[((argp)>>1)] = winsize[0]; + HEAP16[(((argp)+(2))>>1)] = winsize[1]; + } + return 0; + } + case 21524: { + + + + if (!stream.tty) return -59; + return 0; + } + case 21515: { + if (!stream.tty) return -59; + return 0; + } + default: return -28; + } + } catch (e) { + if (typeof FS == 'undefined' || !(e.name === 'ErrnoError')) throw e; + return -e.errno; + } + } + + + function ___syscall_lstat64(path, buf) { + try { + + path = SYSCALLS.getStr(path); + return SYSCALLS.writeStat(buf, FS.lstat(path)); + } catch (e) { + if (typeof FS == 'undefined' || !(e.name === 'ErrnoError')) throw e; + return -e.errno; + } + } + + + function ___syscall_mkdirat(dirfd, path, mode) { + try { + + path = SYSCALLS.getStr(path); + path = SYSCALLS.calculateAt(dirfd, path); + mode &= ~SYSCALLS.currentUmask; + FS.mkdir(path, mode, 0); + return 0; + } catch (e) { + if (typeof FS == 'undefined' || !(e.name === 'ErrnoError')) throw e; + return -e.errno; + } + } + + + function ___syscall_newfstatat(dirfd, path, buf, flags) { + try { + + path = SYSCALLS.getStr(path); + var nofollow = flags & 256; + var allowEmpty = flags & 4096; + flags = flags & (~6400); + path = SYSCALLS.calculateAt(dirfd, path, allowEmpty); + return SYSCALLS.writeStat(buf, nofollow ? FS.lstat(path) : FS.stat(path)); + } catch (e) { + if (typeof FS == 'undefined' || !(e.name === 'ErrnoError')) throw e; + return -e.errno; + } + } + + + + function ___syscall_openat(dirfd, path, flags, varargs) { + SYSCALLS.varargs = varargs; + try { + + path = SYSCALLS.getStr(path); + path = SYSCALLS.calculateAt(dirfd, path); + var mode = varargs ? syscallGetVarargI() : 0; + if (flags & 64) { + mode &= ~SYSCALLS.currentUmask; + } + return FS.open(path, flags, mode).fd; + } catch (e) { + if (typeof FS == 'undefined' || !(e.name === 'ErrnoError')) throw e; + return -e.errno; + } + } + + + + + function ___syscall_readlinkat(dirfd, path, buf, bufsize) { + try { + + path = SYSCALLS.getStr(path); + path = SYSCALLS.calculateAt(dirfd, path); + if (bufsize <= 0) return -28; + var ret = FS.readlink(path); + + var len = Math.min(bufsize, lengthBytesUTF8(ret)); + var endChar = HEAP8[buf+len]; + stringToUTF8(ret, buf, bufsize+1); + + + HEAP8[buf+len] = endChar; + return len; + } catch (e) { + if (typeof FS == 'undefined' || !(e.name === 'ErrnoError')) throw e; + return -e.errno; + } + } + + + function ___syscall_rmdir(path) { + try { + + path = SYSCALLS.getStr(path); + FS.rmdir(path); + return 0; + } catch (e) { + if (typeof FS == 'undefined' || !(e.name === 'ErrnoError')) throw e; + return -e.errno; + } + } + + + function ___syscall_stat64(path, buf) { + try { + + path = SYSCALLS.getStr(path); + return SYSCALLS.writeStat(buf, FS.stat(path)); + } catch (e) { + if (typeof FS == 'undefined' || !(e.name === 'ErrnoError')) throw e; + return -e.errno; + } + } + + + function ___syscall_unlinkat(dirfd, path, flags) { + try { + + path = SYSCALLS.getStr(path); + path = SYSCALLS.calculateAt(dirfd, path); + if (!flags) { + FS.unlink(path); + } else if (flags === 512) { + FS.rmdir(path); + } else { + return -28; + } + return 0; + } catch (e) { + if (typeof FS == 'undefined' || !(e.name === 'ErrnoError')) throw e; + return -e.errno; + } + } + + + var readI53FromI64 = (ptr) => { + return HEAPU32[((ptr)>>2)] + HEAP32[(((ptr)+(4))>>2)] * 4294967296; + }; + + function ___syscall_utimensat(dirfd, path, times, flags) { + try { + + var nofollow = flags & 256; + path = SYSCALLS.getStr(path); + path = SYSCALLS.calculateAt(dirfd, path, true); + var now = Date.now(), atime, mtime; + if (!times) { + atime = now; + mtime = now; + } else { + var seconds = readI53FromI64(times); + var nanoseconds = HEAP32[(((times)+(8))>>2)]; + if (nanoseconds == 1073741823) { + atime = now; + } else if (nanoseconds == 1073741822) { + atime = null; + } else { + atime = (seconds*1000) + (nanoseconds/(1000*1000)); + } + times += 16; + seconds = readI53FromI64(times); + nanoseconds = HEAP32[(((times)+(8))>>2)]; + if (nanoseconds == 1073741823) { + mtime = now; + } else if (nanoseconds == 1073741822) { + mtime = null; + } else { + mtime = (seconds*1000) + (nanoseconds/(1000*1000)); + } + } + + + if ((mtime ?? atime) !== null) { + FS.utime(path, atime, mtime, nofollow); + } + return 0; + } catch (e) { + if (typeof FS == 'undefined' || !(e.name === 'ErrnoError')) throw e; + return -e.errno; + } + } + + + var __abort_js = () => + abort(''); + + var isLeapYear = (year) => year%4 === 0 && (year%100 !== 0 || year%400 === 0); + + var MONTH_DAYS_LEAP_CUMULATIVE = [0,31,60,91,121,152,182,213,244,274,305,335]; + + var MONTH_DAYS_REGULAR_CUMULATIVE = [0,31,59,90,120,151,181,212,243,273,304,334]; + var ydayFromDate = (date) => { + var leap = isLeapYear(date.getFullYear()); + var monthDaysCumulative = (leap ? MONTH_DAYS_LEAP_CUMULATIVE : MONTH_DAYS_REGULAR_CUMULATIVE); + var yday = monthDaysCumulative[date.getMonth()] + date.getDate() - 1; + + return yday; + }; + + function __localtime_js(time, tmPtr) { + time = bigintToI53Checked(time); + + + var date = new Date(time*1000); + if (isNaN(date.getTime())) { + return 1; + } + HEAP32[((tmPtr)>>2)] = date.getSeconds(); + HEAP32[(((tmPtr)+(4))>>2)] = date.getMinutes(); + HEAP32[(((tmPtr)+(8))>>2)] = date.getHours(); + HEAP32[(((tmPtr)+(12))>>2)] = date.getDate(); + HEAP32[(((tmPtr)+(16))>>2)] = date.getMonth(); + HEAP32[(((tmPtr)+(20))>>2)] = date.getFullYear()-1900; + HEAP32[(((tmPtr)+(24))>>2)] = date.getDay(); + + var yday = ydayFromDate(date)|0; + HEAP32[(((tmPtr)+(28))>>2)] = yday; + HEAP32[(((tmPtr)+(36))>>2)] = -(date.getTimezoneOffset() * 60); + + + var start = new Date(date.getFullYear(), 0, 1); + var summerOffset = new Date(date.getFullYear(), 6, 1).getTimezoneOffset(); + var winterOffset = start.getTimezoneOffset(); + var dst = (summerOffset != winterOffset && date.getTimezoneOffset() == Math.min(winterOffset, summerOffset))|0; + HEAP32[(((tmPtr)+(32))>>2)] = dst; + return 0; + ; + } + + + + + + + function __mmap_js(len, prot, flags, fd, offset, allocated, addr) { + offset = bigintToI53Checked(offset); + + + try { + + var stream = SYSCALLS.getStreamFromFD(fd); + var res = FS.mmap(stream, len, offset, prot, flags); + var ptr = res.ptr; + HEAP32[((allocated)>>2)] = res.allocated; + HEAPU32[((addr)>>2)] = ptr; + return 0; + } catch (e) { + if (typeof FS == 'undefined' || !(e.name === 'ErrnoError')) throw e; + return -e.errno; + } + ; + } + + + function __munmap_js(addr, len, prot, flags, fd, offset) { + offset = bigintToI53Checked(offset); + + + try { + + var stream = SYSCALLS.getStreamFromFD(fd); + if (prot & 2) { + SYSCALLS.doMsync(addr, stream, len, flags, offset); + } + } catch (e) { + if (typeof FS == 'undefined' || !(e.name === 'ErrnoError')) throw e; + return -e.errno; + } + ; + } + + var __tzset_js = (timezone, daylight, std_name, dst_name) => { + + var currentYear = new Date().getFullYear(); + var winter = new Date(currentYear, 0, 1); + var summer = new Date(currentYear, 6, 1); + var winterOffset = winter.getTimezoneOffset(); + var summerOffset = summer.getTimezoneOffset(); + + + + + + + + var stdTimezoneOffset = Math.max(winterOffset, summerOffset); + + + + + + + HEAPU32[((timezone)>>2)] = stdTimezoneOffset * 60; + + HEAP32[((daylight)>>2)] = Number(winterOffset != summerOffset); + + var extractZone = (timezoneOffset) => { + + + var sign = timezoneOffset >= 0 ? "-" : "+"; + + var absOffset = Math.abs(timezoneOffset) + var hours = String(Math.floor(absOffset / 60)).padStart(2, "0"); + var minutes = String(absOffset % 60).padStart(2, "0"); + + return `UTC${sign}${hours}${minutes}`; + } + + var winterName = extractZone(winterOffset); + var summerName = extractZone(summerOffset); + if (summerOffset < winterOffset) { + + stringToUTF8(winterName, std_name, 17); + stringToUTF8(summerName, dst_name, 17); + } else { + stringToUTF8(winterName, dst_name, 17); + stringToUTF8(summerName, std_name, 17); + } + }; + + var _emscripten_get_now = () => performance.now(); + + var _emscripten_date_now = () => Date.now(); + + var nowIsMonotonic = 1; + + var checkWasiClock = (clock_id) => clock_id >= 0 && clock_id <= 3; + + function _clock_time_get(clk_id, ignored_precision, ptime) { + ignored_precision = bigintToI53Checked(ignored_precision); + + + if (!checkWasiClock(clk_id)) { + return 28; + } + var now; + + if (clk_id === 0) { + now = _emscripten_date_now(); + } else if (nowIsMonotonic) { + now = _emscripten_get_now(); + } else { + return 52; + } + + var nsec = Math.round(now * 1000 * 1000); + HEAP64[((ptime)>>3)] = BigInt(nsec); + return 0; + ; + } + + + var getHeapMax = () => + + + + + 2147483648; + var _emscripten_get_heap_max = () => getHeapMax(); + + + + + var growMemory = (size) => { + var oldHeapSize = wasmMemory.buffer.byteLength; + var pages = ((size - oldHeapSize + 65535) / 65536) | 0; + try { + + wasmMemory.grow(pages); + updateMemoryViews(); + return 1 ; + } catch(e) { + } + + + }; + var _emscripten_resize_heap = (requestedSize) => { + var oldSize = HEAPU8.length; + + requestedSize >>>= 0; + + + + + + + + + + + + + + + + + + + + + + + var maxHeapSize = getHeapMax(); + if (requestedSize > maxHeapSize) { + return false; + } + + + + + for (var cutDown = 1; cutDown <= 4; cutDown *= 2) { + var overGrownHeapSize = oldSize * (1 + 0.2 / cutDown); + + overGrownHeapSize = Math.min(overGrownHeapSize, requestedSize + 100663296 ); + + var newSize = Math.min(maxHeapSize, alignMemory(Math.max(requestedSize, overGrownHeapSize), 65536)); + + var replacement = growMemory(newSize); + if (replacement) { + + return true; + } + } + return false; + }; + + var ENV = { + }; + + var getExecutableName = () => thisProgram; + var getEnvStrings = () => { + if (!getEnvStrings.strings) { + + var lang = (globalThis.navigator?.language ?? 'C').replace('-', '_') + '.UTF-8'; + var env = { + 'USER': 'web_user', + 'LOGNAME': 'web_user', + 'PATH': '/', + 'PWD': '/', + 'HOME': '/home/web_user', + 'LANG': lang, + '_': getExecutableName() + }; + + for (var x in ENV) { + + + + if (ENV[x] === undefined) delete env[x]; + else env[x] = ENV[x]; + } + var strings = []; + for (var x in env) { + strings.push(`${x}=${env[x]}`); + } + getEnvStrings.strings = strings; + } + return getEnvStrings.strings; + }; + + var _environ_get = (__environ, environ_buf) => { + var bufSize = 0; + var envp = 0; + for (var string of getEnvStrings()) { + var ptr = environ_buf + bufSize; + HEAPU32[(((__environ)+(envp))>>2)] = ptr; + bufSize += stringToUTF8(string, ptr, Infinity) + 1; + envp += 4; + } + return 0; + }; + + + var _environ_sizes_get = (penviron_count, penviron_buf_size) => { + var strings = getEnvStrings(); + HEAPU32[((penviron_count)>>2)] = strings.length; + var bufSize = 0; + for (var string of strings) { + bufSize += lengthBytesUTF8(string) + 1; + } + HEAPU32[((penviron_buf_size)>>2)] = bufSize; + return 0; + }; + + + var runtimeKeepaliveCounter = 0; + var keepRuntimeAlive = () => noExitRuntime || runtimeKeepaliveCounter > 0; + var _proc_exit = (code) => { + EXITSTATUS = code; + if (!keepRuntimeAlive()) { + Module['onExit']?.(code); + ABORT = true; + } + quit_(code, new ExitStatus(code)); + }; + + var exitJS = (status, implicit) => { + EXITSTATUS = status; + + _proc_exit(status); + }; + var _exit = exitJS; + + function _fd_close(fd) { + try { + + var stream = SYSCALLS.getStreamFromFD(fd); + FS.close(stream); + return 0; + } catch (e) { + if (typeof FS == 'undefined' || !(e.name === 'ErrnoError')) throw e; + return e.errno; + } + } + + + function _fd_fdstat_get(fd, pbuf) { + try { + + var rightsBase = 0; + var rightsInheriting = 0; + var flags = 0; + { + var stream = SYSCALLS.getStreamFromFD(fd); + + + var type = stream.tty ? 2 : + FS.isDir(stream.mode) ? 3 : + FS.isLink(stream.mode) ? 7 : + 4; + } + HEAP8[pbuf] = type; + HEAP16[(((pbuf)+(2))>>1)] = flags; + HEAP64[(((pbuf)+(8))>>3)] = BigInt(rightsBase); + HEAP64[(((pbuf)+(16))>>3)] = BigInt(rightsInheriting); + return 0; + } catch (e) { + if (typeof FS == 'undefined' || !(e.name === 'ErrnoError')) throw e; + return e.errno; + } + } + + + + var doReadv = (stream, iov, iovcnt, offset) => { + var ret = 0; + for (var i = 0; i < iovcnt; i++) { + var ptr = HEAPU32[((iov)>>2)]; + var len = HEAPU32[(((iov)+(4))>>2)]; + iov += 8; + try { + var curr = FS.read(stream, HEAP8, ptr, len, offset); + } catch (e) { + + + + if (ret > 0 && e instanceof FS.ErrnoError && + (e.errno == 6 || e.errno == 6)) { + break; + } + throw e; + } + if (curr < 0) return -1; + ret += curr; + if (curr < len) break; + if (typeof offset != 'undefined') { + offset += curr; + } + } + return ret; + }; + + function _fd_read(fd, iov, iovcnt, pnum) { + try { + + var stream = SYSCALLS.getStreamFromFD(fd); + var num = doReadv(stream, iov, iovcnt); + HEAPU32[((pnum)>>2)] = num; + return 0; + } catch (e) { + if (typeof FS == 'undefined' || !(e.name === 'ErrnoError')) throw e; + return e.errno; + } + } + + + + function _fd_seek(fd, offset, whence, newOffset) { + offset = bigintToI53Checked(offset); + + + try { + + if (isNaN(offset)) return 22; + var stream = SYSCALLS.getStreamFromFD(fd); + FS.llseek(stream, offset, whence); + HEAP64[((newOffset)>>3)] = BigInt(stream.position); + if (stream.getdents && offset === 0 && whence === 0) stream.getdents = null; + return 0; + } catch (e) { + if (typeof FS == 'undefined' || !(e.name === 'ErrnoError')) throw e; + return e.errno; + } + ; + } + + function _fd_sync(fd) { + try { + + var stream = SYSCALLS.getStreamFromFD(fd); + var rtn = stream.stream_ops?.fsync?.(stream); + return rtn; + } catch (e) { + if (typeof FS == 'undefined' || !(e.name === 'ErrnoError')) throw e; + return e.errno; + } + } + + + + var doWritev = (stream, iov, iovcnt, offset) => { + + + + + if (iovcnt == 1) { + + return FS.write(stream, HEAP8, HEAPU32[((iov)>>2)], HEAPU32[(((iov)+(4))>>2)], offset); + } + var total = 0; + for (var i = 0, p = iov; i < iovcnt; i++, p += 8) { + total += HEAPU32[(((p)+(4))>>2)]; + } + var view = new Uint8Array(total); + var voff = 0; + for (var i = 0; i < iovcnt; i++, iov += 8) { + var ptr = HEAPU32[((iov)>>2)]; + var len = HEAPU32[(((iov)+(4))>>2)]; + view.set(HEAPU8.subarray(ptr, ptr + len), voff); + voff += len; + } + return FS.write(stream, view, 0, total, offset); + }; + + function _fd_write(fd, iov, iovcnt, pnum) { + try { + + var stream = SYSCALLS.getStreamFromFD(fd); + var num = doWritev(stream, iov, iovcnt); + HEAPU32[((pnum)>>2)] = num; + return 0; + } catch (e) { + if (typeof FS == 'undefined' || !(e.name === 'ErrnoError')) throw e; + return e.errno; + } + } + + + + FS.createPreloadedFile = FS_createPreloadedFile; + FS.preloadFile = FS_preloadFile; + FS.staticInit();; + + + + + + +{ + + + initMemory(); + + + if (Module['noExitRuntime']) noExitRuntime = Module['noExitRuntime']; + +if (Module['print']) out = Module['print']; +if (Module['printErr']) err = Module['printErr']; + + + if (Module['arguments']) programArgs = Module['arguments']; + if (Module['thisProgram']) thisProgram = Module['thisProgram']; + + var preInit = Module['preInit']; + if (preInit) { + if (typeof preInit == 'function') Module['preInit'] = preInit = [preInit]; + + + while (preInit.length > 0) { + preInit.shift()(); + } + } +} + + + Module['wasmMemory'] = wasmMemory; + + + + + + +function wasm_crypto_getrandom(buf,n) { if (typeof crypto === 'undefined' || !crypto.getRandomValues) return -1; try { var view = new Uint8Array(HEAPU8.buffer, buf, n); var chunk = 65536; for (var i = 0; i < n; i += chunk) { crypto.getRandomValues(view.subarray(i, Math.min(i + chunk, n))); } return 0; } catch (e) { return -1; } } + + +var _sqlite3_status64, + _sqlite3_status, + _sqlite3_db_status64, + _sqlite3_msize, + _sqlite3_db_status, + _sqlite3_vfs_find, + _sqlite3_initialize, + _sqlite3_vfs_register, + _sqlite3_vfs_unregister, + _sqlite3_malloc, + _sqlite3_malloc64, + _sqlite3_free, + _sqlite3_realloc, + _sqlite3_realloc64, + _sqlite3_value_text, + _sqlite3_randomness, + _sqlite3_stricmp, + _sqlite3_strnicmp, + _sqlite3_uri_parameter, + _sqlite3_uri_boolean, + _sqlite3_serialize, + _sqlite3_prepare_v2, + _sqlite3_step, + _sqlite3_column_int64, + _sqlite3_reset, + _sqlite3_exec, + _sqlite3_column_int, + _sqlite3_finalize, + _sqlite3_file_control, + _sqlite3_column_name, + _sqlite3_column_text, + _sqlite3_column_type, + _sqlite3_errmsg, + _sqlite3_deserialize, + _sqlite3_backup_init, + _sqlite3_backup_step, + _sqlite3_backup_finish, + _sqlite3_backup_remaining, + _sqlite3_backup_pagecount, + _sqlite3_clear_bindings, + _sqlite3_value_blob, + _sqlite3_value_bytes, + _sqlite3_value_double, + _sqlite3_value_int, + _sqlite3_value_int64, + _sqlite3_value_subtype, + _sqlite3_value_pointer, + _sqlite3_value_type, + _sqlite3_value_nochange, + _sqlite3_value_frombind, + _sqlite3_value_dup, + _sqlite3_value_free, + _sqlite3_result_blob, + _sqlite3_result_error_toobig, + _sqlite3_result_error_nomem, + _sqlite3_result_double, + _sqlite3_result_error, + _sqlite3_result_int, + _sqlite3_result_int64, + _sqlite3_result_null, + _sqlite3_result_pointer, + _sqlite3_result_subtype, + _sqlite3_result_text, + _sqlite3_result_zeroblob, + _sqlite3_result_zeroblob64, + _sqlite3_result_error_code, + _sqlite3_user_data, + _sqlite3_context_db_handle, + _sqlite3_vtab_nochange, + _sqlite3_vtab_in_first, + _sqlite3_vtab_in_next, + _sqlite3_aggregate_context, + _sqlite3_get_auxdata, + _sqlite3_set_auxdata, + _sqlite3_column_count, + _sqlite3_data_count, + _sqlite3_column_blob, + _sqlite3_column_bytes, + _sqlite3_column_double, + _sqlite3_column_value, + _sqlite3_column_decltype, + _sqlite3_column_database_name, + _sqlite3_column_table_name, + _sqlite3_column_origin_name, + _sqlite3_bind_blob, + _sqlite3_bind_double, + _sqlite3_bind_int, + _sqlite3_bind_int64, + _sqlite3_bind_null, + _sqlite3_bind_pointer, + _sqlite3_bind_text, + _sqlite3_bind_zeroblob, + _sqlite3_bind_parameter_count, + _sqlite3_bind_parameter_name, + _sqlite3_bind_parameter_index, + _sqlite3_db_handle, + _sqlite3_stmt_readonly, + _sqlite3_stmt_isexplain, + _sqlite3_stmt_explain, + _sqlite3_stmt_busy, + _sqlite3_next_stmt, + _sqlite3_stmt_status, + _sqlite3_sql, + _sqlite3_expanded_sql, + _sqlite3_preupdate_old, + _sqlite3_preupdate_count, + _sqlite3_preupdate_depth, + _sqlite3_preupdate_blobwrite, + _sqlite3_preupdate_new, + _sqlite3_value_numeric_type, + _sqlite3_set_authorizer, + _sqlite3_strglob, + _sqlite3_strlike, + _sqlite3_auto_extension, + _sqlite3_cancel_auto_extension, + _sqlite3_reset_auto_extension, + _sqlite3_prepare_v3, + _sqlite3_create_module, + _sqlite3_create_module_v2, + _sqlite3_drop_modules, + _sqlite3_declare_vtab, + _sqlite3_vtab_on_conflict, + _sqlite3_vtab_collation, + _sqlite3_vtab_in, + _sqlite3_vtab_rhs_value, + _sqlite3_vtab_distinct, + _sqlite3_keyword_name, + _sqlite3_keyword_count, + _sqlite3_keyword_check, + _sqlite3_complete, + _sqlite3_libversion, + _sqlite3_libversion_number, + _sqlite3mc_vfs_create, + _sqlite3_shutdown, + _sqlite3mc_vfs_shutdown, + _sqlite3_last_insert_rowid, + _sqlite3_set_last_insert_rowid, + _sqlite3_changes64, + _sqlite3_changes, + _sqlite3_total_changes64, + _sqlite3_total_changes, + _sqlite3_txn_state, + _sqlite3_close_v2, + _sqlite3_busy_handler, + _sqlite3_progress_handler, + _sqlite3_busy_timeout, + _sqlite3_interrupt, + _sqlite3_is_interrupted, + _sqlite3_create_function, + _sqlite3_create_function_v2, + _sqlite3_create_window_function, + _sqlite3_overload_function, + _sqlite3_trace_v2, + _sqlite3_commit_hook, + _sqlite3_update_hook, + _sqlite3_rollback_hook, + _sqlite3_preupdate_hook, + _sqlite3_set_errmsg, + _sqlite3_error_offset, + _sqlite3_errcode, + _sqlite3_extended_errcode, + _sqlite3_errstr, + _sqlite3_limit, + _sqlite3_open, + _sqlite3_open_v2, + _sqlite3_create_collation, + _sqlite3_create_collation_v2, + _sqlite3_collation_needed, + _sqlite3_get_autocommit, + _sqlite3_table_column_metadata, + _sqlite3_extended_result_codes, + _sqlite3_uri_key, + _sqlite3_uri_int64, + _sqlite3_db_name, + _sqlite3_db_filename, + _sqlite3_db_readonly, + _sqlite3_compileoption_used, + _sqlite3_compileoption_get, + _sqlite3session_diff, + _sqlite3session_attach, + _sqlite3session_create, + _sqlite3session_delete, + _sqlite3session_table_filter, + _sqlite3session_changeset, + _sqlite3session_changeset_strm, + _sqlite3session_patchset_strm, + _sqlite3session_patchset, + _sqlite3session_enable, + _sqlite3session_indirect, + _sqlite3session_isempty, + _sqlite3session_memory_used, + _sqlite3session_object_config, + _sqlite3session_changeset_size, + _sqlite3changeset_start, + _sqlite3changeset_start_v2, + _sqlite3changeset_start_strm, + _sqlite3changeset_start_v2_strm, + _sqlite3changeset_next, + _sqlite3changeset_op, + _sqlite3changeset_pk, + _sqlite3changeset_old, + _sqlite3changeset_new, + _sqlite3changeset_conflict, + _sqlite3changeset_fk_conflicts, + _sqlite3changeset_finalize, + _sqlite3changeset_invert, + _sqlite3changeset_invert_strm, + _sqlite3changeset_apply_v2, + _sqlite3changeset_apply_v3, + _sqlite3changeset_apply, + _sqlite3changeset_apply_v3_strm, + _sqlite3changeset_apply_v2_strm, + _sqlite3changeset_apply_strm, + _sqlite3changegroup_new, + _sqlite3changegroup_add, + _sqlite3changegroup_output, + _sqlite3changegroup_add_strm, + _sqlite3changegroup_output_strm, + _sqlite3changegroup_delete, + _sqlite3changeset_concat, + _sqlite3changeset_concat_strm, + _sqlite3session_config, + _sqlite3_sourceid, + _sqlite3mc_version, + _sqlite3mc_config, + _sqlite3mc_cipher_count, + _sqlite3mc_cipher_index, + _sqlite3mc_cipher_name, + _sqlite3mc_cipher_name_copy, + _sqlite3mc_config_cipher, + _sqlite3mc_codec_data, + _sqlite3_activate_see, + _sqlite3_key, + _sqlite3_key_v2, + _sqlite3_rekey_v2, + _sqlite3_rekey, + _sqlite3mc_vfs_destroy, + _sqlite3__wasm_pstack_ptr, + _sqlite3__wasm_pstack_restore, + _sqlite3__wasm_pstack_alloc, + _sqlite3__wasm_pstack_remaining, + _sqlite3__wasm_pstack_quota, + _sqlite3__wasm_test_struct, + _sqlite3__wasm_enum_json, + _sqlite3__wasm_vfs_unlink, + _sqlite3__wasm_db_vfs, + _sqlite3__wasm_db_reset, + _sqlite3__wasm_db_export_chunked, + _sqlite3__wasm_db_serialize, + _sqlite3__wasm_vfs_create_file, + _sqlite3__wasm_posix_create_file, + _sqlite3__wasm_kvvfsMakeKey, + _sqlite3__wasm_kvvfs_methods, + _sqlite3__wasm_vtab_config, + _sqlite3__wasm_db_config_ip, + _sqlite3__wasm_db_config_pii, + _sqlite3__wasm_db_config_s, + _sqlite3__wasm_config_i, + _sqlite3__wasm_config_ii, + _sqlite3__wasm_config_j, + _sqlite3__wasm_qfmt_token, + _sqlite3__wasm_kvvfs_decode, + _sqlite3__wasm_kvvfs_encode, + _sqlite3__wasm_init_wasmfs, + _sqlite3__wasm_test_intptr, + _sqlite3__wasm_test_voidptr, + _sqlite3__wasm_test_int64_max, + _sqlite3__wasm_test_int64_min, + _sqlite3__wasm_test_int64_times2, + _sqlite3__wasm_test_int64_minmax, + _sqlite3__wasm_test_int64ptr, + _sqlite3__wasm_test_stack_overflow, + _sqlite3__wasm_test_str_hello, + _sqlite3__wasm_SQLTester_strglob, + _malloc, + _free, + _realloc, + _emscripten_builtin_memalign, + __emscripten_stack_restore, + __emscripten_stack_alloc, + _emscripten_stack_get_current, + __indirect_function_table; + + +function assignWasmExports(wasmExports) { + _sqlite3_status64 = Module['_sqlite3_status64'] = wasmExports['sqlite3_status64']; + _sqlite3_status = Module['_sqlite3_status'] = wasmExports['sqlite3_status']; + _sqlite3_db_status64 = Module['_sqlite3_db_status64'] = wasmExports['sqlite3_db_status64']; + _sqlite3_msize = Module['_sqlite3_msize'] = wasmExports['sqlite3_msize']; + _sqlite3_db_status = Module['_sqlite3_db_status'] = wasmExports['sqlite3_db_status']; + _sqlite3_vfs_find = Module['_sqlite3_vfs_find'] = wasmExports['sqlite3_vfs_find']; + _sqlite3_initialize = Module['_sqlite3_initialize'] = wasmExports['sqlite3_initialize']; + _sqlite3_vfs_register = Module['_sqlite3_vfs_register'] = wasmExports['sqlite3_vfs_register']; + _sqlite3_vfs_unregister = Module['_sqlite3_vfs_unregister'] = wasmExports['sqlite3_vfs_unregister']; + _sqlite3_malloc = Module['_sqlite3_malloc'] = wasmExports['sqlite3_malloc']; + _sqlite3_malloc64 = Module['_sqlite3_malloc64'] = wasmExports['sqlite3_malloc64']; + _sqlite3_free = Module['_sqlite3_free'] = wasmExports['sqlite3_free']; + _sqlite3_realloc = Module['_sqlite3_realloc'] = wasmExports['sqlite3_realloc']; + _sqlite3_realloc64 = Module['_sqlite3_realloc64'] = wasmExports['sqlite3_realloc64']; + _sqlite3_value_text = Module['_sqlite3_value_text'] = wasmExports['sqlite3_value_text']; + _sqlite3_randomness = Module['_sqlite3_randomness'] = wasmExports['sqlite3_randomness']; + _sqlite3_stricmp = Module['_sqlite3_stricmp'] = wasmExports['sqlite3_stricmp']; + _sqlite3_strnicmp = Module['_sqlite3_strnicmp'] = wasmExports['sqlite3_strnicmp']; + _sqlite3_uri_parameter = Module['_sqlite3_uri_parameter'] = wasmExports['sqlite3_uri_parameter']; + _sqlite3_uri_boolean = Module['_sqlite3_uri_boolean'] = wasmExports['sqlite3_uri_boolean']; + _sqlite3_serialize = Module['_sqlite3_serialize'] = wasmExports['sqlite3_serialize']; + _sqlite3_prepare_v2 = Module['_sqlite3_prepare_v2'] = wasmExports['sqlite3_prepare_v2']; + _sqlite3_step = Module['_sqlite3_step'] = wasmExports['sqlite3_step']; + _sqlite3_column_int64 = Module['_sqlite3_column_int64'] = wasmExports['sqlite3_column_int64']; + _sqlite3_reset = Module['_sqlite3_reset'] = wasmExports['sqlite3_reset']; + _sqlite3_exec = Module['_sqlite3_exec'] = wasmExports['sqlite3_exec']; + _sqlite3_column_int = Module['_sqlite3_column_int'] = wasmExports['sqlite3_column_int']; + _sqlite3_finalize = Module['_sqlite3_finalize'] = wasmExports['sqlite3_finalize']; + _sqlite3_file_control = Module['_sqlite3_file_control'] = wasmExports['sqlite3_file_control']; + _sqlite3_column_name = Module['_sqlite3_column_name'] = wasmExports['sqlite3_column_name']; + _sqlite3_column_text = Module['_sqlite3_column_text'] = wasmExports['sqlite3_column_text']; + _sqlite3_column_type = Module['_sqlite3_column_type'] = wasmExports['sqlite3_column_type']; + _sqlite3_errmsg = Module['_sqlite3_errmsg'] = wasmExports['sqlite3_errmsg']; + _sqlite3_deserialize = Module['_sqlite3_deserialize'] = wasmExports['sqlite3_deserialize']; + _sqlite3_backup_init = Module['_sqlite3_backup_init'] = wasmExports['sqlite3_backup_init']; + _sqlite3_backup_step = Module['_sqlite3_backup_step'] = wasmExports['sqlite3_backup_step']; + _sqlite3_backup_finish = Module['_sqlite3_backup_finish'] = wasmExports['sqlite3_backup_finish']; + _sqlite3_backup_remaining = Module['_sqlite3_backup_remaining'] = wasmExports['sqlite3_backup_remaining']; + _sqlite3_backup_pagecount = Module['_sqlite3_backup_pagecount'] = wasmExports['sqlite3_backup_pagecount']; + _sqlite3_clear_bindings = Module['_sqlite3_clear_bindings'] = wasmExports['sqlite3_clear_bindings']; + _sqlite3_value_blob = Module['_sqlite3_value_blob'] = wasmExports['sqlite3_value_blob']; + _sqlite3_value_bytes = Module['_sqlite3_value_bytes'] = wasmExports['sqlite3_value_bytes']; + _sqlite3_value_double = Module['_sqlite3_value_double'] = wasmExports['sqlite3_value_double']; + _sqlite3_value_int = Module['_sqlite3_value_int'] = wasmExports['sqlite3_value_int']; + _sqlite3_value_int64 = Module['_sqlite3_value_int64'] = wasmExports['sqlite3_value_int64']; + _sqlite3_value_subtype = Module['_sqlite3_value_subtype'] = wasmExports['sqlite3_value_subtype']; + _sqlite3_value_pointer = Module['_sqlite3_value_pointer'] = wasmExports['sqlite3_value_pointer']; + _sqlite3_value_type = Module['_sqlite3_value_type'] = wasmExports['sqlite3_value_type']; + _sqlite3_value_nochange = Module['_sqlite3_value_nochange'] = wasmExports['sqlite3_value_nochange']; + _sqlite3_value_frombind = Module['_sqlite3_value_frombind'] = wasmExports['sqlite3_value_frombind']; + _sqlite3_value_dup = Module['_sqlite3_value_dup'] = wasmExports['sqlite3_value_dup']; + _sqlite3_value_free = Module['_sqlite3_value_free'] = wasmExports['sqlite3_value_free']; + _sqlite3_result_blob = Module['_sqlite3_result_blob'] = wasmExports['sqlite3_result_blob']; + _sqlite3_result_error_toobig = Module['_sqlite3_result_error_toobig'] = wasmExports['sqlite3_result_error_toobig']; + _sqlite3_result_error_nomem = Module['_sqlite3_result_error_nomem'] = wasmExports['sqlite3_result_error_nomem']; + _sqlite3_result_double = Module['_sqlite3_result_double'] = wasmExports['sqlite3_result_double']; + _sqlite3_result_error = Module['_sqlite3_result_error'] = wasmExports['sqlite3_result_error']; + _sqlite3_result_int = Module['_sqlite3_result_int'] = wasmExports['sqlite3_result_int']; + _sqlite3_result_int64 = Module['_sqlite3_result_int64'] = wasmExports['sqlite3_result_int64']; + _sqlite3_result_null = Module['_sqlite3_result_null'] = wasmExports['sqlite3_result_null']; + _sqlite3_result_pointer = Module['_sqlite3_result_pointer'] = wasmExports['sqlite3_result_pointer']; + _sqlite3_result_subtype = Module['_sqlite3_result_subtype'] = wasmExports['sqlite3_result_subtype']; + _sqlite3_result_text = Module['_sqlite3_result_text'] = wasmExports['sqlite3_result_text']; + _sqlite3_result_zeroblob = Module['_sqlite3_result_zeroblob'] = wasmExports['sqlite3_result_zeroblob']; + _sqlite3_result_zeroblob64 = Module['_sqlite3_result_zeroblob64'] = wasmExports['sqlite3_result_zeroblob64']; + _sqlite3_result_error_code = Module['_sqlite3_result_error_code'] = wasmExports['sqlite3_result_error_code']; + _sqlite3_user_data = Module['_sqlite3_user_data'] = wasmExports['sqlite3_user_data']; + _sqlite3_context_db_handle = Module['_sqlite3_context_db_handle'] = wasmExports['sqlite3_context_db_handle']; + _sqlite3_vtab_nochange = Module['_sqlite3_vtab_nochange'] = wasmExports['sqlite3_vtab_nochange']; + _sqlite3_vtab_in_first = Module['_sqlite3_vtab_in_first'] = wasmExports['sqlite3_vtab_in_first']; + _sqlite3_vtab_in_next = Module['_sqlite3_vtab_in_next'] = wasmExports['sqlite3_vtab_in_next']; + _sqlite3_aggregate_context = Module['_sqlite3_aggregate_context'] = wasmExports['sqlite3_aggregate_context']; + _sqlite3_get_auxdata = Module['_sqlite3_get_auxdata'] = wasmExports['sqlite3_get_auxdata']; + _sqlite3_set_auxdata = Module['_sqlite3_set_auxdata'] = wasmExports['sqlite3_set_auxdata']; + _sqlite3_column_count = Module['_sqlite3_column_count'] = wasmExports['sqlite3_column_count']; + _sqlite3_data_count = Module['_sqlite3_data_count'] = wasmExports['sqlite3_data_count']; + _sqlite3_column_blob = Module['_sqlite3_column_blob'] = wasmExports['sqlite3_column_blob']; + _sqlite3_column_bytes = Module['_sqlite3_column_bytes'] = wasmExports['sqlite3_column_bytes']; + _sqlite3_column_double = Module['_sqlite3_column_double'] = wasmExports['sqlite3_column_double']; + _sqlite3_column_value = Module['_sqlite3_column_value'] = wasmExports['sqlite3_column_value']; + _sqlite3_column_decltype = Module['_sqlite3_column_decltype'] = wasmExports['sqlite3_column_decltype']; + _sqlite3_column_database_name = Module['_sqlite3_column_database_name'] = wasmExports['sqlite3_column_database_name']; + _sqlite3_column_table_name = Module['_sqlite3_column_table_name'] = wasmExports['sqlite3_column_table_name']; + _sqlite3_column_origin_name = Module['_sqlite3_column_origin_name'] = wasmExports['sqlite3_column_origin_name']; + _sqlite3_bind_blob = Module['_sqlite3_bind_blob'] = wasmExports['sqlite3_bind_blob']; + _sqlite3_bind_double = Module['_sqlite3_bind_double'] = wasmExports['sqlite3_bind_double']; + _sqlite3_bind_int = Module['_sqlite3_bind_int'] = wasmExports['sqlite3_bind_int']; + _sqlite3_bind_int64 = Module['_sqlite3_bind_int64'] = wasmExports['sqlite3_bind_int64']; + _sqlite3_bind_null = Module['_sqlite3_bind_null'] = wasmExports['sqlite3_bind_null']; + _sqlite3_bind_pointer = Module['_sqlite3_bind_pointer'] = wasmExports['sqlite3_bind_pointer']; + _sqlite3_bind_text = Module['_sqlite3_bind_text'] = wasmExports['sqlite3_bind_text']; + _sqlite3_bind_zeroblob = Module['_sqlite3_bind_zeroblob'] = wasmExports['sqlite3_bind_zeroblob']; + _sqlite3_bind_parameter_count = Module['_sqlite3_bind_parameter_count'] = wasmExports['sqlite3_bind_parameter_count']; + _sqlite3_bind_parameter_name = Module['_sqlite3_bind_parameter_name'] = wasmExports['sqlite3_bind_parameter_name']; + _sqlite3_bind_parameter_index = Module['_sqlite3_bind_parameter_index'] = wasmExports['sqlite3_bind_parameter_index']; + _sqlite3_db_handle = Module['_sqlite3_db_handle'] = wasmExports['sqlite3_db_handle']; + _sqlite3_stmt_readonly = Module['_sqlite3_stmt_readonly'] = wasmExports['sqlite3_stmt_readonly']; + _sqlite3_stmt_isexplain = Module['_sqlite3_stmt_isexplain'] = wasmExports['sqlite3_stmt_isexplain']; + _sqlite3_stmt_explain = Module['_sqlite3_stmt_explain'] = wasmExports['sqlite3_stmt_explain']; + _sqlite3_stmt_busy = Module['_sqlite3_stmt_busy'] = wasmExports['sqlite3_stmt_busy']; + _sqlite3_next_stmt = Module['_sqlite3_next_stmt'] = wasmExports['sqlite3_next_stmt']; + _sqlite3_stmt_status = Module['_sqlite3_stmt_status'] = wasmExports['sqlite3_stmt_status']; + _sqlite3_sql = Module['_sqlite3_sql'] = wasmExports['sqlite3_sql']; + _sqlite3_expanded_sql = Module['_sqlite3_expanded_sql'] = wasmExports['sqlite3_expanded_sql']; + _sqlite3_preupdate_old = Module['_sqlite3_preupdate_old'] = wasmExports['sqlite3_preupdate_old']; + _sqlite3_preupdate_count = Module['_sqlite3_preupdate_count'] = wasmExports['sqlite3_preupdate_count']; + _sqlite3_preupdate_depth = Module['_sqlite3_preupdate_depth'] = wasmExports['sqlite3_preupdate_depth']; + _sqlite3_preupdate_blobwrite = Module['_sqlite3_preupdate_blobwrite'] = wasmExports['sqlite3_preupdate_blobwrite']; + _sqlite3_preupdate_new = Module['_sqlite3_preupdate_new'] = wasmExports['sqlite3_preupdate_new']; + _sqlite3_value_numeric_type = Module['_sqlite3_value_numeric_type'] = wasmExports['sqlite3_value_numeric_type']; + _sqlite3_set_authorizer = Module['_sqlite3_set_authorizer'] = wasmExports['sqlite3_set_authorizer']; + _sqlite3_strglob = Module['_sqlite3_strglob'] = wasmExports['sqlite3_strglob']; + _sqlite3_strlike = Module['_sqlite3_strlike'] = wasmExports['sqlite3_strlike']; + _sqlite3_auto_extension = Module['_sqlite3_auto_extension'] = wasmExports['sqlite3_auto_extension']; + _sqlite3_cancel_auto_extension = Module['_sqlite3_cancel_auto_extension'] = wasmExports['sqlite3_cancel_auto_extension']; + _sqlite3_reset_auto_extension = Module['_sqlite3_reset_auto_extension'] = wasmExports['sqlite3_reset_auto_extension']; + _sqlite3_prepare_v3 = Module['_sqlite3_prepare_v3'] = wasmExports['sqlite3_prepare_v3']; + _sqlite3_create_module = Module['_sqlite3_create_module'] = wasmExports['sqlite3_create_module']; + _sqlite3_create_module_v2 = Module['_sqlite3_create_module_v2'] = wasmExports['sqlite3_create_module_v2']; + _sqlite3_drop_modules = Module['_sqlite3_drop_modules'] = wasmExports['sqlite3_drop_modules']; + _sqlite3_declare_vtab = Module['_sqlite3_declare_vtab'] = wasmExports['sqlite3_declare_vtab']; + _sqlite3_vtab_on_conflict = Module['_sqlite3_vtab_on_conflict'] = wasmExports['sqlite3_vtab_on_conflict']; + _sqlite3_vtab_collation = Module['_sqlite3_vtab_collation'] = wasmExports['sqlite3_vtab_collation']; + _sqlite3_vtab_in = Module['_sqlite3_vtab_in'] = wasmExports['sqlite3_vtab_in']; + _sqlite3_vtab_rhs_value = Module['_sqlite3_vtab_rhs_value'] = wasmExports['sqlite3_vtab_rhs_value']; + _sqlite3_vtab_distinct = Module['_sqlite3_vtab_distinct'] = wasmExports['sqlite3_vtab_distinct']; + _sqlite3_keyword_name = Module['_sqlite3_keyword_name'] = wasmExports['sqlite3_keyword_name']; + _sqlite3_keyword_count = Module['_sqlite3_keyword_count'] = wasmExports['sqlite3_keyword_count']; + _sqlite3_keyword_check = Module['_sqlite3_keyword_check'] = wasmExports['sqlite3_keyword_check']; + _sqlite3_complete = Module['_sqlite3_complete'] = wasmExports['sqlite3_complete']; + _sqlite3_libversion = Module['_sqlite3_libversion'] = wasmExports['sqlite3_libversion']; + _sqlite3_libversion_number = Module['_sqlite3_libversion_number'] = wasmExports['sqlite3_libversion_number']; + _sqlite3mc_vfs_create = Module['_sqlite3mc_vfs_create'] = wasmExports['sqlite3mc_vfs_create']; + _sqlite3_shutdown = Module['_sqlite3_shutdown'] = wasmExports['sqlite3_shutdown']; + _sqlite3mc_vfs_shutdown = Module['_sqlite3mc_vfs_shutdown'] = wasmExports['sqlite3mc_vfs_shutdown']; + _sqlite3_last_insert_rowid = Module['_sqlite3_last_insert_rowid'] = wasmExports['sqlite3_last_insert_rowid']; + _sqlite3_set_last_insert_rowid = Module['_sqlite3_set_last_insert_rowid'] = wasmExports['sqlite3_set_last_insert_rowid']; + _sqlite3_changes64 = Module['_sqlite3_changes64'] = wasmExports['sqlite3_changes64']; + _sqlite3_changes = Module['_sqlite3_changes'] = wasmExports['sqlite3_changes']; + _sqlite3_total_changes64 = Module['_sqlite3_total_changes64'] = wasmExports['sqlite3_total_changes64']; + _sqlite3_total_changes = Module['_sqlite3_total_changes'] = wasmExports['sqlite3_total_changes']; + _sqlite3_txn_state = Module['_sqlite3_txn_state'] = wasmExports['sqlite3_txn_state']; + _sqlite3_close_v2 = Module['_sqlite3_close_v2'] = wasmExports['sqlite3_close_v2']; + _sqlite3_busy_handler = Module['_sqlite3_busy_handler'] = wasmExports['sqlite3_busy_handler']; + _sqlite3_progress_handler = Module['_sqlite3_progress_handler'] = wasmExports['sqlite3_progress_handler']; + _sqlite3_busy_timeout = Module['_sqlite3_busy_timeout'] = wasmExports['sqlite3_busy_timeout']; + _sqlite3_interrupt = Module['_sqlite3_interrupt'] = wasmExports['sqlite3_interrupt']; + _sqlite3_is_interrupted = Module['_sqlite3_is_interrupted'] = wasmExports['sqlite3_is_interrupted']; + _sqlite3_create_function = Module['_sqlite3_create_function'] = wasmExports['sqlite3_create_function']; + _sqlite3_create_function_v2 = Module['_sqlite3_create_function_v2'] = wasmExports['sqlite3_create_function_v2']; + _sqlite3_create_window_function = Module['_sqlite3_create_window_function'] = wasmExports['sqlite3_create_window_function']; + _sqlite3_overload_function = Module['_sqlite3_overload_function'] = wasmExports['sqlite3_overload_function']; + _sqlite3_trace_v2 = Module['_sqlite3_trace_v2'] = wasmExports['sqlite3_trace_v2']; + _sqlite3_commit_hook = Module['_sqlite3_commit_hook'] = wasmExports['sqlite3_commit_hook']; + _sqlite3_update_hook = Module['_sqlite3_update_hook'] = wasmExports['sqlite3_update_hook']; + _sqlite3_rollback_hook = Module['_sqlite3_rollback_hook'] = wasmExports['sqlite3_rollback_hook']; + _sqlite3_preupdate_hook = Module['_sqlite3_preupdate_hook'] = wasmExports['sqlite3_preupdate_hook']; + _sqlite3_set_errmsg = Module['_sqlite3_set_errmsg'] = wasmExports['sqlite3_set_errmsg']; + _sqlite3_error_offset = Module['_sqlite3_error_offset'] = wasmExports['sqlite3_error_offset']; + _sqlite3_errcode = Module['_sqlite3_errcode'] = wasmExports['sqlite3_errcode']; + _sqlite3_extended_errcode = Module['_sqlite3_extended_errcode'] = wasmExports['sqlite3_extended_errcode']; + _sqlite3_errstr = Module['_sqlite3_errstr'] = wasmExports['sqlite3_errstr']; + _sqlite3_limit = Module['_sqlite3_limit'] = wasmExports['sqlite3_limit']; + _sqlite3_open = Module['_sqlite3_open'] = wasmExports['sqlite3_open']; + _sqlite3_open_v2 = Module['_sqlite3_open_v2'] = wasmExports['sqlite3_open_v2']; + _sqlite3_create_collation = Module['_sqlite3_create_collation'] = wasmExports['sqlite3_create_collation']; + _sqlite3_create_collation_v2 = Module['_sqlite3_create_collation_v2'] = wasmExports['sqlite3_create_collation_v2']; + _sqlite3_collation_needed = Module['_sqlite3_collation_needed'] = wasmExports['sqlite3_collation_needed']; + _sqlite3_get_autocommit = Module['_sqlite3_get_autocommit'] = wasmExports['sqlite3_get_autocommit']; + _sqlite3_table_column_metadata = Module['_sqlite3_table_column_metadata'] = wasmExports['sqlite3_table_column_metadata']; + _sqlite3_extended_result_codes = Module['_sqlite3_extended_result_codes'] = wasmExports['sqlite3_extended_result_codes']; + _sqlite3_uri_key = Module['_sqlite3_uri_key'] = wasmExports['sqlite3_uri_key']; + _sqlite3_uri_int64 = Module['_sqlite3_uri_int64'] = wasmExports['sqlite3_uri_int64']; + _sqlite3_db_name = Module['_sqlite3_db_name'] = wasmExports['sqlite3_db_name']; + _sqlite3_db_filename = Module['_sqlite3_db_filename'] = wasmExports['sqlite3_db_filename']; + _sqlite3_db_readonly = Module['_sqlite3_db_readonly'] = wasmExports['sqlite3_db_readonly']; + _sqlite3_compileoption_used = Module['_sqlite3_compileoption_used'] = wasmExports['sqlite3_compileoption_used']; + _sqlite3_compileoption_get = Module['_sqlite3_compileoption_get'] = wasmExports['sqlite3_compileoption_get']; + _sqlite3session_diff = Module['_sqlite3session_diff'] = wasmExports['sqlite3session_diff']; + _sqlite3session_attach = Module['_sqlite3session_attach'] = wasmExports['sqlite3session_attach']; + _sqlite3session_create = Module['_sqlite3session_create'] = wasmExports['sqlite3session_create']; + _sqlite3session_delete = Module['_sqlite3session_delete'] = wasmExports['sqlite3session_delete']; + _sqlite3session_table_filter = Module['_sqlite3session_table_filter'] = wasmExports['sqlite3session_table_filter']; + _sqlite3session_changeset = Module['_sqlite3session_changeset'] = wasmExports['sqlite3session_changeset']; + _sqlite3session_changeset_strm = Module['_sqlite3session_changeset_strm'] = wasmExports['sqlite3session_changeset_strm']; + _sqlite3session_patchset_strm = Module['_sqlite3session_patchset_strm'] = wasmExports['sqlite3session_patchset_strm']; + _sqlite3session_patchset = Module['_sqlite3session_patchset'] = wasmExports['sqlite3session_patchset']; + _sqlite3session_enable = Module['_sqlite3session_enable'] = wasmExports['sqlite3session_enable']; + _sqlite3session_indirect = Module['_sqlite3session_indirect'] = wasmExports['sqlite3session_indirect']; + _sqlite3session_isempty = Module['_sqlite3session_isempty'] = wasmExports['sqlite3session_isempty']; + _sqlite3session_memory_used = Module['_sqlite3session_memory_used'] = wasmExports['sqlite3session_memory_used']; + _sqlite3session_object_config = Module['_sqlite3session_object_config'] = wasmExports['sqlite3session_object_config']; + _sqlite3session_changeset_size = Module['_sqlite3session_changeset_size'] = wasmExports['sqlite3session_changeset_size']; + _sqlite3changeset_start = Module['_sqlite3changeset_start'] = wasmExports['sqlite3changeset_start']; + _sqlite3changeset_start_v2 = Module['_sqlite3changeset_start_v2'] = wasmExports['sqlite3changeset_start_v2']; + _sqlite3changeset_start_strm = Module['_sqlite3changeset_start_strm'] = wasmExports['sqlite3changeset_start_strm']; + _sqlite3changeset_start_v2_strm = Module['_sqlite3changeset_start_v2_strm'] = wasmExports['sqlite3changeset_start_v2_strm']; + _sqlite3changeset_next = Module['_sqlite3changeset_next'] = wasmExports['sqlite3changeset_next']; + _sqlite3changeset_op = Module['_sqlite3changeset_op'] = wasmExports['sqlite3changeset_op']; + _sqlite3changeset_pk = Module['_sqlite3changeset_pk'] = wasmExports['sqlite3changeset_pk']; + _sqlite3changeset_old = Module['_sqlite3changeset_old'] = wasmExports['sqlite3changeset_old']; + _sqlite3changeset_new = Module['_sqlite3changeset_new'] = wasmExports['sqlite3changeset_new']; + _sqlite3changeset_conflict = Module['_sqlite3changeset_conflict'] = wasmExports['sqlite3changeset_conflict']; + _sqlite3changeset_fk_conflicts = Module['_sqlite3changeset_fk_conflicts'] = wasmExports['sqlite3changeset_fk_conflicts']; + _sqlite3changeset_finalize = Module['_sqlite3changeset_finalize'] = wasmExports['sqlite3changeset_finalize']; + _sqlite3changeset_invert = Module['_sqlite3changeset_invert'] = wasmExports['sqlite3changeset_invert']; + _sqlite3changeset_invert_strm = Module['_sqlite3changeset_invert_strm'] = wasmExports['sqlite3changeset_invert_strm']; + _sqlite3changeset_apply_v2 = Module['_sqlite3changeset_apply_v2'] = wasmExports['sqlite3changeset_apply_v2']; + _sqlite3changeset_apply_v3 = Module['_sqlite3changeset_apply_v3'] = wasmExports['sqlite3changeset_apply_v3']; + _sqlite3changeset_apply = Module['_sqlite3changeset_apply'] = wasmExports['sqlite3changeset_apply']; + _sqlite3changeset_apply_v3_strm = Module['_sqlite3changeset_apply_v3_strm'] = wasmExports['sqlite3changeset_apply_v3_strm']; + _sqlite3changeset_apply_v2_strm = Module['_sqlite3changeset_apply_v2_strm'] = wasmExports['sqlite3changeset_apply_v2_strm']; + _sqlite3changeset_apply_strm = Module['_sqlite3changeset_apply_strm'] = wasmExports['sqlite3changeset_apply_strm']; + _sqlite3changegroup_new = Module['_sqlite3changegroup_new'] = wasmExports['sqlite3changegroup_new']; + _sqlite3changegroup_add = Module['_sqlite3changegroup_add'] = wasmExports['sqlite3changegroup_add']; + _sqlite3changegroup_output = Module['_sqlite3changegroup_output'] = wasmExports['sqlite3changegroup_output']; + _sqlite3changegroup_add_strm = Module['_sqlite3changegroup_add_strm'] = wasmExports['sqlite3changegroup_add_strm']; + _sqlite3changegroup_output_strm = Module['_sqlite3changegroup_output_strm'] = wasmExports['sqlite3changegroup_output_strm']; + _sqlite3changegroup_delete = Module['_sqlite3changegroup_delete'] = wasmExports['sqlite3changegroup_delete']; + _sqlite3changeset_concat = Module['_sqlite3changeset_concat'] = wasmExports['sqlite3changeset_concat']; + _sqlite3changeset_concat_strm = Module['_sqlite3changeset_concat_strm'] = wasmExports['sqlite3changeset_concat_strm']; + _sqlite3session_config = Module['_sqlite3session_config'] = wasmExports['sqlite3session_config']; + _sqlite3_sourceid = Module['_sqlite3_sourceid'] = wasmExports['sqlite3_sourceid']; + _sqlite3mc_version = Module['_sqlite3mc_version'] = wasmExports['sqlite3mc_version']; + _sqlite3mc_config = Module['_sqlite3mc_config'] = wasmExports['sqlite3mc_config']; + _sqlite3mc_cipher_count = Module['_sqlite3mc_cipher_count'] = wasmExports['sqlite3mc_cipher_count']; + _sqlite3mc_cipher_index = Module['_sqlite3mc_cipher_index'] = wasmExports['sqlite3mc_cipher_index']; + _sqlite3mc_cipher_name = Module['_sqlite3mc_cipher_name'] = wasmExports['sqlite3mc_cipher_name']; + _sqlite3mc_cipher_name_copy = Module['_sqlite3mc_cipher_name_copy'] = wasmExports['sqlite3mc_cipher_name_copy']; + _sqlite3mc_config_cipher = Module['_sqlite3mc_config_cipher'] = wasmExports['sqlite3mc_config_cipher']; + _sqlite3mc_codec_data = Module['_sqlite3mc_codec_data'] = wasmExports['sqlite3mc_codec_data']; + _sqlite3_activate_see = Module['_sqlite3_activate_see'] = wasmExports['sqlite3_activate_see']; + _sqlite3_key = Module['_sqlite3_key'] = wasmExports['sqlite3_key']; + _sqlite3_key_v2 = Module['_sqlite3_key_v2'] = wasmExports['sqlite3_key_v2']; + _sqlite3_rekey_v2 = Module['_sqlite3_rekey_v2'] = wasmExports['sqlite3_rekey_v2']; + _sqlite3_rekey = Module['_sqlite3_rekey'] = wasmExports['sqlite3_rekey']; + _sqlite3mc_vfs_destroy = Module['_sqlite3mc_vfs_destroy'] = wasmExports['sqlite3mc_vfs_destroy']; + _sqlite3__wasm_pstack_ptr = Module['_sqlite3__wasm_pstack_ptr'] = wasmExports['sqlite3__wasm_pstack_ptr']; + _sqlite3__wasm_pstack_restore = Module['_sqlite3__wasm_pstack_restore'] = wasmExports['sqlite3__wasm_pstack_restore']; + _sqlite3__wasm_pstack_alloc = Module['_sqlite3__wasm_pstack_alloc'] = wasmExports['sqlite3__wasm_pstack_alloc']; + _sqlite3__wasm_pstack_remaining = Module['_sqlite3__wasm_pstack_remaining'] = wasmExports['sqlite3__wasm_pstack_remaining']; + _sqlite3__wasm_pstack_quota = Module['_sqlite3__wasm_pstack_quota'] = wasmExports['sqlite3__wasm_pstack_quota']; + _sqlite3__wasm_test_struct = Module['_sqlite3__wasm_test_struct'] = wasmExports['sqlite3__wasm_test_struct']; + _sqlite3__wasm_enum_json = Module['_sqlite3__wasm_enum_json'] = wasmExports['sqlite3__wasm_enum_json']; + _sqlite3__wasm_vfs_unlink = Module['_sqlite3__wasm_vfs_unlink'] = wasmExports['sqlite3__wasm_vfs_unlink']; + _sqlite3__wasm_db_vfs = Module['_sqlite3__wasm_db_vfs'] = wasmExports['sqlite3__wasm_db_vfs']; + _sqlite3__wasm_db_reset = Module['_sqlite3__wasm_db_reset'] = wasmExports['sqlite3__wasm_db_reset']; + _sqlite3__wasm_db_export_chunked = Module['_sqlite3__wasm_db_export_chunked'] = wasmExports['sqlite3__wasm_db_export_chunked']; + _sqlite3__wasm_db_serialize = Module['_sqlite3__wasm_db_serialize'] = wasmExports['sqlite3__wasm_db_serialize']; + _sqlite3__wasm_vfs_create_file = Module['_sqlite3__wasm_vfs_create_file'] = wasmExports['sqlite3__wasm_vfs_create_file']; + _sqlite3__wasm_posix_create_file = Module['_sqlite3__wasm_posix_create_file'] = wasmExports['sqlite3__wasm_posix_create_file']; + _sqlite3__wasm_kvvfsMakeKey = Module['_sqlite3__wasm_kvvfsMakeKey'] = wasmExports['sqlite3__wasm_kvvfsMakeKey']; + _sqlite3__wasm_kvvfs_methods = Module['_sqlite3__wasm_kvvfs_methods'] = wasmExports['sqlite3__wasm_kvvfs_methods']; + _sqlite3__wasm_vtab_config = Module['_sqlite3__wasm_vtab_config'] = wasmExports['sqlite3__wasm_vtab_config']; + _sqlite3__wasm_db_config_ip = Module['_sqlite3__wasm_db_config_ip'] = wasmExports['sqlite3__wasm_db_config_ip']; + _sqlite3__wasm_db_config_pii = Module['_sqlite3__wasm_db_config_pii'] = wasmExports['sqlite3__wasm_db_config_pii']; + _sqlite3__wasm_db_config_s = Module['_sqlite3__wasm_db_config_s'] = wasmExports['sqlite3__wasm_db_config_s']; + _sqlite3__wasm_config_i = Module['_sqlite3__wasm_config_i'] = wasmExports['sqlite3__wasm_config_i']; + _sqlite3__wasm_config_ii = Module['_sqlite3__wasm_config_ii'] = wasmExports['sqlite3__wasm_config_ii']; + _sqlite3__wasm_config_j = Module['_sqlite3__wasm_config_j'] = wasmExports['sqlite3__wasm_config_j']; + _sqlite3__wasm_qfmt_token = Module['_sqlite3__wasm_qfmt_token'] = wasmExports['sqlite3__wasm_qfmt_token']; + _sqlite3__wasm_kvvfs_decode = Module['_sqlite3__wasm_kvvfs_decode'] = wasmExports['sqlite3__wasm_kvvfs_decode']; + _sqlite3__wasm_kvvfs_encode = Module['_sqlite3__wasm_kvvfs_encode'] = wasmExports['sqlite3__wasm_kvvfs_encode']; + _sqlite3__wasm_init_wasmfs = Module['_sqlite3__wasm_init_wasmfs'] = wasmExports['sqlite3__wasm_init_wasmfs']; + _sqlite3__wasm_test_intptr = Module['_sqlite3__wasm_test_intptr'] = wasmExports['sqlite3__wasm_test_intptr']; + _sqlite3__wasm_test_voidptr = Module['_sqlite3__wasm_test_voidptr'] = wasmExports['sqlite3__wasm_test_voidptr']; + _sqlite3__wasm_test_int64_max = Module['_sqlite3__wasm_test_int64_max'] = wasmExports['sqlite3__wasm_test_int64_max']; + _sqlite3__wasm_test_int64_min = Module['_sqlite3__wasm_test_int64_min'] = wasmExports['sqlite3__wasm_test_int64_min']; + _sqlite3__wasm_test_int64_times2 = Module['_sqlite3__wasm_test_int64_times2'] = wasmExports['sqlite3__wasm_test_int64_times2']; + _sqlite3__wasm_test_int64_minmax = Module['_sqlite3__wasm_test_int64_minmax'] = wasmExports['sqlite3__wasm_test_int64_minmax']; + _sqlite3__wasm_test_int64ptr = Module['_sqlite3__wasm_test_int64ptr'] = wasmExports['sqlite3__wasm_test_int64ptr']; + _sqlite3__wasm_test_stack_overflow = Module['_sqlite3__wasm_test_stack_overflow'] = wasmExports['sqlite3__wasm_test_stack_overflow']; + _sqlite3__wasm_test_str_hello = Module['_sqlite3__wasm_test_str_hello'] = wasmExports['sqlite3__wasm_test_str_hello']; + _sqlite3__wasm_SQLTester_strglob = Module['_sqlite3__wasm_SQLTester_strglob'] = wasmExports['sqlite3__wasm_SQLTester_strglob']; + _malloc = Module['_malloc'] = wasmExports['malloc']; + _free = Module['_free'] = wasmExports['free']; + _realloc = Module['_realloc'] = wasmExports['realloc']; + _emscripten_builtin_memalign = wasmExports['emscripten_builtin_memalign']; + __emscripten_stack_restore = wasmExports['_emscripten_stack_restore']; + __emscripten_stack_alloc = wasmExports['_emscripten_stack_alloc']; + _emscripten_stack_get_current = wasmExports['emscripten_stack_get_current']; + __indirect_function_table = wasmExports['__indirect_function_table']; +} + +var wasmImports = { + + __syscall_chmod: ___syscall_chmod, + + __syscall_faccessat: ___syscall_faccessat, + + __syscall_fchmod: ___syscall_fchmod, + + __syscall_fchown32: ___syscall_fchown32, + + __syscall_fcntl64: ___syscall_fcntl64, + + __syscall_fstat64: ___syscall_fstat64, + + __syscall_ftruncate64: ___syscall_ftruncate64, + + __syscall_getcwd: ___syscall_getcwd, + + __syscall_ioctl: ___syscall_ioctl, + + __syscall_lstat64: ___syscall_lstat64, + + __syscall_mkdirat: ___syscall_mkdirat, + + __syscall_newfstatat: ___syscall_newfstatat, + + __syscall_openat: ___syscall_openat, + + __syscall_readlinkat: ___syscall_readlinkat, + + __syscall_rmdir: ___syscall_rmdir, + + __syscall_stat64: ___syscall_stat64, + + __syscall_unlinkat: ___syscall_unlinkat, + + __syscall_utimensat: ___syscall_utimensat, + + _abort_js: __abort_js, + + _localtime_js: __localtime_js, + + _mmap_js: __mmap_js, + + _munmap_js: __munmap_js, + + _tzset_js: __tzset_js, + + clock_time_get: _clock_time_get, + + emscripten_date_now: _emscripten_date_now, + + emscripten_get_heap_max: _emscripten_get_heap_max, + + emscripten_get_now: _emscripten_get_now, + + emscripten_resize_heap: _emscripten_resize_heap, + + environ_get: _environ_get, + + environ_sizes_get: _environ_sizes_get, + + exit: _exit, + + fd_close: _fd_close, + + fd_fdstat_get: _fd_fdstat_get, + + fd_read: _fd_read, + + fd_seek: _fd_seek, + + fd_sync: _fd_sync, + + fd_write: _fd_write, + + memory: wasmMemory, + + wasm_crypto_getrandom +}; + + + + + +async function run() { + + preRun(); + + if (runDependencies) { + await resolveRunDependencies(); + } + + var setStatus = Module['setStatus']; + if (setStatus) { + setStatus('Running...'); + + await new Promise((resolve) => setTimeout(resolve, 1)); + + setTimeout(setStatus, 1, ''); + } + + if (ABORT) return; + + initRuntime(); + + Module['onRuntimeInitialized']?.(); + + postRun(); +} + +var wasmExports; + + + +wasmExports = await createWasm(); +await run(); + + + + + +Module.runSQLite3PostLoadInit = async function( + sqlite3InitScriptInfo, + EmscriptenModule, + sqlite3IsUnderTest +){ + + 'use strict'; + delete EmscriptenModule.runSQLite3PostLoadInit; + + + + + + + +'use strict'; +globalThis.sqlite3ApiBootstrap = async function sqlite3ApiBootstrap( + apiConfig = (globalThis.sqlite3ApiConfig || sqlite3ApiBootstrap.defaultConfig) +){ + if(sqlite3ApiBootstrap.sqlite3){ + (sqlite3ApiBootstrap.sqlite3.config || console).warn( + "sqlite3ApiBootstrap() called multiple times.", + "Config and external initializers are ignored on calls after the first." + ); + return sqlite3ApiBootstrap.sqlite3; + } + const nu = (...obj)=>Object.assign(Object.create(null),...obj); + const config = nu({ + exports: undefined, + memory: undefined, + bigIntEnabled: !!globalThis.BigInt64Array, + debug: console.debug.bind(console), + warn: console.warn.bind(console), + error: console.error.bind(console), + log: console.log.bind(console), + wasmfsOpfsDir: '/opfs', + + useStdAlloc: false + }, apiConfig); + + Object.assign(config, { + allocExportName: config.useStdAlloc ? 'malloc' : 'sqlite3_malloc', + deallocExportName: config.useStdAlloc ? 'free' : 'sqlite3_free', + reallocExportName: config.useStdAlloc ? 'realloc' : 'sqlite3_realloc' + }); + + [ + + + 'exports', 'memory', 'functionTable', 'wasmfsOpfsDir' + ].forEach((k)=>{ + if('function' === typeof config[k]){ + config[k] = config[k](); + } + }); + + + const capi = nu(); + + const wasm = nu(); + + + const __rcStr = (rc)=>{ + return (capi.sqlite3_js_rc_str && capi.sqlite3_js_rc_str(rc)) + || ("Unknown result code #"+rc); + }; + + + const isInt32 = (n)=> + 'number'===typeof n + && n===(n | 0) + && n<=2147483647 && n>=-2147483648; + + + class SQLite3Error extends Error { + + constructor(...args){ + let rc; + if(args.length){ + if(isInt32(args[0])){ + rc = args[0]; + if(1===args.length){ + super(__rcStr(args[0])); + }else{ + const rcStr = __rcStr(rc); + if('object'===typeof args[1]){ + super(rcStr,args[1]); + }else{ + args[0] = rcStr+':'; + super(args.join(' ')); + } + } + }else{ + if(2===args.length && 'object'===typeof args[1]){ + super(...args); + }else{ + super(args.join(' ')); + } + } + } + this.resultCode = rc || capi.SQLITE_ERROR; + this.name = 'SQLite3Error'; + } + }; + + + SQLite3Error.toss = (...args)=>{ + throw new SQLite3Error(...args); + }; + const toss3 = SQLite3Error.toss; + + if(config.wasmfsOpfsDir && !/^\/[^/]+$/.test(config.wasmfsOpfsDir)){ + toss3("config.wasmfsOpfsDir must be falsy or in the form '/dir-name'."); + } + + + const bigIntFits64 = function f(b){ + if(!f._max){ + f._max = BigInt("0x7fffffffffffffff"); + f._min = ~f._max; + } + return b >= f._min && b <= f._max; + }; + + + const bigIntFits32 = (b)=>(b >= (-0x7fffffffn - 1n) && b <= 0x7fffffffn); + + + const bigIntFitsDouble = function f(b){ + if(!f._min){ + f._min = Number.MIN_SAFE_INTEGER; + f._max = Number.MAX_SAFE_INTEGER; + } + return b >= f._min && b <= f._max; + }; + + + const isTypedArray = (v)=>{ + return (v && v.constructor && isInt32(v.constructor.BYTES_PER_ELEMENT)) ? v : false; + }; + + + const isBindableTypedArray = (v)=> + v && (v instanceof Uint8Array + || v instanceof Int8Array + || v instanceof ArrayBuffer); + + + const isSQLableTypedArray = (v)=> + v && (v instanceof Uint8Array + || v instanceof Int8Array + || v instanceof ArrayBuffer); + + + const affirmBindableTypedArray = (v)=> + isBindableTypedArray(v) + || toss3("Value is not of a supported TypedArray type."); + + + const flexibleString = function(v){ + if(isSQLableTypedArray(v)){ + return wasm.typedArrayToString( + (v instanceof ArrayBuffer) ? new Uint8Array(v) : v, + 0, v.length + ); + } + else if(Array.isArray(v)) return v.join(""); + else if(wasm.isPtr(v)) v = wasm.cstrToJs(v); + return v; + }; + + + class WasmAllocError extends Error { + + constructor(...args){ + if(2===args.length && 'object'===typeof args[1]){ + super(...args); + }else if(args.length){ + super(args.join(' ')); + }else{ + super("Allocation failed."); + } + this.resultCode = capi.SQLITE_NOMEM; + this.name = 'WasmAllocError'; + } + }; + + WasmAllocError.toss = (...args)=>{ + throw new WasmAllocError(...args); + }; + + Object.assign(capi, { + + sqlite3_bind_blob: undefined, + + + sqlite3_bind_text: undefined, + + + sqlite3_create_function_v2: ( + pDb, funcName, nArg, eTextRep, pApp, + xFunc, xStep, xFinal, xDestroy + )=>{}, + + sqlite3_create_function: ( + pDb, funcName, nArg, eTextRep, pApp, + xFunc, xStep, xFinal + )=>{}, + + sqlite3_create_window_function: ( + pDb, funcName, nArg, eTextRep, pApp, + xStep, xFinal, xValue, xInverse, xDestroy + )=>{}, + + sqlite3_prepare_v3: (dbPtr, sql, sqlByteLen, prepFlags, + stmtPtrPtr, strPtrPtr)=>{}, + + + sqlite3_prepare_v2: (dbPtr, sql, sqlByteLen, + stmtPtrPtr,strPtrPtr)=>{}, + + + sqlite3_exec: (pDb, sql, callback, pVoid, pErrMsg)=>{}, + + + sqlite3_randomness: (n, outPtr)=>{}, + }); + + + const util = { + affirmBindableTypedArray, flexibleString, + bigIntFits32, bigIntFits64, bigIntFitsDouble, + isBindableTypedArray, + isInt32, isSQLableTypedArray, isTypedArray, + isUIThread: ()=>(globalThis.window===globalThis && !!globalThis.document), + + toss: function(...args){throw new Error(args.join(' '))}, + toss3, + typedArrayPart: wasm.typedArrayPart, + nu, + assert: function(arg,msg){ + if( !arg ){ + util.toss("Assertion failed:",msg); + } + }, + + affirmDbHeader: function(bytes){ + if(bytes instanceof ArrayBuffer) bytes = new Uint8Array(bytes); + const header = "SQLite format 3"; + if( header.length > bytes.byteLength ){ + toss3("Input does not contain an SQLite3 database header."); + } + for(let i = 0; i < header.length; ++i){ + if( header.charCodeAt(i) !== bytes[i] ){ + break ; + } + } + }, + + affirmIsDb: function(bytes){ + if(bytes instanceof ArrayBuffer) bytes = new Uint8Array(bytes); + const n = bytes.byteLength; + if(n<512 || n%512!==0) { + toss3("Byte array size",n,"is invalid for an SQLite3 db."); + } + util.affirmDbHeader(bytes); + } + }; + + + Object.assign(wasm, { + + + exports: config.exports + || toss3("Missing API config.exports (WASM module exports)."), + + + memory: config.memory + || config.exports['memory'] + || toss3("API config object requires a WebAssembly.Memory object", + "in either config.exports.memory (exported)", + "or config.memory (imported)."), + + + pointerSize: ('number'===typeof config.exports.sqlite3_libversion()) ? 4 : 8, + + + bigIntEnabled: !!config.bigIntEnabled, + + + functionTable: config.functionTable, + + + alloc: undefined, + + + realloc: undefined, + + + dealloc: undefined + + + }); + + + wasm.allocFromTypedArray = function(srcTypedArray){ + if(srcTypedArray instanceof ArrayBuffer){ + srcTypedArray = new Uint8Array(srcTypedArray); + } + affirmBindableTypedArray(srcTypedArray); + const pRet = wasm.alloc(srcTypedArray.byteLength || 1); + wasm.heapForSize(srcTypedArray.constructor) + .set(srcTypedArray.byteLength ? srcTypedArray : [0], Number(pRet)) + ; + return pRet; + }; + + { + + const keyAlloc = config.allocExportName, + keyDealloc = config.deallocExportName, + keyRealloc = config.reallocExportName; + for(const key of [keyAlloc, keyDealloc, keyRealloc]){ + const f = wasm.exports[key]; + if(!(f instanceof Function)) toss3("Missing required exports[",key,"] function."); + } + + wasm.alloc = function f(n){ + return f.impl(n) || WasmAllocError.toss("Failed to allocate",n," bytes."); + }; + wasm.alloc.impl = wasm.exports[keyAlloc]; + wasm.realloc = function f(m,n){ + const m2 = f.impl(wasm.ptr.coerce(m),n); + return n ? (m2 || WasmAllocError.toss("Failed to reallocate",n," bytes.")) : wasm.ptr.null; + }; + wasm.realloc.impl = wasm.exports[keyRealloc]; + wasm.dealloc = function f(m){ + f.impl(wasm.ptr.coerce(m)) + ; + }; + wasm.dealloc.impl = wasm.exports[keyDealloc]; + } + + + wasm.compileOptionUsed = function f(optName){ + if(!arguments.length){ + if(f._result) return f._result; + else if(!f._opt){ + f._rx = /^([^=]+)=(.+)/; + f._rxInt = /^-?\d+$/; + f._opt = function(opt, rv){ + const m = f._rx.exec(opt); + rv[0] = (m ? m[1] : opt); + rv[1] = m ? (f._rxInt.test(m[2]) ? +m[2] : m[2]) : true; + }; + } + const rc = nu(), ov = [0,0]; + let i = 0, k; + while((k = capi.sqlite3_compileoption_get(i++))){ + f._opt(k,ov); + rc[ov[0]] = ov[1]; + } + return f._result = rc; + }else if(Array.isArray(optName)){ + const rc = nu(); + optName.forEach((v)=>{ + rc[v] = capi.sqlite3_compileoption_used(v); + }); + return rc; + }else if('object' === typeof optName){ + Object.keys(optName).forEach((k)=> { + optName[k] = capi.sqlite3_compileoption_used(k); + }); + return optName; + } + return ( + 'string'===typeof optName + ) ? !!capi.sqlite3_compileoption_used(optName) : false; + }; + + + wasm.pstack = nu({ + + restore: wasm.exports.sqlite3__wasm_pstack_restore, + + + alloc: function(n){ + if('string'===typeof n && !(n = wasm.sizeofIR(n))){ + WasmAllocError.toss("Invalid value for pstack.alloc(",arguments[0],")"); + } + return wasm.exports.sqlite3__wasm_pstack_alloc(n) + || WasmAllocError.toss("Could not allocate",n, + "bytes from the pstack."); + }, + + + allocChunks: function(n,sz){ + if('string'===typeof sz && !(sz = wasm.sizeofIR(sz))){ + WasmAllocError.toss("Invalid size value for allocChunks(",arguments[1],")"); + } + const mem = wasm.pstack.alloc(n * sz); + const rc = [mem]; + let i = 1, offset = sz; + for(; i < n; ++i, offset += sz) rc.push(wasm.ptr.add(mem, offset)); + return rc; + }, + + + allocPtr: (n=1,safePtrSize=true)=>{ + return 1===n + ? wasm.pstack.alloc(safePtrSize ? 8 : wasm.ptr.size) + : wasm.pstack.allocChunks(n, safePtrSize ? 8 : wasm.ptr.size); + }, + + + call: function(f){ + const stackPos = wasm.pstack.pointer; + try{ return f(sqlite3) } + finally{ wasm.pstack.restore(stackPos); } + } + + }); + + Object.defineProperties(wasm.pstack, { + + pointer: { + configurable: false, iterable: true, writeable: false, + get: wasm.exports.sqlite3__wasm_pstack_ptr + + + + }, + + + quota: { + configurable: false, iterable: true, writeable: false, + get: wasm.exports.sqlite3__wasm_pstack_quota + }, + + + remaining: { + configurable: false, iterable: true, writeable: false, + get: wasm.exports.sqlite3__wasm_pstack_remaining + } + }); + + + capi.sqlite3_randomness = (...args)=>{ + if(1===args.length + && util.isTypedArray(args[0]) + && 1===args[0].BYTES_PER_ELEMENT){ + const ta = args[0]; + if(0===ta.byteLength){ + wasm.exports.sqlite3_randomness(0,wasm.ptr.null); + return ta; + } + const stack = wasm.pstack.pointer; + try { + let n = ta.byteLength, offset = 0; + const r = wasm.exports.sqlite3_randomness; + const heap = wasm.heap8u(); + const nAlloc = n < 512 ? n : 512; + const ptr = wasm.pstack.alloc(nAlloc); + do{ + const j = (n>nAlloc ? nAlloc : n); + r(j, ptr); + ta.set(wasm.typedArrayPart(heap, ptr, wasm.ptr.add(ptr,j)), offset); + n -= j; + offset += j; + } while(n > 0); + }catch(e){ + config.error("Highly unexpected (and ignored!) "+ + "exception in sqlite3_randomness():",e); + }finally{ + wasm.pstack.restore(stack); + } + return ta; + } + wasm.exports.sqlite3_randomness(...args); + }; + + + capi.sqlite3_wasmfs_opfs_dir = function(){ + if(undefined !== this.dir) return this.dir; + + const pdir = config.wasmfsOpfsDir; + if(!pdir + || !globalThis.FileSystemHandle + || !globalThis.FileSystemDirectoryHandle + || !globalThis.FileSystemFileHandle + || !wasm.exports.sqlite3__wasm_init_wasmfs){ + return this.dir = ""; + } + try{ + if(pdir && 0===wasm.xCallWrapped( + 'sqlite3__wasm_init_wasmfs', 'i32', ['string'], pdir + )){ + return this.dir = pdir; + }else{ + return this.dir = ""; + } + }catch(e){ + + return this.dir = ""; + } + }.bind(nu()); + + + capi.sqlite3_wasmfs_filename_is_persistent = function(name){ + const p = capi.sqlite3_wasmfs_opfs_dir(); + return (p && name) ? name.startsWith(p+'/') : false; + }; + + + capi.sqlite3_js_db_uses_vfs = function(pDb,vfsName,dbName=0){ + try{ + const pK = capi.sqlite3_vfs_find(vfsName); + if(!pK) return false; + else if(!pDb){ + return pK===capi.sqlite3_vfs_find(0) ? pK : false; + }else{ + return pK===capi.sqlite3_js_db_vfs(pDb,dbName) ? pK : false; + } + }catch(e){ + + return false; + } + }; + + + capi.sqlite3_js_vfs_list = function(){ + const rc = []; + let pVfs = capi.sqlite3_vfs_find(wasm.ptr.null); + while(pVfs){ + const oVfs = new capi.sqlite3_vfs(pVfs); + rc.push(wasm.cstrToJs(oVfs.$zName)); + pVfs = oVfs.$pNext; + oVfs.dispose(); + } + return rc; + }; + + + capi.sqlite3_js_db_export = function(pDb, schema=0){ + pDb = wasm.xWrap.testConvertArg('sqlite3*', pDb); + if(!pDb) toss3('Invalid sqlite3* argument.'); + if(!wasm.bigIntEnabled) toss3('BigInt support is not enabled.'); + const scope = wasm.scopedAllocPush(); + let pOut; + try{ + const pSize = wasm.scopedAlloc(8 + wasm.ptr.size); + const ppOut = wasm.ptr.add(pSize, 8); + + const zSchema = schema + ? (wasm.isPtr(schema) ? schema : wasm.scopedAllocCString(''+schema)) + : wasm.ptr.null; + let rc = wasm.exports.sqlite3__wasm_db_serialize( + pDb, zSchema, ppOut, pSize, 0 + ); + if(rc){ + toss3("Database serialization failed with code", + sqlite3.capi.sqlite3_js_rc_str(rc)); + } + pOut = wasm.peekPtr(ppOut); + const nOut = wasm.peek(pSize, 'i64'); + rc = nOut + ? wasm.heap8u().slice(Number(pOut), Number(pOut) + Number(nOut)) + : new Uint8Array(); + return rc; + }finally{ + if(pOut) wasm.exports.sqlite3_free(pOut); + wasm.scopedAllocPop(scope); + } + }; + + + capi.sqlite3_js_db_vfs = + (dbPointer, dbName=wasm.ptr.null)=>util.sqlite3__wasm_db_vfs(dbPointer, dbName); + + + capi.sqlite3_js_aggregate_context = (pCtx, n)=>{ + return capi.sqlite3_aggregate_context(pCtx, n) + || (n ? WasmAllocError.toss("Cannot allocate",n, + "bytes for sqlite3_aggregate_context()") + : 0); + }; + + + capi.sqlite3_js_posix_create_file = function(filename, data, dataLen){ + let pData; + if(data && wasm.isPtr(data)){ + pData = data; + }else if(data instanceof ArrayBuffer || data instanceof Uint8Array){ + pData = wasm.allocFromTypedArray(data); + if(arguments.length<3 || !util.isInt32(dataLen) || dataLen<0){ + dataLen = data.byteLength; + } + }else{ + SQLite3Error.toss("Invalid 2nd argument for sqlite3_js_posix_create_file()."); + } + try{ + if(!util.isInt32(dataLen) || dataLen<0){ + SQLite3Error.toss("Invalid 3rd argument for sqlite3_js_posix_create_file()."); + } + const rc = util.sqlite3__wasm_posix_create_file(filename, pData, dataLen); + if(rc) SQLite3Error.toss("Creation of file failed with sqlite3 result code", + capi.sqlite3_js_rc_str(rc)); + }finally{ + if( pData && pData!==data ) wasm.dealloc(pData); + } + }; + + + capi.sqlite3_js_vfs_create_file = function(vfs, filename, data, dataLen){ + config.warn("sqlite3_js_vfs_create_file() is deprecated and", + "should be avoided because it can lead to C-level crashes.", + "See its documentation for alternatives."); + let pData; + if(data){ + if( wasm.isPtr(data) ){ + pData = data; + }else{ + if( data instanceof ArrayBuffer ){ + data = new Uint8Array(data); + } + if( data instanceof Uint8Array ){ + pData = wasm.allocFromTypedArray(data); + if(arguments.length<4 || !util.isInt32(dataLen) || dataLen<0){ + dataLen = data.byteLength; + } + }else{ + SQLite3Error.toss("Invalid 3rd argument type for sqlite3_js_vfs_create_file()."); + } + } + }else{ + pData = 0; + } + if(!util.isInt32(dataLen) || dataLen<0){ + if( pData && pData!==data ) wasm.dealloc(pData); + SQLite3Error.toss("Invalid 4th argument for sqlite3_js_vfs_create_file()."); + } + try{ + const rc = util.sqlite3__wasm_vfs_create_file(vfs, filename, pData, dataLen); + if(rc) SQLite3Error.toss("Creation of file failed with sqlite3 result code", + capi.sqlite3_js_rc_str(rc)); + }finally{ + if( pData && pData!==data ) wasm.dealloc(pData); + } + }; + + + capi.sqlite3_js_sql_to_string = (sql)=>{ + if('string' === typeof sql){ + return sql; + } + const x = flexibleString(v); + return x===v ? undefined : x; + } + + + capi.sqlite3_db_config = function(pDb, op, ...args){ + switch(op){ + case capi.SQLITE_DBCONFIG_ENABLE_FKEY: + case capi.SQLITE_DBCONFIG_ENABLE_TRIGGER: + case capi.SQLITE_DBCONFIG_ENABLE_FTS3_TOKENIZER: + case capi.SQLITE_DBCONFIG_ENABLE_LOAD_EXTENSION: + case capi.SQLITE_DBCONFIG_NO_CKPT_ON_CLOSE: + case capi.SQLITE_DBCONFIG_ENABLE_QPSG: + case capi.SQLITE_DBCONFIG_TRIGGER_EQP: + case capi.SQLITE_DBCONFIG_RESET_DATABASE: + case capi.SQLITE_DBCONFIG_DEFENSIVE: + case capi.SQLITE_DBCONFIG_WRITABLE_SCHEMA: + case capi.SQLITE_DBCONFIG_LEGACY_ALTER_TABLE: + case capi.SQLITE_DBCONFIG_DQS_DML: + case capi.SQLITE_DBCONFIG_DQS_DDL: + case capi.SQLITE_DBCONFIG_ENABLE_VIEW: + case capi.SQLITE_DBCONFIG_LEGACY_FILE_FORMAT: + case capi.SQLITE_DBCONFIG_TRUSTED_SCHEMA: + case capi.SQLITE_DBCONFIG_STMT_SCANSTATUS: + case capi.SQLITE_DBCONFIG_REVERSE_SCANORDER: + case capi.SQLITE_DBCONFIG_ENABLE_ATTACH_CREATE: + case capi.SQLITE_DBCONFIG_ENABLE_ATTACH_WRITE: + case capi.SQLITE_DBCONFIG_ENABLE_COMMENTS: + case capi.SQLITE_DBCONFIG_FP_DIGITS: + if( !this.ip ){ + this.ip = wasm.xWrap('sqlite3__wasm_db_config_ip','int', + ['sqlite3*', 'int', 'int', '*']); + } + return this.ip(pDb, op, args[0], args[1] || 0); + case capi.SQLITE_DBCONFIG_LOOKASIDE: + if( !this.pii ){ + this.pii = wasm.xWrap('sqlite3__wasm_db_config_pii', 'int', + ['sqlite3*', 'int', '*', 'int', 'int']); + } + return this.pii(pDb, op, args[0], args[1], args[2]); + case capi.SQLITE_DBCONFIG_MAINDBNAME: + if(!this.s){ + this.s = wasm.xWrap('sqlite3__wasm_db_config_s','int', + ['sqlite3*', 'int', 'string:static'] + ); + } + return this.s(pDb, op, args[0]); + default: + return capi.SQLITE_MISUSE; + } + }.bind(nu()); + + + capi.sqlite3_value_to_js = function(pVal,throwIfCannotConvert=true){ + let arg; + const valType = capi.sqlite3_value_type(pVal); + switch(valType){ + case capi.SQLITE_INTEGER: + if(wasm.bigIntEnabled){ + arg = capi.sqlite3_value_int64(pVal); + if(util.bigIntFitsDouble(arg)) arg = Number(arg); + } + else arg = capi.sqlite3_value_double(pVal); + break; + case capi.SQLITE_FLOAT: + arg = capi.sqlite3_value_double(pVal); + break; + case capi.SQLITE_TEXT: + arg = capi.sqlite3_value_text(pVal); + break; + case capi.SQLITE_BLOB:{ + const n = capi.sqlite3_value_bytes(pVal); + const pBlob = capi.sqlite3_value_blob(pVal); + if(n && !pBlob) sqlite3.WasmAllocError.toss( + "Cannot allocate memory for blob argument of",n,"byte(s)" + ); + arg = n + ? wasm.heap8u().slice(Number(pBlob), Number(pBlob) + Number(n)) + : null; + break; + } + case capi.SQLITE_NULL: + arg = null; break; + default: + if(throwIfCannotConvert){ + toss3(capi.SQLITE_MISMATCH, + "Unhandled sqlite3_value_type():",valType); + } + arg = undefined; + } + return arg; + }; + + + capi.sqlite3_values_to_js = function(argc,pArgv,throwIfCannotConvert=true){ + let i; + const tgt = []; + for(i = 0; i < argc; ++i){ + + tgt.push(capi.sqlite3_value_to_js( + wasm.peekPtr(wasm.ptr.add(pArgv, wasm.ptr.size * i)), + throwIfCannotConvert + )); + } + return tgt; + }; + + + capi.sqlite3_result_error_js = function(pCtx,e){ + if(e instanceof WasmAllocError){ + capi.sqlite3_result_error_nomem(pCtx); + }else{ + ; + capi.sqlite3_result_error(pCtx, ''+e, -1); + } + }; + + + capi.sqlite3_result_js = function(pCtx,val){ + if(val instanceof Error){ + capi.sqlite3_result_error_js(pCtx, val); + return; + } + try{ + switch(typeof val) { + case 'undefined': + + break; + case 'boolean': + capi.sqlite3_result_int(pCtx, val ? 1 : 0); + break; + case 'bigint': + if(util.bigIntFits32(val)){ + capi.sqlite3_result_int(pCtx, Number(val)); + }else if(util.bigIntFitsDouble(val)){ + capi.sqlite3_result_double(pCtx, Number(val)); + }else if(wasm.bigIntEnabled){ + if(util.bigIntFits64(val)) capi.sqlite3_result_int64(pCtx, val); + else toss3("BigInt value",val.toString(),"is too BigInt for int64."); + }else{ + toss3("BigInt value",val.toString(),"is too BigInt."); + } + break; + case 'number': { + let f; + if(util.isInt32(val)){ + f = capi.sqlite3_result_int; + }else if(wasm.bigIntEnabled + && Number.isInteger(val) + && util.bigIntFits64(BigInt(val))){ + f = capi.sqlite3_result_int64; + }else{ + f = capi.sqlite3_result_double; + } + f(pCtx, val); + break; + } + case 'string': { + const [p, n] = wasm.allocCString(val,true); + capi.sqlite3_result_text(pCtx, p, n, capi.SQLITE_WASM_DEALLOC); + break; + } + case 'object': + if(null===val) { + capi.sqlite3_result_null(pCtx); + break; + }else if(util.isBindableTypedArray(val)){ + const pBlob = wasm.allocFromTypedArray(val); + capi.sqlite3_result_blob( + pCtx, pBlob, val.byteLength, + capi.SQLITE_WASM_DEALLOC + ); + break; + } + + default: + toss3("Don't not how to handle this UDF result value:",(typeof val), val); + } + }catch(e){ + capi.sqlite3_result_error_js(pCtx, e); + } + }; + + + capi.sqlite3_column_js = function(pStmt, iCol, throwIfCannotConvert=true){ + const v = capi.sqlite3_column_value(pStmt, iCol); + return (0===v) ? undefined : capi.sqlite3_value_to_js(v, throwIfCannotConvert); + }; + + if( true ){ + + const __newOldValue = function(pObj, iCol, impl){ + impl = capi[impl]; + if(!this.ptr) this.ptr = wasm.allocPtr(); + else wasm.pokePtr(this.ptr, 0); + const rc = impl(pObj, iCol, this.ptr); + if(rc) return SQLite3Error.toss(rc,arguments[2]+"() failed with code "+rc); + const pv = wasm.peekPtr(this.ptr); + return pv ? capi.sqlite3_value_to_js( pv, true ) : undefined; + }.bind(nu()); + + + capi.sqlite3_preupdate_new_js = + (pDb, iCol)=>__newOldValue(pDb, iCol, 'sqlite3_preupdate_new'); + + + capi.sqlite3_preupdate_old_js = + (pDb, iCol)=>__newOldValue(pDb, iCol, 'sqlite3_preupdate_old'); + + + capi.sqlite3changeset_new_js = + (pChangesetIter, iCol) => __newOldValue(pChangesetIter, iCol, + 'sqlite3changeset_new'); + + + capi.sqlite3changeset_old_js = + (pChangesetIter, iCol)=>__newOldValue(pChangesetIter, iCol, + 'sqlite3changeset_old'); + } + + + capi.sqlite3_js_retry_busy = function(maxTimes, callback, beforeRetry){ + for(let n = 1; n <= maxTimes; ++n){ + try{ + if( beforeRetry && n>1 ) beforeRetry(n); + const rc = callback(); + if( capi.SQLITE_BUSY===rc ){ + if( n===maxTimes ){ + throw new SQLite3Error(rc, [ + "sqlite3_js_retry_busy() max retry attempts (", + maxTimes, + ") reached." + ].join('')); + } + continue; + } + return rc; + }catch(e){ + if( n{ + if(!sqlite3.__isUnderTest){ + + delete sqlite3.util; + delete sqlite3.StructBinder; + delete sqlite3.opfs; + } + return sqlite3; + }; + const catcher = (e)=>{ + config.error("an async sqlite3 initializer failed:",e); + throw e; + }; + if(!lia || !lia.length){ + return ff.isReady = postInit().catch(catcher); + } + lia = lia.map((f)=>{ + return (f instanceof Function) ? async x=>f(sqlite3) : f; + }); + lia.push(postInit); + let p = Promise.resolve(sqlite3); + while(lia.length) p = p.then(lia.shift()); + return ff.isReady = p.catch(catcher); + }.bind(sqlite3ApiBootstrap), + + scriptInfo: undefined + }; + if( 'undefined'!==typeof sqlite3IsUnderTest ){ + sqlite3.__isUnderTest = !!sqlite3IsUnderTest; + } + try{ + sqlite3ApiBootstrap.initializers.forEach((f)=>{ + f(sqlite3); + }); + }catch(e){ + + console.error("sqlite3 bootstrap initializer threw:",e); + throw e; + } + delete sqlite3ApiBootstrap.initializers; + sqlite3ApiBootstrap.sqlite3 = sqlite3; + if( 'undefined'!==typeof sqlite3InitScriptInfo ){ + sqlite3InitScriptInfo.debugModule( + "sqlite3ApiBootstrap() complete", sqlite3 + ); + sqlite3.scriptInfo + = sqlite3InitScriptInfo; + } + if( sqlite3.__isUnderTest ){ + if( 'undefined'!==typeof EmscriptenModule ){ + sqlite3.config.emscripten = EmscriptenModule; + } + + const iw = sqlite3.scriptInfo?.instantiateWasm; + if( iw ){ + + sqlite3.wasm.module = iw.module; + sqlite3.wasm.instance = iw.instance; + sqlite3.wasm.imports = iw.imports; + } + } + + + delete globalThis.sqlite3ApiConfig; + delete globalThis.sqlite3ApiBootstrap; + delete sqlite3ApiBootstrap.defaultConfig; + return sqlite3.asyncPostInit().then((s)=>{ + if( 'undefined'!==typeof sqlite3InitScriptInfo ){ + sqlite3InitScriptInfo.debugModule( + "sqlite3.asyncPostInit() complete", s + ); + } + delete s.asyncPostInit; + delete s.scriptInfo; + delete s.emscripten; + return s; + }); +}; + + +globalThis.sqlite3ApiBootstrap.initializers = []; + + +globalThis.sqlite3ApiBootstrap.initializersAsync = []; + + +globalThis.sqlite3ApiBootstrap.defaultConfig = Object.create(null); + + +globalThis.sqlite3ApiBootstrap.sqlite3 = undefined; +globalThis.sqlite3ApiBootstrap.initializers.push(function(sqlite3){ + sqlite3.version = {"libVersion": "3.54.0", "libVersionNumber": 3054000, "sourceId": "2026-10-09 15:46:58 be8d059e9a49089ab2dce5ed26dd87aaf598fdc5fbe0b107c0dd758464bcd5e3","downloadVersion": 3540000,"scm":{ "sha3-256": "be8d059e9a49089ab2dce5ed26dd87aaf598fdc5fbe0b107c0dd758464bcd5e3","branch": "trunk","tags": "release major-release version-3.54.0","datetime": "2026-10-09T15:46:58.543Z"}}; +}); + + +'use strict'; +globalThis.WhWasmUtilInstaller = +function WhWasmUtilInstaller(target){ + 'use strict'; + if(undefined===target.bigIntEnabled){ + target.bigIntEnabled = !!globalThis['BigInt64Array']; + } + + + const toss = (...args)=>{throw new Error(args.join(' '))}; + + if( !target.pointerSize && !target.pointerIR + && target.alloc && target.dealloc ){ + + const ptr = target.alloc(1); + target.pointerSize = ('bigint'===typeof ptr ? 8 : 4); + target.dealloc(ptr); + } + + + if( target.pointerSize && !target.pointerIR ){ + target.pointerIR = (4===target.pointerSize ? 'i32' : 'i64'); + } + const __ptrIR = (target.pointerIR ??= 'i32'); + const __ptrSize = (target.pointerSize ??= + ('i32'===__ptrIR ? 4 : ('i64'===__ptrIR ? 8 : 0))); + delete target.pointerSize; + delete target.pointerIR; + + if( 'i32'!==__ptrIR && 'i64'!==__ptrIR ){ + toss("Invalid pointerIR:",__ptrIR); + }else if( 8!==__ptrSize && 4!==__ptrSize ){ + toss("Invalid pointerSize:",__ptrSize); + } + + + const __BigInt = target.bigIntEnabled + ? (v)=>BigInt(v || 0) + : (v)=>toss("BigInt support is disabled in this build."); + + const __Number = (v)=>Number(v||0); + + + const __asPtrType = (4===__ptrSize) ? __Number : __BigInt; + + + const __NullPtr = __asPtrType(0); + + + const __ptrAdd = function(...args){ + let rc = __asPtrType(0); + for(const v of args) rc += __asPtrType(v); + return rc; + }; + + + { + const __ptr = Object.create(null); + Object.defineProperty(target, 'ptr', { + enumerable: true, + get: ()=>__ptr, + set: ()=>toss("The ptr property is read-only.") + }); + (function f(name, val){ + Object.defineProperty(__ptr, name, { + enumerable: true, + get: ()=>val, + set: ()=>toss("ptr["+name+"] is read-only.") + }); + return f; + })( + 'null', __NullPtr + )( + 'size', __ptrSize + )( + 'ir', __ptrIR + )( + 'coerce', __asPtrType + )( + 'add', __ptrAdd + )( + 'addn', (4===__ptrIR) ? __ptrAdd : (...args)=>Number(__ptrAdd(...args)) + ); + } + + if(!target.exports){ + Object.defineProperty(target, 'exports', { + enumerable: true, configurable: true, + get: ()=>(target.instance?.exports) + }); + } + + + const cache = Object.create(null); + + cache.heapSize = 0; + + cache.memory = null; + + cache.freeFuncIndexes = []; + + cache.scopedAlloc = []; + + + cache.scopedAlloc.pushPtr = (ptr)=>{ + cache.scopedAlloc[cache.scopedAlloc.length-1].push(ptr); + return ptr; + }; + + cache.utf8Decoder = new TextDecoder(); + cache.utf8Encoder = new TextEncoder('utf-8'); + + + target.sizeofIR = (n)=>{ + switch(n){ + case 'i8': return 1; + case 'i16': return 2; + case 'i32': case 'f32': case 'float': return 4; + case 'i64': case 'f64': case 'double': return 8; + case '*': return __ptrSize; + default: + return (''+n).endsWith('*') ? __ptrSize : undefined; + } + }; + + + const heapWrappers = function(){ + if(!cache.memory){ + cache.memory = (target.memory instanceof WebAssembly.Memory) + ? target.memory : target.exports.memory; + }else if(cache.heapSize === cache.memory.buffer.byteLength){ + return cache; + } + + const b = cache.memory.buffer; + cache.HEAP8 = new Int8Array(b); cache.HEAP8U = new Uint8Array(b); + cache.HEAP16 = new Int16Array(b); cache.HEAP16U = new Uint16Array(b); + cache.HEAP32 = new Int32Array(b); cache.HEAP32U = new Uint32Array(b); + cache.HEAP32F = new Float32Array(b); cache.HEAP64F = new Float64Array(b); + if(target.bigIntEnabled){ + if( 'undefined'!==typeof BigInt64Array ){ + cache.HEAP64 = new BigInt64Array(b); cache.HEAP64U = new BigUint64Array(b); + }else{ + toss("BigInt support is enabled, but the BigInt64Array type is missing."); + } + } + cache.heapSize = b.byteLength; + return cache; + }; + + + target.heap8 = ()=>heapWrappers().HEAP8; + + + target.heap8u = ()=>heapWrappers().HEAP8U; + + + target.heap16 = ()=>heapWrappers().HEAP16; + + + target.heap16u = ()=>heapWrappers().HEAP16U; + + + target.heap32 = ()=>heapWrappers().HEAP32; + + + target.heap32u = ()=>heapWrappers().HEAP32U; + + + target.heapForSize = function(n,unsigned = true){ + let ctor; + const c = (cache.memory && cache.heapSize === cache.memory.buffer.byteLength) + ? cache : heapWrappers(); + switch(n){ + case Int8Array: return c.HEAP8; case Uint8Array: return c.HEAP8U; + case Int16Array: return c.HEAP16; case Uint16Array: return c.HEAP16U; + case Int32Array: return c.HEAP32; case Uint32Array: return c.HEAP32U; + case 8: return unsigned ? c.HEAP8U : c.HEAP8; + case 16: return unsigned ? c.HEAP16U : c.HEAP16; + case 32: return unsigned ? c.HEAP32U : c.HEAP32; + case 64: + if(c.HEAP64) return unsigned ? c.HEAP64U : c.HEAP64; + break; + default: + if(target.bigIntEnabled){ + if(n===globalThis['BigUint64Array']) return c.HEAP64U; + else if(n===globalThis['BigInt64Array']) return c.HEAP64; + break; + } + } + toss("Invalid heapForSize() size: expecting 8, 16, 32,", + "or (if BigInt is enabled) 64."); + }; + + const __funcTable = target.functionTable; + delete target.functionTable; + + + target.functionTable = __funcTable + ? ()=>__funcTable + : ()=>target.exports.__indirect_function_table + ; + + + target.functionEntry = function(fptr){ + const ft = target.functionTable(); + + + + return fptr < ft.length ? ft.get(__asPtrType(fptr)) : undefined; + }; + + + target.jsFuncToWasm = function f(func, sig){ + + if(!f._){ + f._ = { + + sigTypes: Object.assign(Object.create(null),{ + i: 'i32', p: __ptrIR, P: __ptrIR, s: __ptrIR, + j: 'i64', f: 'f32', d: 'f64' + }), + + + typeCodes: Object.assign(Object.create(null),{ + f64: 0x7c, f32: 0x7d, i64: 0x7e, i32: 0x7f + }), + + + uleb128Encode: (tgt, method, n)=>{ + if(n<128) tgt[method](n); + else tgt[method]( (n % 128) | 128, n>>7); + }, + + + rxJSig: /^(\w)\((\w*)\)$/, + + + sigParams: (sig)=>{ + const m = f._.rxJSig.exec(sig); + return m ? m[2] : sig.substr(1); + }, + + + letterType: (x)=>f._.sigTypes[x] || toss("Invalid signature letter:",x), + + + pushSigType: (dest, letter)=>dest.push(f._.typeCodes[f._.letterType(letter)]) + + + + }; + } + if('string'===typeof func){ + const x = sig; + sig = func; + func = x; + } + const _ = f._; + const sigParams = _.sigParams(sig); + const wasmCode = [0x01, 0x60]; + _.uleb128Encode(wasmCode, 'push', sigParams.length); + for(const x of sigParams) _.pushSigType(wasmCode, x); + if('v'===sig[0]) wasmCode.push(0); + else{ + wasmCode.push(1); + _.pushSigType(wasmCode, sig[0]); + } + _.uleb128Encode(wasmCode, 'unshift', wasmCode.length); + wasmCode.unshift( + 0x00, 0x61, 0x73, 0x6d, + 0x01, 0x00, 0x00, 0x00, + 0x01 + ); + wasmCode.push( + 0x02, 0x07, + + 0x01, 0x01, 0x65, 0x01, 0x66, 0x00, 0x00, + 0x07, 0x05, + + 0x01, 0x01, 0x66, 0x00, 0x00 + ); + return (new WebAssembly.Instance( + new WebAssembly.Module(new Uint8Array(wasmCode)), { + e: { f: func } + })).exports['f']; + }; + + + const __installFunction = function f(func, sig, scoped){ + if(scoped && !cache.scopedAlloc.length){ + toss("No scopedAllocPush() scope is active."); + } + if('string'===typeof func){ + const x = sig; + sig = func; + func = x; + } + if('string'!==typeof sig || !(func instanceof Function)){ + toss("Invalid arguments: expecting (function,signature) "+ + "or (signature,function)."); + } + const ft = target.functionTable(); + const oldLen = __asPtrType(ft.length); + let ptr; + while( (ptr = cache.freeFuncIndexes.pop()) ){ + if(ft.get(ptr)){ + + ptr = null; + continue; + }else{ + + break; + } + } + if(!ptr){ + ptr = __asPtrType(oldLen); + ft.grow(__asPtrType(1)); + } + try{ + + ft.set(ptr, func); + if(scoped){ + cache.scopedAlloc.pushPtr(ptr); + } + return ptr; + }catch(e){ + if(!(e instanceof TypeError)){ + if(ptr===oldLen) cache.freeFuncIndexes.push(oldLen); + throw e; + } + } + + try { + const fptr = target.jsFuncToWasm(func, sig); + ft.set(ptr, fptr); + if(scoped){ + cache.scopedAlloc.pushPtr(ptr); + } + }catch(e){ + if(ptr===oldLen) cache.freeFuncIndexes.push(oldLen); + throw e; + } + return ptr; + }; + + + target.installFunction = (func, sig)=>__installFunction(func, sig, false); + + + target.scopedInstallFunction = (func, sig)=>__installFunction(func, sig, true); + + + target.uninstallFunction = function(ptr){ + if(!ptr && __NullPtr!==ptr) return undefined; + + const ft = target.functionTable(); + cache.freeFuncIndexes.push(ptr); + const rc = ft.get(ptr); + ft.set(ptr, null); + return rc; + }; + + + target.peek = function f(ptr, type='i8'){ + if(type.endsWith('*')) type = __ptrIR; + const c = (cache.memory && cache.heapSize === cache.memory.buffer.byteLength) + ? cache : heapWrappers(); + const list = Array.isArray(ptr) ? [] : undefined; + let rc; + do{ + if(list) ptr = arguments[0].shift(); + switch(type){ + case 'i1': + case 'i8': rc = c.HEAP8[Number(ptr)>>0]; break; + case 'i16': rc = c.HEAP16[Number(ptr)>>1]; break; + case 'i32': rc = c.HEAP32[Number(ptr)>>2]; break; + case 'float': case 'f32': rc = c.HEAP32F[Number(ptr)>>2]; break; + case 'double': case 'f64': rc = Number(c.HEAP64F[Number(ptr)>>3]); break; + case 'i64': + if(c.HEAP64){ + rc = __BigInt(c.HEAP64[Number(ptr)>>3]); + break; + } + + default: + toss('Invalid type for peek():',type); + } + if(list) list.push(rc); + }while(list && arguments[0].length); + return list || rc; + }; + + + target.poke = function(ptr, value, type='i8'){ + if (type.endsWith('*')) type = __ptrIR; + const c = (cache.memory && cache.heapSize === cache.memory.buffer.byteLength) + ? cache : heapWrappers(); + for(const p of (Array.isArray(ptr) ? ptr : [ptr])){ + switch (type) { + case 'i1': + case 'i8': c.HEAP8[Number(p)>>0] = value; continue; + case 'i16': c.HEAP16[Number(p)>>1] = value; continue; + case 'i32': c.HEAP32[Number(p)>>2] = value; continue; + case 'float': case 'f32': c.HEAP32F[Number(p)>>2] = value; continue; + case 'double': case 'f64': c.HEAP64F[Number(p)>>3] = value; continue; + case 'i64': + if(c.HEAP64){ + c.HEAP64[Number(p)>>3] = __BigInt(value); + continue; + } + + default: + toss('Invalid type for poke(): ' + type); + } + } + return this; + }; + + + target.peekPtr = (...ptr)=>target.peek( (1===ptr.length ? ptr[0] : ptr), __ptrIR ); + + + target.pokePtr = (ptr, value=0)=>target.poke(ptr, value, __ptrIR); + + + target.peek8 = (...ptr)=>target.peek( (1===ptr.length ? ptr[0] : ptr), 'i8' ); + + target.poke8 = (ptr, value)=>target.poke(ptr, value, 'i8'); + + target.peek16 = (...ptr)=>target.peek( (1===ptr.length ? ptr[0] : ptr), 'i16' ); + + target.poke16 = (ptr, value)=>target.poke(ptr, value, 'i16'); + + target.peek32 = (...ptr)=>target.peek( (1===ptr.length ? ptr[0] : ptr), 'i32' ); + + target.poke32 = (ptr, value)=>target.poke(ptr, value, 'i32'); + + target.peek64 = (...ptr)=>target.peek( (1===ptr.length ? ptr[0] : ptr), 'i64' ); + + target.poke64 = (ptr, value)=>target.poke(ptr, value, 'i64'); + + target.peek32f = (...ptr)=>target.peek( (1===ptr.length ? ptr[0] : ptr), 'f32' ); + + target.poke32f = (ptr, value)=>target.poke(ptr, value, 'f32'); + + target.peek64f = (...ptr)=>target.peek( (1===ptr.length ? ptr[0] : ptr), 'f64' ); + + target.poke64f = (ptr, value)=>target.poke(ptr, value, 'f64'); + + + target.getMemValue = target.peek; + + target.getPtrValue = target.peekPtr; + + target.setMemValue = target.poke; + + target.setPtrValue = target.pokePtr; + + + target.isPtr32 = (ptr)=>( + 'number'===typeof ptr && ptr>=0 && ptr===(ptr|0) + ); + + + target.isPtr64 = (ptr)=>( + ('bigint'===typeof ptr) ? ptr >= 0 : target.isPtr32(ptr) + ); + + + target.isPtr = (4===__ptrSize) ? target.isPtr32 : target.isPtr64; + + + target.cstrlen = function(ptr){ + if(!ptr || !target.isPtr(ptr)) return null; + ptr = Number(ptr) ; + const h = heapWrappers().HEAP8U; + let pos = ptr; + for( ; h[pos] !== 0; ++pos ){} + return pos - ptr; + }; + + + const __SAB = ('undefined'===typeof SharedArrayBuffer) + ? function(){} : SharedArrayBuffer; + + const isSharedTypedArray = (aTypedArray)=>(aTypedArray.buffer instanceof __SAB); + + target.isSharedTypedArray = isSharedTypedArray; + + + const typedArrayPart = (aTypedArray, begin, end)=>{ + if( 8===__ptrSize ){ + + if( 'bigint'===typeof begin ) begin = Number(begin); + if( 'bigint'===typeof end ) end = Number(end); + } + return isSharedTypedArray(aTypedArray) + ? aTypedArray.slice(begin, end) + : aTypedArray.subarray(begin, end); + }; + + target.typedArrayPart = typedArrayPart; + + + const typedArrayToString = (typedArray, begin, end)=> + cache.utf8Decoder.decode( + typedArrayPart(typedArray, begin, end) + ); + + target.typedArrayToString = typedArrayToString; + + + target.cstrToJs = function(ptr){ + const n = target.cstrlen(ptr); + return n + ? typedArrayToString(heapWrappers().HEAP8U, Number(ptr), Number(ptr)+n) + : (null===n ? n : ""); + }; + + + target.jstrlen = function(str){ + + if('string'!==typeof str) return null; + const n = str.length; + let len = 0; + for(let i = 0; i < n; ++i){ + let u = str.charCodeAt(i); + if(u>=0xd800 && u<=0xdfff){ + u = 0x10000 + ((u & 0x3FF) << 10) | (str.charCodeAt(++i) & 0x3FF); + } + if(u<=0x7f) ++len; + else if(u<=0x7ff) len += 2; + else if(u<=0xffff) len += 3; + else len += 4; + } + return len; + }; + + + target.jstrcpy = function(jstr, tgt, offset = 0, maxBytes = -1, addNul = true){ + + if(!tgt || (!(tgt instanceof Int8Array) && !(tgt instanceof Uint8Array))){ + toss("jstrcpy() target must be an Int8Array or Uint8Array."); + } + maxBytes = Number(maxBytes); + offset = Number(offset); + if(maxBytes<0) maxBytes = tgt.length - offset; + if(!(maxBytes>0) || !(offset>=0)) return 0; + let i = 0, max = jstr.length; + const begin = offset, end = offset + maxBytes - (addNul ? 1 : 0); + for(; i < max && offset < end; ++i){ + let u = jstr.charCodeAt(i); + if(u>=0xd800 && u<=0xdfff){ + u = 0x10000 + ((u & 0x3FF) << 10) | (jstr.charCodeAt(++i) & 0x3FF); + } + if(u<=0x7f){ + if(offset >= end) break; + tgt[offset++] = u; + }else if(u<=0x7ff){ + if(offset + 1 >= end) break; + tgt[offset++] = 0xC0 | (u >> 6); + tgt[offset++] = 0x80 | (u & 0x3f); + }else if(u<=0xffff){ + if(offset + 2 >= end) break; + tgt[offset++] = 0xe0 | (u >> 12); + tgt[offset++] = 0x80 | ((u >> 6) & 0x3f); + tgt[offset++] = 0x80 | (u & 0x3f); + }else{ + if(offset + 3 >= end) break; + tgt[offset++] = 0xf0 | (u >> 18); + tgt[offset++] = 0x80 | ((u >> 12) & 0x3f); + tgt[offset++] = 0x80 | ((u >> 6) & 0x3f); + tgt[offset++] = 0x80 | (u & 0x3f); + } + } + if(addNul) tgt[offset++] = 0; + return offset - begin; + }; + + + target.cstrncpy = function(tgtPtr, srcPtr, n){ + if(!tgtPtr || !srcPtr) toss("cstrncpy() does not accept NULL strings."); + if(n<0) n = target.cstrlen(strPtr)+1; + else if(!(n>0)) return 0; + const heap = target.heap8u(); + let i = 0, ch; + const tgtNumber = Number(tgtPtr), srcNumber = Number(srcPtr); + for(; i < n && (ch = heap[srcNumber+i]); ++i){ + heap[tgtNumber+i] = ch; + } + if(i{ + return cache.utf8Encoder.encode(addNul ? (str+"\0") : str); + }; + + const __affirmAlloc = (obj,funcName)=>{ + if(!(obj.alloc instanceof Function) || + !(obj.dealloc instanceof Function)){ + toss("Object is missing alloc() and/or dealloc() function(s)", + "required by",funcName+"()."); + } + }; + + const __allocCStr = function(jstr, returnWithLength, allocator, funcName){ + __affirmAlloc(target, funcName); + if('string'!==typeof jstr) return null; + const u = cache.utf8Encoder.encode(jstr), + ptr = allocator(u.length+1); + let toFree = ptr; + try{ + const heap = heapWrappers().HEAP8U; + heap.set(u, Number(ptr)); + heap[__ptrAdd(ptr, u.length)] = 0; + toFree = __NullPtr; + return returnWithLength ? [ptr, u.length] : ptr; + }finally{ + if( toFree ) target.dealloc(toFree); + } + }; + + + target.allocCString = + (jstr, returnWithLength=false)=>__allocCStr(jstr, returnWithLength, + target.alloc, 'allocCString()'); + + + target.scopedAllocPush = function(){ + __affirmAlloc(target, 'scopedAllocPush'); + const a = []; + cache.scopedAlloc.push(a); + return a; + }; + + + target.scopedAllocPop = function(state){ + __affirmAlloc(target, 'scopedAllocPop'); + const n = arguments.length + ? cache.scopedAlloc.indexOf(state) + : cache.scopedAlloc.length-1; + if(n<0) toss("Invalid state object for scopedAllocPop()."); + if(0===arguments.length) state = cache.scopedAlloc[n]; + cache.scopedAlloc.splice(n,1); + for(let p; (p = state.pop()); ){ + if(target.functionEntry(p)){ + + target.uninstallFunction(p); + }else{ + target.dealloc(p); + } + } + }; + + + target.scopedAlloc = function(n){ + if(!cache.scopedAlloc.length){ + toss("No scopedAllocPush() scope is active."); + } + const p = __asPtrType(target.alloc(n)); + return cache.scopedAlloc.pushPtr(p); + }; + + Object.defineProperty(target.scopedAlloc, 'level', { + configurable: false, enumerable: false, + get: ()=>cache.scopedAlloc.length, + set: ()=>toss("The 'active' property is read-only.") + }); + + + target.scopedAllocCString = + (jstr, returnWithLength=false)=>__allocCStr(jstr, returnWithLength, + target.scopedAlloc, + 'scopedAllocCString()'); + + + const __allocMainArgv = function(isScoped, list){ + const pList = target[ + isScoped ? 'scopedAlloc' : 'alloc' + ]((list.length + 1) * target.ptr.size); + let i = 0; + list.forEach((e)=>{ + target.pokePtr(__ptrAdd(pList, target.ptr.size * i++), + target[ + isScoped ? 'scopedAllocCString' : 'allocCString' + ](""+e)); + }); + target.pokePtr(__ptrAdd(pList, target.ptr.size * i), 0); + return pList; + }; + + + target.scopedAllocMainArgv = (list)=>__allocMainArgv(true, list); + + + target.allocMainArgv = (list)=>__allocMainArgv(false, list); + + + target.cArgvToJs = (argc, pArgv)=>{ + const list = []; + for(let i = 0; i < argc; ++i){ + const arg = target.peekPtr(__ptrAdd(pArgv, target.ptr.size * i)); + list.push( arg ? target.cstrToJs(arg) : null ); + } + return list; + }; + + + target.scopedAllocCall = function(func){ + target.scopedAllocPush(); + try{ return func() } finally{ target.scopedAllocPop() } + }; + + + const __allocPtr = function(howMany, safePtrSize, method){ + __affirmAlloc(target, method); + const pIr = safePtrSize ? 'i64' : __ptrIR; + let m = target[method](howMany * (safePtrSize ? 8 : __ptrSize)); + target.poke(m, 0, pIr) + if(1===howMany){ + return m; + } + const a = [m]; + for(let i = 1; i < howMany; ++i){ + m = __ptrAdd(m, (safePtrSize ? 8 : __ptrSize)); + a[i] = m; + target.poke(m, 0, pIr); + } + return a; + }; + + + target.allocPtr = + (howMany=1, safePtrSize=true)=>__allocPtr(howMany, safePtrSize, 'alloc'); + + + target.scopedAllocPtr = + (howMany=1, safePtrSize=true)=>__allocPtr(howMany, safePtrSize, 'scopedAlloc'); + + + target.xGet = function(name){ + return target.exports[name] || toss("Cannot find exported symbol:",name); + }; + + const __argcMismatch = + (f,n)=>toss(f+"() requires",n,"argument(s)."); + + + target.xCall = function(fname, ...args){ + const f = (fname instanceof Function) ? fname : target.xGet(fname); + if(!(f instanceof Function)) toss("Exported symbol",fname,"is not a function."); + if(f.length!==args.length){ + __argcMismatch(((f===fname) ? f.name : fname),f.length) + ; + } + return (2===arguments.length && Array.isArray(arguments[1])) + ? f.apply(null, arguments[1]) + : f.apply(null, args); + }; + + + cache.xWrap = Object.create(null); + cache.xWrap.convert = Object.create(null); + + cache.xWrap.convert.arg = new Map; + + cache.xWrap.convert.result = new Map; + + const xArg = cache.xWrap.convert.arg, xResult = cache.xWrap.convert.result; + + const __xArgPtr = __asPtrType; + xArg + .set('i64', __BigInt) + .set('i32', (i)=>i|0) + .set('i16', (i)=>((i | 0) & 0xFFFF)) + .set('i8', (i)=>((i | 0) & 0xFF)) + .set('f32', (i)=>Number(i).valueOf()) + .set('float', xArg.get('f32')) + .set('f64', xArg.get('f32')) + .set('double', xArg.get('f64')) + .set('int', xArg.get('i32')) + .set('null', (i)=>i) + .set(null, xArg.get('null')) + .set('**', __xArgPtr) + .set('*', __xArgPtr) + ; + xResult.set('*', __xArgPtr) + .set('pointer', __xArgPtr) + .set('number', (v)=>Number(v)) + .set('void', (v)=>undefined) + .set(undefined, xResult.get('void')) + .set('null', (v)=>v) + .set(null, xResult.get('null')) + ; + + + for(const t of [ + 'i8', 'i16', 'i32', 'i64', 'int', + 'f32', 'float', 'f64', 'double' + ]){ + xArg.set(t+'*', __xArgPtr); + xResult.set(t+'*', __xArgPtr); + xResult.set( + t, xArg.get(t) + || toss("Maintenance required: missing arg converter for",t) + ); + } + + + const __xArgString = (v)=>{ + return ('string'===typeof v) + ? target.scopedAllocCString(v) + : __asPtrType(v); + }; + + xArg.set('string', __xArgString) + .set('utf8', __xArgString) + + ; + + xResult + .set('string', (i)=>target.cstrToJs(i)) + .set('utf8', xResult.get('string')) + .set('string:dealloc', (i)=>{ + try { return i ? target.cstrToJs(i) : null } + finally{ target.dealloc(i) } + }) + .set('utf8:dealloc', xResult.get('string:dealloc')) + .set('json', (i)=>JSON.parse(target.cstrToJs(i))) + .set('json:dealloc', (i)=>{ + try{ return i ? JSON.parse(target.cstrToJs(i)) : null } + finally{ target.dealloc(i) } + }); + + + const AbstractArgAdapter = class { + constructor(opt){ + this.name = opt.name || 'unnamed adapter'; + } + + convertArg(v,argv,argIndex){ + toss("AbstractArgAdapter must be subclassed."); + } + }; + + + xArg.FuncPtrAdapter = class FuncPtrAdapter extends AbstractArgAdapter { + constructor(opt) { + super(opt); + if(xArg.FuncPtrAdapter.warnOnUse){ + console.warn('xArg.FuncPtrAdapter is an internal-only API', + 'and is not intended to be invoked from', + 'client-level code. Invoked with:',opt); + } + this.name = opt.name || "unnamed"; + this.signature = opt.signature; + if(opt.contextKey instanceof Function){ + this.contextKey = opt.contextKey; + if(!opt.bindScope) opt.bindScope = 'context'; + } + this.bindScope = opt.bindScope + || toss("FuncPtrAdapter options requires a bindScope (explicit or implied)."); + if(FuncPtrAdapter.bindScopes.indexOf(opt.bindScope)<0){ + toss("Invalid options.bindScope ("+opt.bindMod+") for FuncPtrAdapter. "+ + "Expecting one of: ("+FuncPtrAdapter.bindScopes.join(', ')+')'); + } + this.isTransient = 'transient'===this.bindScope; + this.isContext = 'context'===this.bindScope; + this.isPermanent = 'permanent'===this.bindScope; + this.singleton = ('singleton'===this.bindScope) ? [] : undefined; + + this.callProxy = (opt.callProxy instanceof Function) + ? opt.callProxy : undefined; + } + + + + + contextKey(argv,argIndex){ + return this; + } + + + contextMap(key){ + const cm = (this.__cmap || (this.__cmap = new Map)); + let rc = cm.get(key); + if(undefined===rc) cm.set(key, (rc = [])); + return rc; + } + + + convertArg(v,argv,argIndex){ + let pair = this.singleton; + if(!pair && this.isContext){ + pair = this.contextMap(this.contextKey(argv,argIndex)); + + } + if( 0 ){ + FuncPtrAdapter.debugOut("FuncPtrAdapter.convertArg()",this.name, + 'signature =',this.signature, + 'transient ?=',this.transient, + 'pair =',pair, + 'v =',v); + } + if(pair && 2===pair.length && pair[0]===v){ + + return pair[1]; + } + if(v instanceof Function){ + + + if(this.callProxy){ + v = this.callProxy(v); + } + const fp = __installFunction(v, this.signature, this.isTransient); + if(FuncPtrAdapter.debugFuncInstall){ + FuncPtrAdapter.debugOut("FuncPtrAdapter installed", this, + this.contextKey(argv,argIndex), '@'+fp, v); + } + if(pair){ + + if(pair[1]){ + if(FuncPtrAdapter.debugFuncInstall){ + FuncPtrAdapter.debugOut("FuncPtrAdapter uninstalling", this, + this.contextKey(argv,argIndex), '@'+pair[1], v); + } + try{ + + cache.scopedAlloc.pushPtr(pair[1]); + } + catch(e){} + } + pair[0] = arguments[0] || __NullPtr; + pair[1] = fp; + } + return fp; + }else if(target.isPtr(v) || null===v || undefined===v){ + if(pair && pair[1] && pair[1]!==v){ + + if(FuncPtrAdapter.debugFuncInstall){ + FuncPtrAdapter.debugOut("FuncPtrAdapter uninstalling", this, + this.contextKey(argv,argIndex), '@'+pair[1], v); + } + try{cache.scopedAlloc.pushPtr(pair[1]);} + catch(e){} + pair[0] = pair[1] = (v || __NullPtr); + } + return v || __NullPtr; + }else{ + throw new TypeError("Invalid FuncPtrAdapter argument type. "+ + "Expecting a function pointer or a "+ + (this.name ? this.name+' ' : '')+ + "function matching signature "+ + this.signature+"."); + } + } + }; + + + xArg.FuncPtrAdapter.warnOnUse = false; + + + xArg.FuncPtrAdapter.debugFuncInstall = false; + + + xArg.FuncPtrAdapter.debugOut = console.debug.bind(console); + + + xArg.FuncPtrAdapter.bindScopes = [ + 'transient', 'context', 'singleton', 'permanent' + ]; + + + const __xArgAdapterCheck = + (t)=>xArg.get(t) || toss("Argument adapter not found:",t); + + + const __xResultAdapterCheck = + (t)=>xResult.get(t) || toss("Result adapter not found:",t); + + + cache.xWrap.convertArg = (t,...args)=>__xArgAdapterCheck(t)(...args); + + cache.xWrap.convertArgNoCheck = (t,...args)=>xArg.get(t)(...args); + + + cache.xWrap.convertResult = + (t,v)=>(null===t ? v : (t ? __xResultAdapterCheck(t)(v) : undefined)); + + cache.xWrap.convertResultNoCheck = + (t,v)=>(null===t ? v : (t ? xResult.get(t)(v) : undefined)); + + + target.xWrap = function callee(fArg, resultType, ...argTypes){ + if(3===arguments.length && Array.isArray(arguments[2])){ + argTypes = arguments[2]; + } + if(target.isPtr(fArg)){ + fArg = target.functionEntry(fArg) + || toss("Function pointer not found in WASM function table."); + } + const fIsFunc = (fArg instanceof Function); + const xf = fIsFunc ? fArg : target.xGet(fArg); + if(fIsFunc) fArg = xf.name || 'unnamed function'; + if(argTypes.length!==xf.length) __argcMismatch(fArg, xf.length); + if( 0===xf.length + && (null===resultType || 'null'===resultType) ){ + + return xf; + } + ; + __xResultAdapterCheck(resultType); + for(const t of argTypes){ + if(t instanceof AbstractArgAdapter) xArg.set(t, (...args)=>t.convertArg(...args)); + else __xArgAdapterCheck(t); + } + const cxw = cache.xWrap; + if(0===xf.length){ + + return (...args)=>(args.length + ? __argcMismatch(fArg, xf.length) + : cxw.convertResult(resultType, xf.call(null))); + } + return function(...args){ + if(args.length!==xf.length) __argcMismatch(fArg, xf.length); + const scope = target.scopedAllocPush(); + try{ + + let i = 0; + if( callee.debug ){ + console.debug("xWrap() preparing: resultType ",resultType, 'xf',xf,"argTypes",argTypes,"args",args); + } + for(; i < args.length; ++i) args[i] = cxw.convertArgNoCheck( + argTypes[i], args[i], args, i + ); + if( callee.debug ){ + console.debug("xWrap() calling: resultType ",resultType, 'xf',xf,"argTypes",argTypes,"args",args); + } + return cxw.convertResultNoCheck(resultType, xf.apply(null,args)); + }finally{ + target.scopedAllocPop(scope); + } + }; + }; + + + const __xAdapter = function(func, argc, typeName, adapter, modeName, xcvPart){ + if('string'===typeof typeName){ + if(1===argc) return xcvPart.get(typeName); + else if(2===argc){ + if(!adapter){ + xcvPart.delete(typeName); + return func; + }else if(!(adapter instanceof Function)){ + toss(modeName,"requires a function argument."); + } + xcvPart.set(typeName, adapter); + return func; + } + } + toss("Invalid arguments to",modeName); + }; + + + target.xWrap.resultAdapter = function f(typeName, adapter){ + return __xAdapter(f, arguments.length, typeName, adapter, + 'resultAdapter()', xResult); + }; + + + target.xWrap.argAdapter = function f(typeName, adapter){ + return __xAdapter(f, arguments.length, typeName, adapter, + 'argAdapter()', xArg); + }; + + target.xWrap.FuncPtrAdapter = xArg.FuncPtrAdapter; + + + target.xCallWrapped = function(fArg, resultType, argTypes, ...args){ + if(Array.isArray(arguments[3])) args = arguments[3]; + return target.xWrap(fArg, resultType, argTypes||[]).apply(null, args||[]); + }; + + + target.xWrap.testConvertArg = cache.xWrap.convertArg; + + + target.xWrap.testConvertResult = cache.xWrap.convertResult; + + return target; +}; + + +globalThis.WhWasmUtilInstaller +.yawl = function yawl(config){ + 'use strict'; + const wfetch = ()=>fetch(config.uri, {credentials: 'same-origin'}); + const wui = this; + const finalThen = function(arg){ + + if(config.wasmUtilTarget){ + const toss = (...args)=>{throw new Error(args.join(' '))}; + const tgt = config.wasmUtilTarget; + tgt.module = arg.module; + tgt.instance = arg.instance; + + if(!tgt.instance.exports.memory){ + + tgt.memory = config?.imports?.env?.memory + || toss("Missing 'memory' object!"); + } + if(!tgt.alloc && arg.instance.exports.malloc){ + const exports = arg.instance.exports; + tgt.alloc = function(n){ + return exports.malloc(n) || toss("Allocation of",n,"bytes failed."); + }; + tgt.dealloc = function(m){m && exports.free(m)}; + } + wui(tgt); + } + arg.config = config; + if(config.onload) config.onload(arg); + return arg ; + }; + const loadWasm = WebAssembly.instantiateStreaming + ? ()=>WebAssembly + .instantiateStreaming(wfetch(), config.imports||{}) + .then(finalThen) + : ()=> wfetch() + .then(response => response.arrayBuffer()) + .then(bytes => WebAssembly.instantiate(bytes, config.imports||{})) + .then(finalThen) + ; + return loadWasm; +}.bind( +globalThis.WhWasmUtilInstaller +); + +'use strict'; +globalThis.Jaccwabyt = +function StructBinderFactory(config){ + 'use strict'; + + + + const toss = (...args)=>{throw new Error(args.join(' '))}; + + { + let h = config.heap; + if( h instanceof WebAssembly.Memory ){ + h = function(){return new Uint8Array(this.buffer)}.bind(h); + }else if( !(h instanceof Function) ){ + + toss("config.heap must be WebAssembly.Memory instance or", + "a function which returns one."); + } + config.heap = h; + } + ['alloc','dealloc'].forEach(function(k){ + (config[k] instanceof Function) || + toss("Config option '"+k+"' must be a function."); + }); + const SBF = StructBinderFactory; + const heap = config.heap, + alloc = config.alloc, + dealloc = config.dealloc, + realloc = (config.realloc || function(){ + toss("This StructBinderFactory was configured without realloc()"); + + }), + log = config.log || console.debug.bind(console), + memberPrefix = (config.memberPrefix || ""), + memberSuffix = (config.memberSuffix || ""), + BigInt = globalThis['BigInt'], + BigInt64Array = globalThis['BigInt64Array'], + bigIntEnabled = config.bigIntEnabled ?? !!BigInt64Array; + + + let ptr; + const ptrSize = config.pointerSize + || config.ptrSize + || ('bigint'===typeof (ptr = alloc(1)) ? 8 : 4); + const ptrIR = config.pointerIR + || config.ptrIR + || (4===ptrSize ? 'i32' : 'i64'); + if( ptr ){ + dealloc(ptr); + ptr = undefined; + } + + + if(ptrSize!==4 && ptrSize!==8) toss("Invalid pointer size:",ptrSize); + if(ptrIR!=='i32' && ptrIR!=='i64') toss("Invalid pointer representation:",ptrIR); + + + const __BigInt = (bigIntEnabled && BigInt) + ? (v)=>BigInt(v || 0) + : (v)=>toss("BigInt support is disabled in this build."); + const __asPtrType = ('i32'==ptrIR) ? Number : __BigInt; + const __NullPtr = __asPtrType(0); + + + const __ptrAdd = function(...args){ + let rc = __NullPtr; + for( let i = 0; i < args.length; ++i ){ + rc += __asPtrType(args[i]); + } + return rc; + }; + + const __ptrAddSelf = function(...args){ + return __ptrAdd(this.pointer,...args); + }; + + if(!SBF.debugFlags){ + SBF.__makeDebugFlags = function(deriveFrom=null){ + + if(deriveFrom && deriveFrom.__flags) deriveFrom = deriveFrom.__flags; + const f = function f(flags){ + if(0===arguments.length){ + return f.__flags; + } + if(flags<0){ + delete f.__flags.getter; delete f.__flags.setter; + delete f.__flags.alloc; delete f.__flags.dealloc; + }else{ + f.__flags.getter = 0!==(0x01 & flags); + f.__flags.setter = 0!==(0x02 & flags); + f.__flags.alloc = 0!==(0x04 & flags); + f.__flags.dealloc = 0!==(0x08 & flags); + } + return f._flags; + }; + Object.defineProperty(f,'__flags', { + iterable: false, writable: false, + value: Object.create(deriveFrom) + }); + if(!deriveFrom) f(0); + return f; + }; + SBF.debugFlags = SBF.__makeDebugFlags(); + } + + const isLittleEndian = true || (function() { + const buffer = new ArrayBuffer(2); + new DataView(buffer).setInt16(0, 256, true ); + + return new Int16Array(buffer)[0] === 256; + })() ; + + + + + const isFuncSig = (s)=>'('===s[1]; + + const isPtrSig = (s)=>'p'===s || 'P'===s || 's'===s; + const isAutoPtrSig = (s)=>'P'===s ; + + const sigLetter = (s)=>s ? (isFuncSig(s) ? 'p' : s[0]) : undefined; + + + const sigIR = function(s){ + switch(sigLetter(s)){ + case 'c': case 'C': return 'i8'; + case 'i': return 'i32'; + case 'p': case 'P': case 's': return ptrIR; + case 'j': return 'i64'; + case 'f': return 'float'; + case 'd': return 'double'; + } + toss("Unhandled signature IR:",s); + }; + + + const sigSize = function(s){ + switch(sigLetter(s)){ + case 'c': case 'C': return 1; + case 'i': return 4; + case 'p': case 'P': case 's': return ptrSize; + case 'j': return 8; + case 'f': return 4; + case 'd': return 8; + } + toss("Unhandled signature sizeof:",s); + }; + + const affirmBigIntArray = BigInt64Array + ? ()=>true : ()=>toss('BigInt64Array is not available.'); + + + const sigDVGetter = function(s){ + switch(sigLetter(s)) { + case 'p': case 'P': case 's': { + switch(ptrSize){ + case 4: return 'getInt32'; + case 8: return affirmBigIntArray() && 'getBigInt64'; + } + break; + } + case 'i': return 'getInt32'; + case 'c': return 'getInt8'; + case 'C': return 'getUint8'; + case 'j': return affirmBigIntArray() && 'getBigInt64'; + case 'f': return 'getFloat32'; + case 'd': return 'getFloat64'; + } + toss("Unhandled DataView getter for signature:",s); + }; + + + const sigDVSetter = function(s){ + switch(sigLetter(s)){ + case 'p': case 'P': case 's': { + switch(ptrSize){ + case 4: return 'setInt32'; + case 8: return affirmBigIntArray() && 'setBigInt64'; + } + break; + } + case 'i': return 'setInt32'; + case 'c': return 'setInt8'; + case 'C': return 'setUint8'; + case 'j': return affirmBigIntArray() && 'setBigInt64'; + case 'f': return 'setFloat32'; + case 'd': return 'setFloat64'; + } + toss("Unhandled DataView setter for signature:",s); + }; + + + const sigDVSetWrapper = function(s){ + switch(sigLetter(s)) { + case 'i': case 'f': case 'c': case 'C': case 'd': return Number; + case 'j': return __BigInt; + case 'p': case 'P': case 's': + switch(ptrSize){ + case 4: return Number; + case 8: return __BigInt; + } + break; + } + toss("Unhandled DataView set wrapper for signature:",s); + }; + + + const sPropName = (s,k)=>s+'::'+k; + + const __propThrowOnSet = function(structName,propName){ + return ()=>toss(sPropName(structName,propName),"is read-only."); + }; + + + const getInstanceHandle = function f(obj, create=true){ + let ii = f.map.get(obj); + if( !ii && create ){ + f.map.set(obj, (ii=f.create(obj))); + } + return ii; + }; + getInstanceHandle.map = new WeakMap; + getInstanceHandle.create = (forObj)=>{ + return Object.assign(Object.create(null),{ + o: forObj, + p: undefined, + ownsPointer: false, + zod: false, + xb: 0 + }); + }; + + + const rmInstanceHandle = (obj)=>getInstanceHandle.map.delete(obj) + ; + + const __isPtr32 = (ptr)=>('number'===typeof ptr && (ptr===(ptr|0)) && ptr>=0); + const __isPtr64 = (ptr)=>( + ('bigint'===typeof ptr && ptr >= 0) || __isPtr32(ptr) + ); + + + const __isPtr = (4===ptrSize) ? __isPtr32 : __isPtr64; + + const __isNonNullPtr = (v)=>__isPtr(v) && (v>0); + + + const __freeStruct = function(ctor, obj, m){ + const ii = getInstanceHandle(obj, false); + if( !ii || ii.isDisposing ) return; + ii.isDisposing = true; + if( !m && !(m = ii.p) ){ + console.warn("Cannot(?) happen: __freeStruct() found no instanceInfo"); + return; + } + if(Array.isArray(obj.ondispose)){ + let x; + while((x = obj.ondispose.pop())){ + try{ + if(x instanceof Function) x.call(obj); + else if(x instanceof StructType) x.dispose(); + else if(__isPtr(x)) dealloc(x); + + + }catch(e){ + console.warn("ondispose() for",ctor.structName,'@', + m,'threw. NOT propagating it.',e); + } + } + }else if(obj.ondispose instanceof Function){ + try{obj.ondispose()} + catch(e){ + + console.warn("ondispose() for",ctor.structName,'@', + m,'threw. NOT propagating it.',e); + } + } + rmInstanceHandle(obj); + delete obj.ondispose; + if(ctor.debugFlags.__flags.dealloc){ + log("debug.dealloc:",(ii.ownsPointer?"":"EXTERNAL"), + ctor.structName,"instance:", + ctor.structInfo.sizeof,"bytes @"+m); + } + if(ii.ownsPointer){ + if( ii.zod || ctor.structInfo.zeroOnDispose ){ + heap().fill(0, Number(m), + Number(m) + ctor.structInfo.sizeof + ii.xb); + } + dealloc(m); + } + }; + + + const rop0 = ()=>{return {configurable: false, writable: false, + iterable: false}}; + + + const rop = (v)=>{return {...rop0(), value: v}}; + + + const __allocStruct = function f(ctor, obj, xm){ + let opt; + const checkPtr = (ptr)=>{ + __isNonNullPtr(ptr) || + toss("Invalid pointer value",arguments[0],"for",ctor.structName,"constructor."); + }; + if( arguments.length>=3 ){ + if( xm && ('object'===typeof xm) ){ + opt = xm; + xm = opt?.wrap; + }else{ + checkPtr(xm); + opt = {wrap: xm}; + } + }else{ + opt = {} + } + + const fill = !xm ; + let nAlloc = 0; + let ownsPointer = false; + if(xm){ + + checkPtr(xm); + ownsPointer = !!opt?.takeOwnership; + }else{ + const nX = opt?.extraBytes ?? 0; + if( nX<0 || (nX!==(nX|0)) ){ + toss("Invalid extraBytes value:",opt?.extraBytes); + } + nAlloc = ctor.structInfo.sizeof + nX; + xm = alloc(nAlloc) + || toss("Allocation of",ctor.structName,"structure failed."); + ownsPointer = true; + } + try { + if( opt?.debugFlags ){ + + obj.debugFlags(opt.debugFlags); + } + if(ctor.debugFlags.__flags.alloc){ + log("debug.alloc:",(fill?"":"EXTERNAL"), + ctor.structName,"instance:", + ctor.structInfo.sizeof,"bytes @"+xm); + } + if(fill){ + heap().fill(0, Number(xm), Number(xm) + nAlloc); + } + const ii = getInstanceHandle(obj); + ii.p = xm; + ii.ownsPointer = ownsPointer; + ii.xb = nAlloc ? (nAlloc-ctor.structInfo.sizeof) : 0; + ii.zod = !!opt?.zeroOnDispose; + if( opt?.ondispose && opt.ondispose!==xm ){ + obj.addOnDispose( opt.ondispose ); + } + }catch(e){ + __freeStruct(ctor, obj, xm); + throw e; + } + }; + + + const looksLikeASig = function f(sig){ + f.rxSig1 ??= /^[ipPsjfdcC]$/; + f.rxSig2 ??= /^[vipPsjfdcC]\([ipPsjfdcC]*\)$/; + return f.rxSig1.test(sig) || f.rxSig2.test(sig); + }; + + + const __adaptorsFor = function(who){ + let x = this.get(who); + if( !x ){ + x = [ Object.create(null), Object.create(null), Object.create(null) ]; + this.set(who, x); + } + return x; + }.bind(new WeakMap); + + + const __adaptor = function(who, which, key, proxy){ + const a = __adaptorsFor(who)[which]; + if(3===arguments.length) return a[key]; + if( proxy ) return a[key] = proxy; + return delete a[key]; + }; + + const noopAdapter = (x)=>x; + + + const __adaptGet = function(key, ...args){ + return __adaptor(this, 0, key, ...args); + }; + + const __affirmNotASig = function(ctx,key){ + looksLikeASig(key) && + toss(ctx,"(",key,") collides with a data type signature."); + }; + + + const __adaptSet = function(key, ...args){ + __affirmNotASig('Setter adaptor',key); + return __adaptor(this, 1, key, ...args); + }; + + + const __adaptStruct = function(key, ...args){ + __affirmNotASig('Struct adaptor',key); + return __adaptor(this, 2, key, ...args); + }; + + + const __adaptStruct2 = function(who,key){ + const si = ('string'===typeof key) + ? __adaptor(who, 2, key) : key; + if( 'object'!==typeof si ){ + toss("Invalid struct mapping object. Arg =",key,JSON.stringify(si)); + } + return si; + }; + + const __memberKey = (k)=>memberPrefix + k + memberSuffix; + const __memberKeyProp = rop(__memberKey); + + + + const __lookupMember = function(structInfo, memberName, tossIfNotFound=true){ + let m = structInfo.members[memberName]; + if(!m && (memberPrefix || memberSuffix)){ + + for(const v of Object.values(structInfo.members)){ + if(v.key===memberName){ m = v; break; } + } + if(!m && tossIfNotFound){ + toss(sPropName(structInfo.name || structInfo.structName, memberName), + 'is not a mapped struct member.'); + } + } + return m; + }; + + + const __memberSignature = function f(obj,memberName,emscriptenFormat=false){ + if(!f._) f._ = (x)=>x.replace(/[^vipPsjrdcC]/g,"").replace(/[pPscC]/g,'i'); + const m = __lookupMember(obj.structInfo, memberName, true); + return emscriptenFormat ? f._(m.signature) : m.signature; + }; + + + const __structMemberKeys = rop(function(){ + const a = []; + for(const k of Object.keys(this.structInfo.members)){ + a.push(this.memberKey(k)); + } + return a; + }); + + const __utf8Decoder = new TextDecoder('utf-8'); + const __utf8Encoder = new TextEncoder(); + + const __SAB = ('undefined'===typeof SharedArrayBuffer) + ? function(){} : SharedArrayBuffer; + const __utf8Decode = function(arrayBuffer, begin, end){ + if( 8===ptrSize ){ + begin = Number(begin); + end = Number(end); + } + return __utf8Decoder.decode( + (arrayBuffer.buffer instanceof __SAB) + ? arrayBuffer.slice(begin, end) + : arrayBuffer.subarray(begin, end) + ); + }; + + + const __memberIsString = function(obj,memberName, tossIfNotFound=false){ + const m = __lookupMember(obj.structInfo, memberName, tossIfNotFound); + return (m && 1===m.signature.length && 's'===m.signature[0]) ? m : false; + }; + + + const __affirmCStringSignature = function(member){ + if('s'===member.signature) return; + toss("Invalid member type signature for C-string value:", + JSON.stringify(member)); + }; + + + const __memberToJsString = function f(obj,memberName){ + const m = __lookupMember(obj.structInfo, memberName, true); + __affirmCStringSignature(m); + const addr = obj[m.key]; + + if(!addr) return null; + let pos = addr; + const mem = heap(); + for( ; mem[pos]!==0; ++pos ) { + + }; + + return (addr===pos) ? "" : __utf8Decode(mem, addr, pos); + }; + + + const __addOnDispose = function(obj, ...v){ + if(obj.ondispose){ + if(!Array.isArray(obj.ondispose)){ + obj.ondispose = [obj.ondispose]; + } + }else{ + obj.ondispose = []; + } + obj.ondispose.push(...v); + }; + + + const __allocCString = function(str){ + const u = __utf8Encoder.encode(str); + const mem = alloc(u.length+1); + if(!mem) toss("Allocation error while duplicating string:",str); + const h = heap(); + + + h.set(u, Number(mem)); + h[__ptrAdd(mem, u.length)] = 0; + + return mem; + }; + + + const __setMemberCString = function(obj, memberName, str){ + const m = __lookupMember(obj.structInfo, memberName, true); + __affirmCStringSignature(m); + + const mem = __allocCString(str); + obj[m.key] = mem; + __addOnDispose(obj, mem); + return obj; + }; + + + const StructType = function StructType(structName, structInfo){ + if(arguments[2]!==rop){ + toss("Do not call the StructType constructor", + "from client-level code."); + } + Object.defineProperties(this,{ + + structName: rop(structName), + structInfo: rop(structInfo) + }); + }; + + + StructType.prototype = Object.create(null, { + dispose: rop(function(){__freeStruct(this.constructor, this)}), + lookupMember: rop(function(memberName, tossIfNotFound=true){ + return __lookupMember(this.structInfo, memberName, tossIfNotFound); + }), + memberToJsString: rop(function(memberName){ + return __memberToJsString(this, memberName); + }), + memberIsString: rop(function(memberName, tossIfNotFound=true){ + return __memberIsString(this, memberName, tossIfNotFound); + }), + memberKey: __memberKeyProp, + memberKeys: __structMemberKeys, + memberSignature: rop(function(memberName, emscriptenFormat=false){ + return __memberSignature(this, memberName, emscriptenFormat); + }), + memoryDump: rop(function(){ + const p = this.pointer; + return p + ? new Uint8Array(heap().slice(Number(p), Number(p) + this.structInfo.sizeof)) + : null; + }), + extraBytes: { + configurable: false, enumerable: false, + get: function(){return getInstanceHandle(this, false)?.xb ?? 0;} + }, + zeroOnDispose: { + configurable: false, enumerable: false, + get: function(){ + return getInstanceHandle(this, false)?.zod + ?? !!this.structInfo.zeroOnDispose; + } + }, + pointer: { + configurable: false, enumerable: false, + get: function(){return getInstanceHandle(this, false)?.p}, + set: ()=>toss("Cannot assign the 'pointer' property of a struct.") + + + + }, + setMemberCString: rop(function(memberName, str){ + return __setMemberCString(this, memberName, str); + }) + }); + + Object.assign(StructType.prototype,{ + addOnDispose: function(...v){ + __addOnDispose(this,...v); + return this; + } + }); + + + Object.defineProperties(StructType, { + allocCString: rop(__allocCString), + isA: rop((v)=>v instanceof StructType), + hasExternalPointer: rop((v)=>{ + const ii = getInstanceHandle(v, false); + return !!(ii?.p && !ii?.ownsPointer); + }), + memberKey: __memberKeyProp + + }); + + + const memberGetterProxy = function(si){ + return si.get || (si.adaptGet + ? StructBinder.adaptGet(si.adaptGet) + : undefined); + }; + + + const memberSetterProxy = function(si){ + return si.set || (si.adaptSet + ? StructBinder.adaptSet(si.adaptSet) + : undefined); + }; + + + const makeMemberStructWrapper = function callee(ctor, name, si){ + + const __innerStructs = (callee.innerStructs ??= new Map()); + const key = ctor.memberKey(name); + if( undefined!==si.signature ){ + toss("'signature' cannot be used on an embedded struct (", + ctor.structName,".",key,")."); + } + if( memberSetterProxy(si) ){ + toss("'set' and 'adaptSet' are not permitted for nested struct members."); + } + + si.structName ??= ctor.structName+'::'+name; + si.key = key; + si.name = name; + si.constructor = this.call(this, si.structName, si); + + + const getterProxy = memberGetterProxy(si); + const prop = Object.assign(Object.create(null),{ + configurable: false, + enumerable: false, + set: __propThrowOnSet(ctor.structName, key), + get: function(){ + const dbg = this.debugFlags.__flags; + const p = this.pointer; + const k = p+'.'+key; + let s = __innerStructs.get(k); + if(dbg.getter){ log("debug.getter: k =",k); } + if( !s ){ + s = new si.constructor(__ptrAdd(p, si.offset)); + __innerStructs.set(k, s); + this.addOnDispose(()=>s.dispose()); + s.addOnDispose(()=>__innerStructs.delete(k)); + + } + if(getterProxy) s = getterProxy.apply(this,[s,key]); + if(dbg.getter) log("debug.getter: result =",s); + return s; + } + }); + Object.defineProperty(ctor.prototype, key, prop); + }; + + + const makeMemberWrapper = function f(ctor, name, si){ + si = __adaptStruct2(this, si); + if( si.members ){ + return makeMemberStructWrapper.call(this, ctor, name, si); + } + + if(!f.cache){ + + f.cache = {getters: {}, setters: {}, sw:{}}; + const a = ['i','c','C','p','P','s','f','d','v()']; + if(bigIntEnabled) a.push('j'); + a.forEach(function(v){ + f.cache.getters[v] = sigDVGetter(v) ; + f.cache.setters[v] = sigDVSetter(v) ; + f.cache.sw[v] = sigDVSetWrapper(v) ; + }); + f.sigCheck = function(obj, name, key,sig){ + if(Object.prototype.hasOwnProperty.call(obj, key)){ + toss(obj.structName,'already has a property named',key+'.'); + } + looksLikeASig(sig) + || toss("Malformed signature for", + sPropName(obj.structName,name)+":",sig); + }; + } + const key = ctor.memberKey(name); + f.sigCheck(ctor.prototype, name, key, si.signature); + si.key = key; + si.name = name; + const sigGlyph = sigLetter(si.signature); + const xPropName = sPropName(ctor.structName,key); + const dbg = ctor.debugFlags.__flags; + + const getterProxy = memberGetterProxy(si); + const prop = Object.create(null); + prop.configurable = false; + prop.enumerable = false; + prop.get = function(){ + + if(dbg.getter){ + log("debug.getter:",f.cache.getters[sigGlyph],"for", sigIR(sigGlyph), + xPropName,'@', this.pointer,'+',si.offset,'sz',si.sizeof); + } + let rc = ( + new DataView(heap().buffer, Number(this.pointer) + si.offset, si.sizeof) + )[f.cache.getters[sigGlyph]](0, isLittleEndian); + + if(getterProxy) rc = getterProxy.apply(this,[key,rc]); + if(dbg.getter) log("debug.getter:",xPropName,"result =",rc); + return rc; + }; + if(si.readOnly){ + prop.set = __propThrowOnSet(ctor.prototype.structName,key); + }else{ + const setterProxy = memberSetterProxy(si); + prop.set = function(v){ + + if(dbg.setter){ + log("debug.setter:",f.cache.setters[sigGlyph],"for", sigIR(sigGlyph), + xPropName,'@', this.pointer,'+',si.offset,'sz',si.sizeof, v); + } + if(!this.pointer){ + toss("Cannot set native property on a disposed", + this.structName,"instance."); + } + if( setterProxy ) v = setterProxy.apply(this,[key,v]); + if( null===v || undefined===v ) v = __NullPtr; + else if( isPtrSig(si.signature) && !__isPtr(v) ){ + if(isAutoPtrSig(si.signature) && (v instanceof StructType)){ + + v = v.pointer || __NullPtr; + if(dbg.setter) log("debug.setter:",xPropName,"resolved to",v); + }else{ + toss("Invalid value for pointer-type",xPropName+'.'); + } + } + ( + new DataView(heap().buffer, Number(this.pointer) + si.offset, + si.sizeof) + )[f.cache.setters[sigGlyph]](0, f.cache.sw[sigGlyph](v), isLittleEndian); + }; + } + Object.defineProperty(ctor.prototype, key, prop); + }; + + + const StructBinderImpl = function StructBinderImpl( + structName, si, opt = Object.create(null) + ){ + + const StructCtor = function StructCtor(arg){ + + if(!(this instanceof StructCtor)){ + toss("The",structName,"constructor may only be called via 'new'."); + } + __allocStruct(StructCtor, this, ...arguments); + }; + const self = this; + + const ads = (x)=>{ + + return (('string'===typeof x) && looksLikeASig(x)) + ? {signature: x} : __adaptStruct2(self,x); + }; + if(1===arguments.length){ + si = ads(structName); + structName = si.structName || si.name; + }else if(2===arguments.length){ + si = ads(si); + si.name ??= structName; + }else{ + si = ads(si); + } + structName ??= si.structName; + + structName ??= opt.structName; + if( !structName ) toss("One of 'name' or 'structName' are required."); + if( si.adapt ){ + + Object.keys(si.adapt.struct||{}).forEach((k)=>{ + __adaptStruct.call(StructBinderImpl, k, si.adapt.struct[k]); + }); + Object.keys(si.adapt.set||{}).forEach((k)=>{ + __adaptSet.call(StructBinderImpl, k, si.adapt.set[k]); + }); + Object.keys(si.adapt.get||{}).forEach((k)=>{ + __adaptGet.call(StructBinderImpl, k, si.adapt.get[k]); + }); + } + if(!si.members && !si.sizeof){ + si.sizeof = sigSize(si.signature); + } + + const debugFlags = rop(SBF.__makeDebugFlags(StructBinder.debugFlags)); + Object.defineProperties(StructCtor,{ + debugFlags: debugFlags, + isA: rop((v)=>v instanceof StructCtor), + memberKey: __memberKeyProp, + memberKeys: __structMemberKeys, + + structInfo: rop(si), + structName: rop(structName), + ptrAdd: rop(__ptrAdd) + }); + StructCtor.prototype = new StructType(structName, si, rop); + Object.defineProperties(StructCtor.prototype,{ + debugFlags: debugFlags, + constructor: rop(StructCtor) + , + ptrAdd: rop(__ptrAddSelf) + }); + let lastMember = false; + let offset = 0; + const autoCalc = !!si.autoCalcSizeOffset; + + if( !autoCalc ){ + if( !si.sizeof ){ + toss(structName,"description is missing its sizeof property."); + } + + si.offset ??= 0; + }else{ + si.offset ??= 0; + } + Object.keys(si.members || {}).forEach((k)=>{ + + let m = ads(si.members[k]); + if(!m.members && !m.sizeof){ + + m.sizeof = sigSize(m.signature); + if(!m.sizeof){ + toss(sPropName(structName,k), "is missing a sizeof property.",m); + } + } + if( undefined===m.offset ){ + if( autoCalc ) m.offset = offset; + else{ + toss(sPropName(structName,k),"is missing its offset.", + JSON.stringify(m)); + } + + } + si.members[k] = m ; + if(!lastMember || lastMember.offset < m.offset) lastMember = m; + const oldAutoCalc = !!m.autoCalc; + if( autoCalc ) m.autoCalcSizeOffset = true; + makeMemberWrapper.call(self, StructCtor, k, m); + if( oldAutoCalc ) m.autoCalcSizeOffset = true; + else delete m.autoCalcSizeOffset; + offset += m.sizeof; + + }); + + if( !lastMember ) toss("No member property descriptions found."); + if( !si.sizeof ) si.sizeof = offset; + if(si.sizeof===1){ + (si.signature === 'c' || si.signature === 'C') || + toss("Unexpected sizeof==1 member", + sPropName(structName,k), + "with signature",si.signature); + }else{ + + + if(0!==(si.sizeof%4)){ + console.warn("Invalid struct member description",si); + toss(structName,"sizeof is not aligned. sizeof="+si.sizeof); + } + if(0!==(si.offset%4)){ + console.warn("Invalid struct member description",si); + toss(structName,"offset is not aligned. offset="+si.offset); + } + } + if( si.sizeof < offset ){ + console.warn("Suspect struct description:",si,"offset =",offset); + toss("Mismatch in the calculated vs. the provided sizeof/offset info.", + "Expected sizeof",offset,"but got",si.sizeof,"for",si); + + } + delete si.autoCalcSizeOffset; + return StructCtor; + }; + + const StructBinder = function StructBinder(structName, structInfo){ + return (1==arguments.length) + ? StructBinderImpl.call(StructBinder, structName) + : StructBinderImpl.call(StructBinder, structName, structInfo); + }; + StructBinder.StructType = StructType; + StructBinder.config = config; + StructBinder.allocCString = __allocCString; + StructBinder.adaptGet = __adaptGet; + StructBinder.adaptSet = __adaptSet; + StructBinder.adaptStruct = __adaptStruct; + StructBinder.ptrAdd = __ptrAdd; + if(!StructBinder.debugFlags){ + StructBinder.debugFlags = SBF.__makeDebugFlags(SBF.debugFlags); + } + return StructBinder; +}; + +globalThis.sqlite3ApiBootstrap.initializers.push(function(sqlite3){ + 'use strict'; + const toss = (...args)=>{throw new Error(args.join(' '))}; + const capi = sqlite3.capi, wasm = sqlite3.wasm, util = sqlite3.util; + globalThis.WhWasmUtilInstaller(wasm); + delete globalThis.WhWasmUtilInstaller; + + if(0){ + + + const dealloc = wasm.exports[sqlite3.config.deallocExportName]; + const nFunc = wasm.functionTable().length; + let i; + for(i = 0; i < nFunc; ++i){ + const e = wasm.functionEntry(i); + if(dealloc === e){ + capi.SQLITE_WASM_DEALLOC = i; + break; + } + } + if(dealloc !== wasm.functionEntry(capi.SQLITE_WASM_DEALLOC)){ + toss("Internal error: cannot find function pointer for SQLITE_WASM_DEALLOC."); + } + } + + + const bindingSignatures = { + core: [ + + ["sqlite3_aggregate_context","void*", "sqlite3_context*", "int"], + + ["sqlite3_backup_init", "sqlite3_backup*", "sqlite3*", "string", "sqlite3*", "string"], + ["sqlite3_backup_step", "int", "sqlite3_backup*", "int"], + ["sqlite3_backup_finish", "int", "sqlite3_backup*"], + ["sqlite3_backup_remaining", "int", "sqlite3_backup*"], + ["sqlite3_backup_pagecount", "int", "sqlite3_backup*"], + + ["sqlite3_bind_double","int", "sqlite3_stmt*", "int", "f64"], + ["sqlite3_bind_int","int", "sqlite3_stmt*", "int", "int"], + ["sqlite3_bind_null",undefined, "sqlite3_stmt*", "int"], + ["sqlite3_bind_parameter_count", "int", "sqlite3_stmt*"], + ["sqlite3_bind_parameter_index","int", "sqlite3_stmt*", "string"], + ["sqlite3_bind_parameter_name", "string", "sqlite3_stmt*", "int"], + ["sqlite3_bind_pointer", "int", + "sqlite3_stmt*", "int", "*", "string:static", "*"], + + ["sqlite3_bind_zeroblob", "int", "sqlite3_stmt*", "int", "int"], + ["sqlite3_busy_handler","int", [ + "sqlite3*", + new wasm.xWrap.FuncPtrAdapter({ + signature: 'i(pi)', + contextKey: (argv,argIndex)=>argv[0] + }), + "*" + ]], + ["sqlite3_busy_timeout","int", "sqlite3*", "int"], + + + ["sqlite3_changes", "int", "sqlite3*"], + ["sqlite3_clear_bindings","int", "sqlite3_stmt*"], + ["sqlite3_collation_needed", "int", "sqlite3*", "*", "*"], + ["sqlite3_column_blob","*", "sqlite3_stmt*", "int"], + ["sqlite3_column_bytes","int", "sqlite3_stmt*", "int"], + ["sqlite3_column_count", "int", "sqlite3_stmt*"], + ["sqlite3_column_decltype", "string", "sqlite3_stmt*", "int"], + ["sqlite3_column_double","f64", "sqlite3_stmt*", "int"], + ["sqlite3_column_int","int", "sqlite3_stmt*", "int"], + ["sqlite3_column_name","string", "sqlite3_stmt*", "int"], + ["sqlite3_column_type","int", "sqlite3_stmt*", "int"], + ["sqlite3_column_value","sqlite3_value*", "sqlite3_stmt*", "int"], + ["sqlite3_commit_hook", "void*", [ + "sqlite3*", + new wasm.xWrap.FuncPtrAdapter({ + name: 'sqlite3_commit_hook', + signature: 'i(p)', + contextKey: (argv)=>argv[0] + }), + '*' + ]], + ["sqlite3_compileoption_get", "string", "int"], + ["sqlite3_compileoption_used", "int", "string"], + ["sqlite3_complete", "int", "string:flexible"], + ["sqlite3_context_db_handle", "sqlite3*", "sqlite3_context*"], + + + ["sqlite3_data_count", "int", "sqlite3_stmt*"], + ["sqlite3_db_filename", "string", "sqlite3*", "string"], + ["sqlite3_db_handle", "sqlite3*", "sqlite3_stmt*"], + ["sqlite3_db_name", "string", "sqlite3*", "int"], + ["sqlite3_db_readonly", "int", "sqlite3*", "string"], + ["sqlite3_db_status", "int", "sqlite3*", "int", "*", "*", "int"], + ["sqlite3_errcode", "int", "sqlite3*"], + ["sqlite3_errmsg", "string", "sqlite3*"], + ["sqlite3_error_offset", "int", "sqlite3*"], + ["sqlite3_errstr", "string", "int"], + ["sqlite3_exec", "int", [ + "sqlite3*", "string:flexible", + new wasm.xWrap.FuncPtrAdapter({ + signature: 'i(pipp)', + bindScope: 'transient', + callProxy: (callback)=>{ + let aNames; + return (pVoid, nCols, pColVals, pColNames)=>{ + try { + const aVals = wasm.cArgvToJs(nCols, pColVals); + if(!aNames) aNames = wasm.cArgvToJs(nCols, pColNames); + return callback(aVals, aNames) | 0; + }catch(e){ + + return e.resultCode || capi.SQLITE_ERROR; + } + } + } + }), + "*", "**" + ]], + ["sqlite3_expanded_sql", "string", "sqlite3_stmt*"], + ["sqlite3_extended_errcode", "int", "sqlite3*"], + ["sqlite3_extended_result_codes", "int", "sqlite3*", "int"], + ["sqlite3_file_control", "int", "sqlite3*", "string", "int", "*"], + ["sqlite3_finalize", "int", "sqlite3_stmt*"], + ["sqlite3_free", undefined,"*"], + ["sqlite3_get_autocommit", "int", "sqlite3*"], + ["sqlite3_get_auxdata", "*", "sqlite3_context*", "int"], + ["sqlite3_initialize", undefined], + ["sqlite3_interrupt", undefined, "sqlite3*"], + ["sqlite3_is_interrupted", "int", "sqlite3*"], + ["sqlite3_keyword_count", "int"], + ["sqlite3_keyword_name", "int", ["int", "**", "*"]], + ["sqlite3_keyword_check", "int", ["string", "int"]], + ["sqlite3_libversion", "string"], + ["sqlite3_libversion_number", "int"], + ["sqlite3_limit", "int", ["sqlite3*", "int", "int"]], + ["sqlite3_malloc", "*","int"], + ["sqlite3_next_stmt", "sqlite3_stmt*", ["sqlite3*","sqlite3_stmt*"]], + ["sqlite3_open", "int", "string", "*"], + ["sqlite3_open_v2", "int", "string", "*", "int", "string"], + + + ["sqlite3_realloc", "*","*","int"], + ["sqlite3_reset", "int", "sqlite3_stmt*"], + + ["sqlite3_result_blob", undefined, "sqlite3_context*", "*", "int", "*"], + ["sqlite3_result_double", undefined, "sqlite3_context*", "f64"], + ["sqlite3_result_error", undefined, "sqlite3_context*", "string", "int"], + ["sqlite3_result_error_code", undefined, "sqlite3_context*", "int"], + ["sqlite3_result_error_nomem", undefined, "sqlite3_context*"], + ["sqlite3_result_error_toobig", undefined, "sqlite3_context*"], + ["sqlite3_result_int", undefined, "sqlite3_context*", "int"], + ["sqlite3_result_null", undefined, "sqlite3_context*"], + ["sqlite3_result_pointer", undefined, + "sqlite3_context*", "*", "string:static", "*"], + ["sqlite3_result_subtype", undefined, "sqlite3_value*", "int"], + ["sqlite3_result_text", undefined, "sqlite3_context*", "string", "int", "*"], + ["sqlite3_result_zeroblob", undefined, "sqlite3_context*", "int"], + ["sqlite3_rollback_hook", "void*", [ + "sqlite3*", + new wasm.xWrap.FuncPtrAdapter({ + name: 'sqlite3_rollback_hook', + signature: 'v(p)', + contextKey: (argv)=>argv[0] + }), + '*' + ]], + + ["sqlite3_set_auxdata", undefined, [ + "sqlite3_context*", "int", "*", + true + ? "*" + : new wasm.xWrap.FuncPtrAdapter({ + + name: 'xDestroyAuxData', + signature: 'v(p)', + contextKey: (argv, argIndex)=>argv[0] + }) + ]], + ['sqlite3_set_errmsg', 'int', 'sqlite3*', 'int', 'string'], + ["sqlite3_shutdown", undefined], + ["sqlite3_sourceid", "string"], + ["sqlite3_sql", "string", "sqlite3_stmt*"], + ["sqlite3_status", "int", "int", "*", "*", "int"], + ["sqlite3_step", "int", "sqlite3_stmt*"], + ["sqlite3_stmt_busy", "int", "sqlite3_stmt*"], + ["sqlite3_stmt_readonly", "int", "sqlite3_stmt*"], + ["sqlite3_stmt_status", "int", "sqlite3_stmt*", "int", "int"], + ["sqlite3_strglob", "int", "string","string"], + ["sqlite3_stricmp", "int", "string", "string"], + ["sqlite3_strlike", "int", "string", "string","int"], + ["sqlite3_strnicmp", "int", "string", "string", "int"], + ["sqlite3_table_column_metadata", "int", + "sqlite3*", "string", "string", "string", + "**", "**", "*", "*", "*"], + ["sqlite3_total_changes", "int", "sqlite3*"], + ["sqlite3_trace_v2", "int", [ + "sqlite3*", "int", + new wasm.xWrap.FuncPtrAdapter({ + name: 'sqlite3_trace_v2::callback', + signature: 'i(ippp)', + contextKey: (argv,argIndex)=>argv[0] + }), + "*" + ]], + ["sqlite3_txn_state", "int", ["sqlite3*","string"]], + + ["sqlite3_uri_boolean", "int", "sqlite3_filename", "string", "int"], + ["sqlite3_uri_key", "string", "sqlite3_filename", "int"], + ["sqlite3_uri_parameter", "string", "sqlite3_filename", "string"], + ["sqlite3_user_data","void*", "sqlite3_context*"], + ["sqlite3_value_blob", "*", "sqlite3_value*"], + ["sqlite3_value_bytes","int", "sqlite3_value*"], + ["sqlite3_value_double","f64", "sqlite3_value*"], + ["sqlite3_value_dup", "sqlite3_value*", "sqlite3_value*"], + ["sqlite3_value_free", undefined, "sqlite3_value*"], + ["sqlite3_value_frombind", "int", "sqlite3_value*"], + ["sqlite3_value_int","int", "sqlite3_value*"], + ["sqlite3_value_nochange", "int", "sqlite3_value*"], + ["sqlite3_value_numeric_type", "int", "sqlite3_value*"], + ["sqlite3_value_pointer", "*", "sqlite3_value*", "string:static"], + ["sqlite3_value_subtype", "int", "sqlite3_value*"], + ["sqlite3_value_type", "int", "sqlite3_value*"], + ["sqlite3_vfs_find", "*", "string"], + ["sqlite3_vfs_register", "int", "sqlite3_vfs*", "int"], + ["sqlite3_vfs_unregister", "int", "sqlite3_vfs*"] + + + ], + + int64: [ + ["sqlite3_bind_int64","int", ["sqlite3_stmt*", "int", "i64"]], + ["sqlite3_changes64","i64", ["sqlite3*"]], + ["sqlite3_column_int64","i64", ["sqlite3_stmt*", "int"]], + ["sqlite3_deserialize", "int", "sqlite3*", "string", "*", "i64", "i64", "int"] + , + ["sqlite3_last_insert_rowid", "i64", ["sqlite3*"]], + ["sqlite3_malloc64", "*","i64"], + ["sqlite3_msize", "i64", "*"], + ["sqlite3_overload_function", "int", ["sqlite3*","string","int"]], + ["sqlite3_realloc64", "*","*", "i64"], + ["sqlite3_result_int64", undefined, "*", "i64"], + ["sqlite3_result_zeroblob64", "int", "*", "i64"], + ["sqlite3_serialize","*", "sqlite3*", "string", "*", "int"], + ["sqlite3_set_last_insert_rowid", undefined, ["sqlite3*", "i64"]], + ["sqlite3_status64", "int", "int", "*", "*", "int"], + ["sqlite3_db_status64", "int", "sqlite3*", "int", "*", "*", "int"], + ["sqlite3_total_changes64", "i64", ["sqlite3*"]], + ["sqlite3_update_hook", "*", [ + "sqlite3*", + new wasm.xWrap.FuncPtrAdapter({ + name: 'sqlite3_update_hook::callback', + signature: "v(pippj)", + contextKey: (argv)=>argv[0], + callProxy: (callback)=>{ + return (p,op,z0,z1,rowid)=>{ + callback(p, op, wasm.cstrToJs(z0), wasm.cstrToJs(z1), rowid); + }; + } + }), + "*" + ]], + ["sqlite3_uri_int64", "i64", ["sqlite3_filename", "string", "i64"]], + ["sqlite3_value_int64","i64", "sqlite3_value*"] + + ], + + wasmInternal: [ + ["sqlite3__wasm_db_reset", "int", "sqlite3*"], + ["sqlite3__wasm_db_vfs", "sqlite3_vfs*", "sqlite3*","string"], + [ + "sqlite3__wasm_vfs_create_file", "int", "sqlite3_vfs*","string","*", "int" + ], + ["sqlite3__wasm_posix_create_file", "int", "string","*", "int"], + ["sqlite3__wasm_vfs_unlink", "int", "sqlite3_vfs*","string"], + ["sqlite3__wasm_qfmt_token","string:dealloc", "string","int"] + ] + } ; + + if( !!wasm.exports.sqlite3_progress_handler ){ + bindingSignatures.core.push( + ["sqlite3_progress_handler", undefined, [ + "sqlite3*", "int", new wasm.xWrap.FuncPtrAdapter({ + name: 'xProgressHandler', + signature: 'i(p)', + bindScope: 'context', + contextKey: (argv,argIndex)=>argv[0] + }), "*" + ]] + ); + } + + if( !!wasm.exports.sqlite3_stmt_explain ){ + bindingSignatures.core.push( + ["sqlite3_stmt_explain", "int", "sqlite3_stmt*", "int"], + ["sqlite3_stmt_isexplain", "int", "sqlite3_stmt*"] + ); + } + + if( !!wasm.exports.sqlite3_set_authorizer ){ + bindingSignatures.core.push( + ["sqlite3_set_authorizer", "int", [ + "sqlite3*", + new wasm.xWrap.FuncPtrAdapter({ + name: "sqlite3_set_authorizer::xAuth", + signature: "i(pi"+"ssss)", + contextKey: (argv, argIndex)=>argv[0], + + callProxy: (callback)=>{ + return (pV, iCode, s0, s1, s2, s3)=>{ + try{ + s0 = s0 && wasm.cstrToJs(s0); s1 = s1 && wasm.cstrToJs(s1); + s2 = s2 && wasm.cstrToJs(s2); s3 = s3 && wasm.cstrToJs(s3); + return callback(pV, iCode, s0, s1, s2, s3) | 0; + }catch(e){ + return e.resultCode || capi.SQLITE_ERROR; + } + } + } + }), + "*" + ]] + ); + } + + if( !!wasm.exports.sqlite3_column_origin_name ){ + bindingSignatures.core.push( + ["sqlite3_column_database_name","string", "sqlite3_stmt*", "int"], + ["sqlite3_column_origin_name","string", "sqlite3_stmt*", "int"], + ["sqlite3_column_table_name","string", "sqlite3_stmt*", "int"] + ); + } + + if(false && wasm.compileOptionUsed('SQLITE_ENABLE_NORMALIZE')){ + + bindingSignatures.core.push(["sqlite3_normalized_sql", "string", "sqlite3_stmt*"]); + } + + if( !!wasm.exports.sqlite3_key_v2 ){ + + bindingSignatures.core.push( + ["sqlite3_key", "int", "sqlite3*", "string", "int"], + ["sqlite3_key_v2","int","sqlite3*","string","*","int"], + ["sqlite3_rekey", "int", "sqlite3*", "string", "int"], + ["sqlite3_rekey_v2", "int", "sqlite3*", "string", "*", "int"], + ["sqlite3_activate_see", undefined, "string"], + ["sqlite3mc_cipher_count", "int"], + ["sqlite3mc_cipher_index", "int", "string"], + ["sqlite3mc_cipher_name", "string", "int"], + ["sqlite3mc_cipher_name_copy", "int", "int", "string", "int"], + ["sqlite3mc_config", "int", "sqlite3*", "string", "int"], + ["sqlite3mc_config_cipher", "int", "sqlite3*", "string", "string", "int"], + ["sqlite3mc_codec_data", "string", "sqlite3*", "string", "string"], + ["sqlite3mc_version", "string"], + ["sqlite3mc_vfs_create", "int", "string", "int"], + ["sqlite3mc_vfs_destroy", undefined, "string"], + ["sqlite3mc_vfs_shutdown", undefined] + ); + } + + if( wasm.bigIntEnabled && !!wasm.exports.sqlite3_declare_vtab ){ + bindingSignatures.int64.push( + ["sqlite3_create_module", "int", + ["sqlite3*","string","sqlite3_module*","*"]], + ["sqlite3_create_module_v2", "int", + ["sqlite3*","string","sqlite3_module*","*","*"]], + ["sqlite3_declare_vtab", "int", ["sqlite3*", "string:flexible"]], + ["sqlite3_drop_modules", "int", ["sqlite3*", "**"]], + ["sqlite3_vtab_collation","string","sqlite3_index_info*","int"], + + ["sqlite3_vtab_distinct","int", "sqlite3_index_info*"], + ["sqlite3_vtab_in","int", "sqlite3_index_info*", "int", "int"], + ["sqlite3_vtab_in_first", "int", "sqlite3_value*", "**"], + ["sqlite3_vtab_in_next", "int", "sqlite3_value*", "**"], + ["sqlite3_vtab_nochange","int", "sqlite3_context*"], + ["sqlite3_vtab_on_conflict","int", "sqlite3*"], + ["sqlite3_vtab_rhs_value","int", "sqlite3_index_info*", "int", "**"] + ); + } + + if(wasm.bigIntEnabled && !!wasm.exports.sqlite3_preupdate_hook){ + bindingSignatures.int64.push( + ["sqlite3_preupdate_blobwrite", "int", "sqlite3*"], + ["sqlite3_preupdate_count", "int", "sqlite3*"], + ["sqlite3_preupdate_depth", "int", "sqlite3*"], + ["sqlite3_preupdate_hook", "*", [ + "sqlite3*", + new wasm.xWrap.FuncPtrAdapter({ + name: 'sqlite3_preupdate_hook', + signature: "v(ppippjj)", + contextKey: (argv)=>argv[0], + callProxy: (callback)=>{ + return (p,db,op,zDb,zTbl,iKey1,iKey2)=>{ + callback(p, db, op, wasm.cstrToJs(zDb), wasm.cstrToJs(zTbl), + iKey1, iKey2); + }; + } + }), + "*" + ]], + ["sqlite3_preupdate_new", "int", ["sqlite3*", "int", "**"]], + ["sqlite3_preupdate_old", "int", ["sqlite3*", "int", "**"]] + ); + } + + + if(wasm.bigIntEnabled + && !!wasm.exports.sqlite3changegroup_add + && !!wasm.exports.sqlite3session_create + && !!wasm.exports.sqlite3_preupdate_hook ){ + + const __ipsProxy = { + signature: 'i(ps)', + callProxy:(callback)=>{ + return (p,s)=>{ + try{return callback(p, wasm.cstrToJs(s)) | 0} + catch(e){return e.resultCode || capi.SQLITE_ERROR} + } + } + }; + + bindingSignatures.int64.push( + ['sqlite3changegroup_add', 'int', ['sqlite3_changegroup*', 'int', 'void*']], + ['sqlite3changegroup_add_strm', 'int', [ + 'sqlite3_changegroup*', + new wasm.xWrap.FuncPtrAdapter({ + name: 'xInput', signature: 'i(ppp)', bindScope: 'transient' + }), + 'void*' + ]], + ['sqlite3changegroup_delete', undefined, ['sqlite3_changegroup*']], + ['sqlite3changegroup_new', 'int', ['**']], + ['sqlite3changegroup_output', 'int', ['sqlite3_changegroup*', 'int*', '**']], + ['sqlite3changegroup_output_strm', 'int', [ + 'sqlite3_changegroup*', + new wasm.xWrap.FuncPtrAdapter({ + name: 'xOutput', signature: 'i(ppi)', bindScope: 'transient' + }), + 'void*' + ]], + ['sqlite3changeset_apply', 'int', [ + 'sqlite3*', 'int', 'void*', + new wasm.xWrap.FuncPtrAdapter({ + name: 'xFilter', bindScope: 'transient', ...__ipsProxy + }), + new wasm.xWrap.FuncPtrAdapter({ + name: 'xConflict', signature: 'i(pip)', bindScope: 'transient' + }), + 'void*' + ]], + ['sqlite3changeset_apply_strm', 'int', [ + 'sqlite3*', + new wasm.xWrap.FuncPtrAdapter({ + name: 'xInput', signature: 'i(ppp)', bindScope: 'transient' + }), + 'void*', + new wasm.xWrap.FuncPtrAdapter({ + name: 'xFilter', bindScope: 'transient', ...__ipsProxy + }), + new wasm.xWrap.FuncPtrAdapter({ + name: 'xConflict', signature: 'i(pip)', bindScope: 'transient' + }), + 'void*' + ]], + ['sqlite3changeset_apply_v2', 'int', [ + 'sqlite3*', 'int', 'void*', + new wasm.xWrap.FuncPtrAdapter({ + name: 'xFilter', bindScope: 'transient', ...__ipsProxy + }), + new wasm.xWrap.FuncPtrAdapter({ + name: 'xConflict', signature: 'i(pip)', bindScope: 'transient' + }), + 'void*', '**', 'int*', 'int' + + ]], + ['sqlite3changeset_apply_v2_strm', 'int', [ + 'sqlite3*', + new wasm.xWrap.FuncPtrAdapter({ + name: 'xInput', signature: 'i(ppp)', bindScope: 'transient' + }), + 'void*', + new wasm.xWrap.FuncPtrAdapter({ + name: 'xFilter', bindScope: 'transient', ...__ipsProxy + }), + new wasm.xWrap.FuncPtrAdapter({ + name: 'xConflict', signature: 'i(pip)', bindScope: 'transient' + }), + 'void*', '**', 'int*', 'int' + ]], + ['sqlite3changeset_apply_v3', 'int', [ + 'sqlite3*', 'int', 'void*', + new wasm.xWrap.FuncPtrAdapter({ + name: 'xFilter', signature: 'i(pp)', bindScope: 'transient' + }), + new wasm.xWrap.FuncPtrAdapter({ + name: 'xConflict', signature: 'i(pip)', bindScope: 'transient' + }), + 'void*', '**', 'int*', 'int' + + ]], + ['sqlite3changeset_apply_v3_strm', 'int', [ + 'sqlite3*', + new wasm.xWrap.FuncPtrAdapter({ + name: 'xInput', signature: 'i(ppp)', bindScope: 'transient' + }), + 'void*', + new wasm.xWrap.FuncPtrAdapter({ + name: 'xFilter', signature: 'i(pp)', bindScope: 'transient' + }), + new wasm.xWrap.FuncPtrAdapter({ + name: 'xConflict', signature: 'i(pip)', bindScope: 'transient' + }), + 'void*', '**', 'int*', 'int' + ]], + ['sqlite3changeset_concat', 'int', ['int','void*', 'int', 'void*', 'int*', '**']], + ['sqlite3changeset_concat_strm', 'int', [ + new wasm.xWrap.FuncPtrAdapter({ + name: 'xInputA', signature: 'i(ppp)', bindScope: 'transient' + }), + 'void*', + new wasm.xWrap.FuncPtrAdapter({ + name: 'xInputB', signature: 'i(ppp)', bindScope: 'transient' + }), + 'void*', + new wasm.xWrap.FuncPtrAdapter({ + name: 'xOutput', signature: 'i(ppi)', bindScope: 'transient' + }), + 'void*' + ]], + ['sqlite3changeset_conflict', 'int', ['sqlite3_changeset_iter*', 'int', '**']], + ['sqlite3changeset_finalize', 'int', ['sqlite3_changeset_iter*']], + ['sqlite3changeset_fk_conflicts', 'int', ['sqlite3_changeset_iter*', 'int*']], + ['sqlite3changeset_invert', 'int', ['int', 'void*', 'int*', '**']], + ['sqlite3changeset_invert_strm', 'int', [ + new wasm.xWrap.FuncPtrAdapter({ + name: 'xInput', signature: 'i(ppp)', bindScope: 'transient' + }), + 'void*', + new wasm.xWrap.FuncPtrAdapter({ + name: 'xOutput', signature: 'i(ppi)', bindScope: 'transient' + }), + 'void*' + ]], + ['sqlite3changeset_new', 'int', ['sqlite3_changeset_iter*', 'int', '**']], + ['sqlite3changeset_next', 'int', ['sqlite3_changeset_iter*']], + ['sqlite3changeset_old', 'int', ['sqlite3_changeset_iter*', 'int', '**']], + ['sqlite3changeset_op', 'int', [ + 'sqlite3_changeset_iter*', '**', 'int*', 'int*','int*' + ]], + ['sqlite3changeset_pk', 'int', ['sqlite3_changeset_iter*', '**', 'int*']], + ['sqlite3changeset_start', 'int', ['**', 'int', '*']], + ['sqlite3changeset_start_strm', 'int', [ + '**', + new wasm.xWrap.FuncPtrAdapter({ + name: 'xInput', signature: 'i(ppp)', bindScope: 'transient' + }), + 'void*' + ]], + ['sqlite3changeset_start_v2', 'int', ['**', 'int', '*', 'int']], + ['sqlite3changeset_start_v2_strm', 'int', [ + '**', + new wasm.xWrap.FuncPtrAdapter({ + name: 'xInput', signature: 'i(ppp)', bindScope: 'transient' + }), + 'void*', 'int' + ]], + ['sqlite3session_attach', 'int', ['sqlite3_session*', 'string']], + ['sqlite3session_changeset', 'int', ['sqlite3_session*', 'int*', '**']], + ['sqlite3session_changeset_size', 'i64', ['sqlite3_session*']], + ['sqlite3session_changeset_strm', 'int', [ + 'sqlite3_session*', + new wasm.xWrap.FuncPtrAdapter({ + name: 'xOutput', signature: 'i(ppp)', bindScope: 'transient' + }), + 'void*' + ]], + ['sqlite3session_config', 'int', ['int', 'void*']], + ['sqlite3session_create', 'int', ['sqlite3*', 'string', '**']], + + ['sqlite3session_diff', 'int', ['sqlite3_session*', 'string', 'string', '**']], + ['sqlite3session_enable', 'int', ['sqlite3_session*', 'int']], + ['sqlite3session_indirect', 'int', ['sqlite3_session*', 'int']], + ['sqlite3session_isempty', 'int', ['sqlite3_session*']], + ['sqlite3session_memory_used', 'i64', ['sqlite3_session*']], + ['sqlite3session_object_config', 'int', ['sqlite3_session*', 'int', 'void*']], + ['sqlite3session_patchset', 'int', ['sqlite3_session*', '*', '**']], + ['sqlite3session_patchset_strm', 'int', [ + 'sqlite3_session*', + new wasm.xWrap.FuncPtrAdapter({ + name: 'xOutput', signature: 'i(ppp)', bindScope: 'transient' + }), + 'void*' + ]], + ['sqlite3session_table_filter', undefined, [ + 'sqlite3_session*', + new wasm.xWrap.FuncPtrAdapter({ + name: 'xFilter', ...__ipsProxy, + contextKey: (argv,argIndex)=>argv[0] + }), + '*' + ]] + ); + } + + + sqlite3.StructBinder = globalThis.Jaccwabyt({ + heap: wasm.heap8u, + alloc: wasm.alloc, + dealloc: wasm.dealloc, + bigIntEnabled: wasm.bigIntEnabled, + pointerIR: wasm.ptr.ir, + memberPrefix: '$' + }); + delete globalThis.Jaccwabyt; + + { + + + const __xString = wasm.xWrap.argAdapter('string'); + wasm.xWrap.argAdapter( + 'string:flexible', (v)=>__xString(util.flexibleString(v)) + ); + + + wasm.xWrap.argAdapter( + 'string:static', + function(v){ + if(wasm.isPtr(v)) return v; + v = ''+v; + let rc = this[v]; + return rc || (this[v] = wasm.allocCString(v)); + }.bind(Object.create(null)) + ); + + + const __xArgPtr = wasm.xWrap.argAdapter('*'); + const nilType = function(){ + + }; + wasm.xWrap.argAdapter('sqlite3_filename', __xArgPtr) + ('sqlite3_backup*', __xArgPtr) + ('sqlite3_context*', __xArgPtr) + ('sqlite3_value*', __xArgPtr) + ('void*', __xArgPtr) + ('sqlite3_changegroup*', __xArgPtr) + ('sqlite3_changeset_iter*', __xArgPtr) + ('sqlite3_session*', __xArgPtr) + ('sqlite3_stmt*', (v)=> + __xArgPtr((v instanceof (sqlite3?.oo1?.Stmt || nilType)) + ? v.pointer : v)) + ('sqlite3*', (v)=> + __xArgPtr((v instanceof (sqlite3?.oo1?.DB || nilType)) + ? v.pointer : v)) + + ('sqlite3_vfs*', (v)=>{ + if('string'===typeof v){ + + return capi.sqlite3_vfs_find(v) + || sqlite3.SQLite3Error.toss( + capi.SQLITE_NOTFOUND, + "Unknown sqlite3_vfs name:", v + ); + } + return __xArgPtr((v instanceof (capi.sqlite3_vfs || nilType)) + ? v.pointer : v); + }); + if( wasm.exports.sqlite3_declare_vtab ){ + wasm.xWrap.argAdapter('sqlite3_index_info*', (v)=> + __xArgPtr((v instanceof (capi.sqlite3_index_info || nilType)) + ? v.pointer : v)) + ('sqlite3_module*', (v)=> + __xArgPtr((v instanceof (capi.sqlite3_module || nilType)) + ? v.pointer : v) + ); + } + + + const __xRcPtr = wasm.xWrap.resultAdapter('*'); + wasm.xWrap.resultAdapter('sqlite3*', __xRcPtr) + ('sqlite3_backup*', __xRcPtr) + ('sqlite3_context*', __xRcPtr) + ('sqlite3_stmt*', __xRcPtr) + ('sqlite3_value*', __xRcPtr) + ('sqlite3_vfs*', __xRcPtr) + ('void*', __xRcPtr); + + + for(const e of bindingSignatures.core){ + capi[e[0]] = wasm.xWrap.apply(null, e); + } + for(const e of bindingSignatures.wasmInternal){ + util[e[0]] = wasm.xWrap.apply(null, e); + } + + + for(const e of bindingSignatures.int64){ + capi[e[0]] = wasm.bigIntEnabled + ? wasm.xWrap.apply(null, e) + : ()=>toss(e[0]+"() is unavailable due to lack", + "of BigInt support in this build."); + } + + + delete bindingSignatures.core; + delete bindingSignatures.int64; + delete bindingSignatures.wasmInternal; + + + util.sqlite3__wasm_db_error = function(pDb, resultCode, message){ + if( !pDb ) return capi.SQLITE_MISUSE; + if(resultCode instanceof sqlite3.WasmAllocError){ + resultCode = capi.SQLITE_NOMEM; + message = 0 ; + }else if(resultCode instanceof Error){ + message = message || ''+resultCode; + resultCode = (resultCode.resultCode || capi.SQLITE_ERROR); + } + return capi.sqlite3_set_errmsg(pDb, resultCode, message) || resultCode; + }; + } + + { + const cJson = wasm.xCall('sqlite3__wasm_enum_json'); + if(!cJson){ + toss("Maintenance required: increase sqlite3__wasm_enum_json()'s", + "static buffer size!"); + } + wasm.ctype = JSON.parse(wasm.cstrToJs(cJson)); + + const defineGroups = ['access', 'authorizer', + 'blobFinalizers', 'changeset', + 'config', 'dataTypes', + 'dbConfig', 'dbStatus', + 'encodings', 'fcntl', 'flock', 'ioCap', + 'limits', 'openFlags', + 'prepareFlags', 'resultCodes', + 'sqlite3Status', + 'stmtStatus', 'syncFlags', + 'trace', 'txnState', 'udfFlags', + 'version']; + if(wasm.bigIntEnabled){ + defineGroups.push('serialize', 'session', 'vtab'); + } + for(const t of defineGroups){ + for(const e of Object.entries(wasm.ctype[t])){ + + + capi[e[0]] = e[1]; + } + } + if(!wasm.functionEntry(capi.SQLITE_WASM_DEALLOC)){ + toss("Internal error: cannot resolve exported function", + "entry SQLITE_WASM_DEALLOC (=="+capi.SQLITE_WASM_DEALLOC+")."); + } + const __rcMap = Object.create(null); + for(const e of Object.entries(wasm.ctype['resultCodes'])){ + __rcMap[e[1]] = e[0]; + } + + capi.sqlite3_js_rc_str = (rc)=>__rcMap[rc]; + + + const notThese = Object.assign(Object.create(null),{ + + WasmTestStruct: true, + + sqlite3_index_info: !wasm.bigIntEnabled, + sqlite3_index_constraint: !wasm.bigIntEnabled, + sqlite3_index_orderby: !wasm.bigIntEnabled, + sqlite3_index_constraint_usage: !wasm.bigIntEnabled + }); + for(const s of wasm.ctype.structs){ + if(!notThese[s.name]){ + capi[s.name] = sqlite3.StructBinder(s); + } + } + if(capi.sqlite3_index_info){ + + for(const k of ['sqlite3_index_constraint', + 'sqlite3_index_orderby', + 'sqlite3_index_constraint_usage']){ + capi.sqlite3_index_info[k] = capi[k]; + delete capi[k]; + } + capi.sqlite3_vtab_config = wasm.xWrap( + 'sqlite3__wasm_vtab_config','int',[ + 'sqlite3*', 'int', 'int'] + ); + } + } + + + const __dbArgcMismatch = (pDb,f,n)=>{ + return util.sqlite3__wasm_db_error(pDb, capi.SQLITE_MISUSE, + f+"() requires "+n+" argument"+ + (1===n?"":'s')+"."); + }; + + + const __errEncoding = (pDb)=>{ + return util.sqlite3__wasm_db_error( + pDb, capi.SQLITE_FORMAT, "SQLITE_UTF8 is the only supported encoding." + ); + }; + + + const __argPDb = (pDb)=>wasm.xWrap.argAdapter('sqlite3*')(pDb); + const __argStr = (str)=>wasm.isPtr(str) ? wasm.cstrToJs(str) : str; + const __dbCleanupMap = function( + pDb, mode + ){ + pDb = __argPDb(pDb); + let m = this.dbMap.get(pDb); + if(!mode){ + this.dbMap.delete(pDb); + return m; + }else if(!m && mode>0){ + this.dbMap.set(pDb, (m = Object.create(null))); + } + return m; + }.bind(Object.assign(Object.create(null),{ + dbMap: new Map + })); + + __dbCleanupMap.addCollation = function(pDb, name){ + const m = __dbCleanupMap(pDb, 1); + if(!m.collation) m.collation = new Set; + m.collation.add(__argStr(name).toLowerCase()); + }; + + __dbCleanupMap._addUDF = function(pDb, name, arity, map){ + + name = __argStr(name).toLowerCase(); + let u = map.get(name); + if(!u) map.set(name, (u = new Set)); + u.add((arity<0) ? -1 : arity); + }; + + __dbCleanupMap.addFunction = function(pDb, name, arity){ + const m = __dbCleanupMap(pDb, 1); + if(!m.udf) m.udf = new Map; + this._addUDF(pDb, name, arity, m.udf); + }; + + if( wasm.exports.sqlite3_create_window_function ){ + __dbCleanupMap.addWindowFunc = function(pDb, name, arity){ + const m = __dbCleanupMap(pDb, 1); + if(!m.wudf) m.wudf = new Map; + this._addUDF(pDb, name, arity, m.wudf); + }; + } + + + __dbCleanupMap.cleanup = function(pDb){ + pDb = __argPDb(pDb); + + + for(const obj of [ + + ['sqlite3_busy_handler',3], + ['sqlite3_commit_hook',3], + ['sqlite3_preupdate_hook',3], + ['sqlite3_progress_handler',4], + ['sqlite3_rollback_hook',3], + ['sqlite3_set_authorizer',3], + ['sqlite3_trace_v2', 4], + ['sqlite3_update_hook',3] + + ]){ + const [name, arity] = obj; + const x = wasm.exports[name]; + if( !x ){ + + continue; + } + const closeArgs = [pDb]; + closeArgs.length = arity + ; + + try{ capi[name](...closeArgs) } + catch(e){ + + sqlite3.config.warn("close-time call of",name+"(",closeArgs,") threw:",e); + } + + } + const m = __dbCleanupMap(pDb, 0); + if(!m) return; + if(m.collation){ + for(const name of m.collation){ + try{ + capi.sqlite3_create_collation_v2( + pDb, name, capi.SQLITE_UTF8, 0, 0, 0 + ); + }catch(e){ + + } + } + delete m.collation; + } + let i; + for(i = 0; i < 2; ++i){ + const fmap = i ? m.wudf : m.udf; + if(!fmap) continue; + const func = i + ? capi.sqlite3_create_window_function + : capi.sqlite3_create_function_v2; + for(const e of fmap){ + const name = e[0], arities = e[1]; + const fargs = [pDb, name, 0, capi.SQLITE_UTF8, 0, 0, 0, 0, 0]; + if(i) fargs.push(0); + for(const arity of arities){ + try{ fargs[2] = arity; func.apply(null, fargs); } + catch(e){} + } + arities.clear(); + } + fmap.clear(); + } + delete m.udf; + delete m.wudf; + }; + + { + const __sqlite3CloseV2 = wasm.xWrap("sqlite3_close_v2", "int", "sqlite3*"); + capi.sqlite3_close_v2 = function(pDb){ + if(1!==arguments.length) return __dbArgcMismatch(pDb, 'sqlite3_close_v2', 1); + if(pDb){ + try{__dbCleanupMap.cleanup(pDb)} catch(e){} + } + return __sqlite3CloseV2(pDb); + }; + } + + if(capi.sqlite3session_create){ + const __sqlite3SessionDelete = wasm.xWrap( + 'sqlite3session_delete', undefined, ['sqlite3_session*'] + ); + capi.sqlite3session_delete = function(pSession){ + if(1!==arguments.length){ + return __dbArgcMismatch(pDb, 'sqlite3session_delete', 1); + + } + else if(pSession){ + + capi.sqlite3session_table_filter(pSession, 0, 0); + } + __sqlite3SessionDelete(pSession); + }; + } + + { + + const contextKey = (argv,argIndex)=>{ + return 'argv['+argIndex+']:'+argv[0]+ + ':'+wasm.cstrToJs(argv[1]).toLowerCase() + }; + const __sqlite3CreateCollationV2 = wasm.xWrap( + 'sqlite3_create_collation_v2', 'int', [ + 'sqlite3*', 'string', 'int', '*', + new wasm.xWrap.FuncPtrAdapter({ + + name: 'xCompare', signature: 'i(pipip)', contextKey + }), + new wasm.xWrap.FuncPtrAdapter({ + + name: 'xDestroy', signature: 'v(p)', contextKey + }) + ] + ); + + + capi.sqlite3_create_collation_v2 = function(pDb,zName,eTextRep,pArg,xCompare,xDestroy){ + if(6!==arguments.length) return __dbArgcMismatch(pDb, 'sqlite3_create_collation_v2', 6); + else if( 0 === (eTextRep & 0xf) ){ + eTextRep |= capi.SQLITE_UTF8; + }else if( capi.SQLITE_UTF8 !== (eTextRep & 0xf) ){ + return __errEncoding(pDb); + } + try{ + const rc = __sqlite3CreateCollationV2(pDb, zName, eTextRep, pArg, xCompare, xDestroy); + if(0===rc && xCompare instanceof Function){ + __dbCleanupMap.addCollation(pDb, zName); + } + return rc; + }catch(e){ + return util.sqlite3__wasm_db_error(pDb, e); + } + }; + + capi.sqlite3_create_collation = (pDb,zName,eTextRep,pArg,xCompare)=>{ + return (5===arguments.length) + ? capi.sqlite3_create_collation_v2(pDb,zName,eTextRep,pArg,xCompare,0) + : __dbArgcMismatch(pDb, 'sqlite3_create_collation', 5); + }; + + } + + { + + const contextKey = function(argv,argIndex){ + return ( + argv[0] + +':'+(argv[2] < 0 ? -1 : argv[2]) + +':'+argIndex + +':'+wasm.cstrToJs(argv[1]).toLowerCase() + ) + }; + + + const __cfProxy = Object.assign(Object.create(null), { + xInverseAndStep: { + signature:'v(pip)', contextKey, + callProxy: (callback)=>{ + return (pCtx, argc, pArgv)=>{ + try{ callback(pCtx, ...capi.sqlite3_values_to_js(argc, pArgv)) } + catch(e){ capi.sqlite3_result_error_js(pCtx, e) } + }; + } + }, + xFinalAndValue: { + signature:'v(p)', contextKey, + callProxy: (callback)=>{ + return (pCtx)=>{ + try{ capi.sqlite3_result_js(pCtx, callback(pCtx)) } + catch(e){ capi.sqlite3_result_error_js(pCtx, e) } + }; + } + }, + xFunc: { + signature:'v(pip)', contextKey, + callProxy: (callback)=>{ + return (pCtx, argc, pArgv)=>{ + try{ + capi.sqlite3_result_js( + pCtx, + callback(pCtx, ...capi.sqlite3_values_to_js(argc, pArgv)) + ); + }catch(e){ + + capi.sqlite3_result_error_js(pCtx, e); + } + }; + } + }, + xDestroy: { + signature:'v(p)', contextKey, + + callProxy: (callback)=>{ + return (pVoid)=>{ + try{ callback(pVoid) } + catch(e){ console.error("UDF xDestroy method threw:",e) } + }; + } + } + }); + + const __sqlite3CreateFunction = wasm.xWrap( + "sqlite3_create_function_v2", "int", [ + "sqlite3*", "string", "int", + "int", "*", + new wasm.xWrap.FuncPtrAdapter({name: 'xFunc', ...__cfProxy.xFunc}), + new wasm.xWrap.FuncPtrAdapter({name: 'xStep', ...__cfProxy.xInverseAndStep}), + new wasm.xWrap.FuncPtrAdapter({name: 'xFinal', ...__cfProxy.xFinalAndValue}), + new wasm.xWrap.FuncPtrAdapter({name: 'xDestroy', ...__cfProxy.xDestroy}) + ] + ); + + const __sqlite3CreateWindowFunction = + wasm.exports.sqlite3_create_window_function + ? wasm.xWrap( + "sqlite3_create_window_function", "int", [ + "sqlite3*", "string", "int", + "int", "*", + new wasm.xWrap.FuncPtrAdapter({name: 'xStep', ...__cfProxy.xInverseAndStep}), + new wasm.xWrap.FuncPtrAdapter({name: 'xFinal', ...__cfProxy.xFinalAndValue}), + new wasm.xWrap.FuncPtrAdapter({name: 'xValue', ...__cfProxy.xFinalAndValue}), + new wasm.xWrap.FuncPtrAdapter({name: 'xInverse', ...__cfProxy.xInverseAndStep}), + new wasm.xWrap.FuncPtrAdapter({name: 'xDestroy', ...__cfProxy.xDestroy}) + ] + ) + : undefined; + + + capi.sqlite3_create_function_v2 = function f( + pDb, funcName, nArg, eTextRep, pApp, + xFunc, + xStep, + xFinal, + xDestroy + ){ + if( f.length!==arguments.length ){ + return __dbArgcMismatch(pDb,"sqlite3_create_function_v2",f.length); + }else if( 0 === (eTextRep & 0xf) ){ + eTextRep |= capi.SQLITE_UTF8; + }else if( capi.SQLITE_UTF8 !== (eTextRep & 0xf) ){ + return __errEncoding(pDb); + } + try{ + const rc = __sqlite3CreateFunction(pDb, funcName, nArg, eTextRep, + pApp, xFunc, xStep, xFinal, xDestroy); + if(0===rc && (xFunc instanceof Function + || xStep instanceof Function + || xFinal instanceof Function + || xDestroy instanceof Function)){ + __dbCleanupMap.addFunction(pDb, funcName, nArg); + } + return rc; + }catch(e){ + console.error("sqlite3_create_function_v2() setup threw:",e); + return util.sqlite3__wasm_db_error(pDb, e, "Creation of UDF threw: "+e); + } + }; + + + capi.sqlite3_create_function = function f( + pDb, funcName, nArg, eTextRep, pApp, + xFunc, xStep, xFinal + ){ + return (f.length===arguments.length) + ? capi.sqlite3_create_function_v2(pDb, funcName, nArg, eTextRep, + pApp, xFunc, xStep, xFinal, 0) + : __dbArgcMismatch(pDb,"sqlite3_create_function",f.length); + }; + + + if( __sqlite3CreateWindowFunction ){ + capi.sqlite3_create_window_function = function f( + pDb, funcName, nArg, eTextRep, pApp, + xStep, + xFinal, + xValue, + xInverse, + xDestroy + ){ + if( f.length!==arguments.length ){ + return __dbArgcMismatch(pDb,"sqlite3_create_window_function",f.length); + }else if( 0 === (eTextRep & 0xf) ){ + eTextRep |= capi.SQLITE_UTF8; + }else if( capi.SQLITE_UTF8 !== (eTextRep & 0xf) ){ + return __errEncoding(pDb); + } + try{ + const rc = __sqlite3CreateWindowFunction(pDb, funcName, nArg, eTextRep, + pApp, xStep, xFinal, xValue, + xInverse, xDestroy); + if(0===rc && (xStep instanceof Function + || xFinal instanceof Function + || xValue instanceof Function + || xInverse instanceof Function + || xDestroy instanceof Function)){ + __dbCleanupMap.addWindowFunc(pDb, funcName, nArg); + } + return rc; + }catch(e){ + console.error("sqlite3_create_window_function() setup threw:",e); + return util.sqlite3__wasm_db_error(pDb, e, "Creation of UDF threw: "+e); + } + }; + }else{ + delete capi.sqlite3_create_window_function; + } + + capi.sqlite3_create_function_v2.udfSetResult = + capi.sqlite3_create_function.udfSetResult = capi.sqlite3_result_js; + if(capi.sqlite3_create_window_function){ + capi.sqlite3_create_window_function.udfSetResult = capi.sqlite3_result_js; + } + + + capi.sqlite3_create_function_v2.udfConvertArgs = + capi.sqlite3_create_function.udfConvertArgs = capi.sqlite3_values_to_js; + if(capi.sqlite3_create_window_function){ + capi.sqlite3_create_window_function.udfConvertArgs = capi.sqlite3_values_to_js; + } + + + capi.sqlite3_create_function_v2.udfSetError = + capi.sqlite3_create_function.udfSetError = capi.sqlite3_result_error_js; + if(capi.sqlite3_create_window_function){ + capi.sqlite3_create_window_function.udfSetError = capi.sqlite3_result_error_js; + } + + }; + + { + + + const __flexiString = (v,n)=>{ + if('string'===typeof v){ + n = -1; + }else if(util.isSQLableTypedArray(v)){ + n = v.byteLength; + v = wasm.typedArrayToString( + (v instanceof ArrayBuffer) ? new Uint8Array(v) : v + ); + }else if(Array.isArray(v)){ + v = v.join(""); + n = -1; + } + return [v, n]; + }; + + + const __prepare = { + + basic: wasm.xWrap('sqlite3_prepare_v3', + "int", ["sqlite3*", "string", + "int", + "int", "**", + "**"]), + + full: wasm.xWrap('sqlite3_prepare_v3', + "int", ["sqlite3*", "*", "int", "int", + "**", "**"]) + }; + + + capi.sqlite3_prepare_v3 = function f(pDb, sql, sqlLen, prepFlags, ppStmt, pzTail){ + if(f.length!==arguments.length){ + return __dbArgcMismatch(pDb,"sqlite3_prepare_v3",f.length); + } + const [xSql, xSqlLen] = __flexiString(sql, Number(sqlLen)); + switch(typeof xSql){ + case 'string': return __prepare.basic(pDb, xSql, xSqlLen, prepFlags, ppStmt, null); + case (typeof wasm.ptr.null): + return __prepare.full(pDb, wasm.ptr.coerce(xSql), xSqlLen, prepFlags, + ppStmt, pzTail); + default: + return util.sqlite3__wasm_db_error( + pDb, capi.SQLITE_MISUSE, + "Invalid SQL argument type for sqlite3_prepare_v2/v3(). typeof="+(typeof xSql) + ); + } + }; + + + capi.sqlite3_prepare_v2 = function f(pDb, sql, sqlLen, ppStmt, pzTail){ + return (f.length===arguments.length) + ? capi.sqlite3_prepare_v3(pDb, sql, sqlLen, 0, ppStmt, pzTail) + : __dbArgcMismatch(pDb,"sqlite3_prepare_v2",f.length); + }; + + } + + { + const __bindText = wasm.xWrap("sqlite3_bind_text", "int", [ + "sqlite3_stmt*", "int", "string", "int", "*" + ]); + const __bindBlob = wasm.xWrap("sqlite3_bind_blob", "int", [ + "sqlite3_stmt*", "int", "*", "int", "*" + ]); + + + capi.sqlite3_bind_text = function f(pStmt, iCol, text, nText, xDestroy){ + if(f.length!==arguments.length){ + return __dbArgcMismatch(capi.sqlite3_db_handle(pStmt), + "sqlite3_bind_text", f.length); + }else if(wasm.isPtr(text) || null===text){ + return __bindText(pStmt, iCol, text, nText, xDestroy); + }else if(text instanceof ArrayBuffer){ + text = new Uint8Array(text); + }else if(Array.isArray(pMem)){ + text = pMem.join(''); + } + let p, n; + try{ + if(util.isSQLableTypedArray(text)){ + p = wasm.allocFromTypedArray(text); + n = text.byteLength; + }else if('string'===typeof text){ + [p, n] = wasm.allocCString(text); + }else{ + return util.sqlite3__wasm_db_error( + capi.sqlite3_db_handle(pStmt), capi.SQLITE_MISUSE, + "Invalid 3rd argument type for sqlite3_bind_text()." + ); + } + return __bindText(pStmt, iCol, p, n, capi.SQLITE_WASM_DEALLOC); + }catch(e){ + wasm.dealloc(p); + return util.sqlite3__wasm_db_error( + capi.sqlite3_db_handle(pStmt), e + ); + } + }; + + + capi.sqlite3_bind_blob = function f(pStmt, iCol, pMem, nMem, xDestroy){ + if(f.length!==arguments.length){ + return __dbArgcMismatch(capi.sqlite3_db_handle(pStmt), + "sqlite3_bind_blob", f.length); + }else if(wasm.isPtr(pMem) || null===pMem){ + return __bindBlob(pStmt, iCol, pMem, nMem, xDestroy); + }else if(pMem instanceof ArrayBuffer){ + pMem = new Uint8Array(pMem); + }else if(Array.isArray(pMem)){ + pMem = pMem.join(''); + } + let p, n; + try{ + if(util.isBindableTypedArray(pMem)){ + p = wasm.allocFromTypedArray(pMem); + n = nMem>=0 ? nMem : pMem.byteLength; + }else if('string'===typeof pMem){ + [p, n] = wasm.allocCString(pMem); + }else{ + return util.sqlite3__wasm_db_error( + capi.sqlite3_db_handle(pStmt), capi.SQLITE_MISUSE, + "Invalid 3rd argument type for sqlite3_bind_blob()." + ); + } + return __bindBlob(pStmt, iCol, p, n, capi.SQLITE_WASM_DEALLOC); + }catch(e){ + wasm.dealloc(p); + return util.sqlite3__wasm_db_error( + capi.sqlite3_db_handle(pStmt), e + ); + } + }; + + } + + if(!capi.sqlite3_column_text){ + + const argStmt = wasm.xWrap.argAdapter('sqlite3_stmt*'), + argInt = wasm.xWrap.argAdapter('int'), + argValue = wasm.xWrap.argAdapter('sqlite3_value*'), + newStr = + (cstr,n)=>wasm.typedArrayToString(wasm.heap8u(), + Number(cstr), Number(cstr)+n) + capi.sqlite3_column_text = function(stmt, colIndex){ + const a0 = argStmt(stmt), a1 = argInt(colIndex); + const cstr = wasm.exports.sqlite3_column_text(a0, a1); + return cstr + ? newStr(cstr,wasm.exports.sqlite3_column_bytes(a0, a1)) + : null; + }; + capi.sqlite3_value_text = function(val){ + const a0 = argValue(val); + const cstr = wasm.exports.sqlite3_value_text(a0); + return cstr + ? newStr(cstr,wasm.exports.sqlite3_value_bytes(a0)) + : null; + }; + } + + { + + capi.sqlite3_config = function(op, ...args){ + if(arguments.length<2) return capi.SQLITE_MISUSE; + switch(op){ + case capi.SQLITE_CONFIG_COVERING_INDEX_SCAN: + case capi.SQLITE_CONFIG_MEMSTATUS: + case capi.SQLITE_CONFIG_SMALL_MALLOC: + case capi.SQLITE_CONFIG_SORTERREF_SIZE: + case capi.SQLITE_CONFIG_STMTJRNL_SPILL: + case capi.SQLITE_CONFIG_URI: + return wasm.exports.sqlite3__wasm_config_i(op, args[0]); + case capi.SQLITE_CONFIG_LOOKASIDE: + return wasm.exports.sqlite3__wasm_config_ii(op, args[0], args[1]); + case capi.SQLITE_CONFIG_MEMDB_MAXSIZE: + return wasm.exports.sqlite3__wasm_config_j(op, args[0]); + case capi.SQLITE_CONFIG_GETMALLOC: + case capi.SQLITE_CONFIG_GETMUTEX: + case capi.SQLITE_CONFIG_GETPCACHE2: + case capi.SQLITE_CONFIG_GETPCACHE: + case capi.SQLITE_CONFIG_HEAP: + case capi.SQLITE_CONFIG_LOG: + case capi.SQLITE_CONFIG_MALLOC: + case capi.SQLITE_CONFIG_MMAP_SIZE: + case capi.SQLITE_CONFIG_MULTITHREAD: + case capi.SQLITE_CONFIG_MUTEX: + case capi.SQLITE_CONFIG_PAGECACHE: + case capi.SQLITE_CONFIG_PCACHE2: + case capi.SQLITE_CONFIG_PCACHE: + case capi.SQLITE_CONFIG_PCACHE_HDRSZ: + case capi.SQLITE_CONFIG_PMASZ: + case capi.SQLITE_CONFIG_SERIALIZED: + case capi.SQLITE_CONFIG_SINGLETHREAD: + case capi.SQLITE_CONFIG_SQLLOG: + case capi.SQLITE_CONFIG_WIN32_HEAPSIZE: + default: + + return capi.SQLITE_NOTFOUND; + } + }; + } + + { + const __autoExtFptr = new Set; + + capi.sqlite3_auto_extension = function(fPtr){ + if( fPtr instanceof Function ){ + fPtr = wasm.installFunction('i(ppp)', fPtr); + }else if( 1!==arguments.length || !wasm.isPtr(fPtr) ){ + return capi.SQLITE_MISUSE; + } + const rc = wasm.exports.sqlite3_auto_extension(fPtr); + if( fPtr!==arguments[0] ){ + if(0===rc) __autoExtFptr.add(fPtr); + else wasm.uninstallFunction(fPtr); + } + return rc; + }; + + capi.sqlite3_cancel_auto_extension = function(fPtr){ + ; + if(!fPtr || 1!==arguments.length || !wasm.isPtr(fPtr)) return 0; + return wasm.exports.sqlite3_cancel_auto_extension(fPtr); + + }; + + capi.sqlite3_reset_auto_extension = function(){ + wasm.exports.sqlite3_reset_auto_extension(); + for(const fp of __autoExtFptr) wasm.uninstallFunction(fp); + __autoExtFptr.clear(); + }; + } + + + wasm.xWrap.FuncPtrAdapter.warnOnUse = true; + + const StructBinder = sqlite3.StructBinder + ; + + const installMethod = function callee( + tgt, name, func, applyArgcCheck = callee.installMethodArgcCheck + ){ + if(!(tgt instanceof StructBinder.StructType)){ + toss("Usage error: target object is-not-a StructType."); + }else if(!(func instanceof Function) && !wasm.isPtr(func)){ + toss("Usage error: expecting a Function or WASM pointer to one."); + } + if(1===arguments.length){ + return (n,f)=>callee(tgt, n, f, applyArgcCheck); + } + if(!callee.argcProxy){ + callee.argcProxy = function(tgt, funcName, func,sig){ + return function(...args){ + if(func.length!==arguments.length){ + toss("Argument mismatch for", + tgt.structInfo.name+"::"+funcName + +": Native signature is:",sig); + } + return func.apply(this, args); + } + }; + + callee.removeFuncList = function(){ + if(this.ondispose.__removeFuncList){ + this.ondispose.__removeFuncList.forEach( + (v,ndx)=>{ + if(wasm.isPtr(v)){ + try{wasm.uninstallFunction(v)} + catch(e){} + } + + } + ); + delete this.ondispose.__removeFuncList; + } + }; + } + const sigN = tgt.memberSignature(name); + if(sigN.length<2){ + toss("Member",name,"does not have a function pointer signature:",sigN); + } + const memKey = tgt.memberKey(name); + const fProxy = (applyArgcCheck && !wasm.isPtr(func)) + + ? callee.argcProxy(tgt, memKey, func, sigN) + : func; + if(wasm.isPtr(fProxy)){ + if(fProxy && !wasm.functionEntry(fProxy)){ + toss("Pointer",fProxy,"is not a WASM function table entry."); + } + tgt[memKey] = fProxy; + }else{ + const pFunc = wasm.installFunction(fProxy, sigN); + tgt[memKey] = pFunc; + if(!tgt.ondispose || !tgt.ondispose.__removeFuncList){ + tgt.addOnDispose('ondispose.__removeFuncList handler', + callee.removeFuncList); + tgt.ondispose.__removeFuncList = []; + } + tgt.ondispose.__removeFuncList.push(memKey, pFunc); + } + return (n,f)=>callee(tgt, n, f, applyArgcCheck); + }; + installMethod.installMethodArgcCheck = false; + + + const installMethods = function( + structInstance, methods, applyArgcCheck = installMethod.installMethodArgcCheck + ){ + const seen = new Map ; + for(const k of Object.keys(methods)){ + const m = methods[k]; + const prior = seen.get(m); + if(prior){ + const mkey = structInstance.memberKey(k); + structInstance[mkey] = structInstance[structInstance.memberKey(prior)]; + }else{ + installMethod(structInstance, k, m, applyArgcCheck); + seen.set(m, k); + } + } + return structInstance; + }; + + + StructBinder.StructType.prototype.installMethod = function callee( + name, func, applyArgcCheck = installMethod.installMethodArgcCheck + ){ + return (arguments.length < 3 && name && 'object'===typeof name) + ? installMethods(this, ...arguments) + : installMethod(this, ...arguments); + }; + + + StructBinder.StructType.prototype.installMethods = function( + methods, applyArgcCheck = installMethod.installMethodArgcCheck + ){ + return installMethods(this, methods, applyArgcCheck); + }; + +}); + +globalThis.sqlite3ApiBootstrap.initializers.push(function(sqlite3){ + const toss3 = (...args)=>{throw new sqlite3.SQLite3Error(...args)}; + + const capi = sqlite3.capi, wasm = sqlite3.wasm, util = sqlite3.util; + + + const outWrapper = function(f){ + return (...args)=>f("sqlite3.oo1:",...args); + }; + + const debug = sqlite3.__isUnderTest + ? outWrapper(console.debug.bind(console)) + : outWrapper(sqlite3.config.debug); + const warn = sqlite3.__isUnderTest + ? outWrapper(console.warn.bind(console)) + : outWrapper(sqlite3.config.warn); + const error = sqlite3.__isUnderTest + ? outWrapper(console.error.bind(console)) + : outWrapper(sqlite3.config.error); + + + const __ptrMap = new WeakMap(); + + const __doesNotOwnHandle = new Set(); + + const __stmtMap = new WeakMap(); + + + const getOwnOption = (opts, p, dflt)=>{ + const d = Object.getOwnPropertyDescriptor(opts,p); + return d ? d.value : dflt; + }; + + + const checkSqlite3Rc = function(dbPtr, sqliteResultCode){ + if(sqliteResultCode){ + if(dbPtr instanceof DB) dbPtr = dbPtr.pointer; + toss3( + sqliteResultCode, + "sqlite3 result code",sqliteResultCode+":", + (dbPtr + ? capi.sqlite3_errmsg(dbPtr) + : capi.sqlite3_errstr(sqliteResultCode)) + ); + } + return arguments[0]; + }; + + + const __dbTraceToConsole = + wasm.installFunction('i(ippp)', function(t,c,p,x){ + if(capi.SQLITE_TRACE_STMT===t){ + + console.log("SQL TRACE #"+(++this.counter), + 'via sqlite3@'+c+'['+capi.sqlite3_db_filename(c,null)+']', + wasm.cstrToJs(x)); + } + }.bind({counter: 0})); + + + const __vfsPostOpenCallback = Object.create(null); + + + const byteArrayToHex = function(ba){ + if( ba instanceof ArrayBuffer ){ + ba = new Uint8Array(ba); + } + const li = []; + const digits = "0123456789abcdef"; + for( const d of ba ){ + li.push( digits[(d & 0xf0) >> 4], digits[d & 0x0f] ); + } + return li.join(''); + }; + + + const dbCtorApplySEEKey = function(db,opt){ + if( !capi.sqlite3_key_v2 ) return; + let keytype; + let key; + const check = (opt.key ? 1 : 0) + (opt.hexkey ? 1 : 0) + (opt.textkey ? 1 : 0); + if( !check ) return; + else if( check>1 ){ + toss3(capi.SQLITE_MISUSE, + "Only ONE of (key, hexkey, textkey) may be provided."); + } + if( opt.key ){ + + keytype = 'key'; + key = opt.key; + if('string'===typeof key){ + key = new TextEncoder('utf-8').encode(key); + } + if((key instanceof ArrayBuffer) || (key instanceof Uint8Array)){ + key = byteArrayToHex(key); + keytype = 'hexkey'; + }else{ + toss3(capi.SQLITE_MISUSE, + "Invalid value for the 'key' option. Expecting a string,", + "ArrayBuffer, or Uint8Array."); + return; + } + }else if( opt.textkey ){ + + keytype = 'textkey'; + key = opt.textkey; + if(key instanceof ArrayBuffer){ + key = new Uint8Array(key); + } + if(key instanceof Uint8Array){ + key = new TextDecoder('utf-8').decode(key); + }else if('string'!==typeof key){ + toss3(capi.SQLITE_MISUSE, + "Invalid value for the 'textkey' option. Expecting a string,", + "ArrayBuffer, or Uint8Array."); + } + }else if( opt.hexkey ){ + keytype = 'hexkey'; + key = opt.hexkey; + if((key instanceof ArrayBuffer) || (key instanceof Uint8Array)){ + key = byteArrayToHex(key); + }else if('string'!==typeof key){ + toss3(capi.SQLITE_MISUSE, + "Invalid value for the 'hexkey' option. Expecting a string,", + "ArrayBuffer, or Uint8Array."); + } + + }else{ + return; + } + let stmt; + try{ + stmt = db.prepare("PRAGMA "+keytype+"="+util.sqlite3__wasm_qfmt_token(key, 1)); + stmt.step(); + return true; + }finally{ + if(stmt) stmt.finalize(); + } + }; + + + const dbCtorHelper = function ctor(...args){ + const opt = ctor.normalizeArgs(...args); + + let pDb; + if( (pDb = opt['sqlite3*']) ){ + + + if( !opt['sqlite3*:takeOwnership'] ){ + + __doesNotOwnHandle.add(this); + } + this.filename = capi.sqlite3_db_filename(pDb,'main'); + }else{ + let fn = opt.filename, vfsName = opt.vfs, flagsStr = opt.flags; + if( ('string'!==typeof fn && !wasm.isPtr(fn)) + || 'string'!==typeof flagsStr + || (vfsName && ('string'!==typeof vfsName && !wasm.isPtr(vfsName))) ){ + sqlite3.config.error("Invalid DB ctor args",opt,arguments); + toss3("Invalid arguments for DB constructor:", arguments, "opts:", opt); + } + let oflags = 0; + if( flagsStr.indexOf('c')>=0 ){ + oflags |= capi.SQLITE_OPEN_CREATE | capi.SQLITE_OPEN_READWRITE; + } + if( flagsStr.indexOf('w')>=0 ) oflags |= capi.SQLITE_OPEN_READWRITE; + if( 0===oflags ) oflags |= capi.SQLITE_OPEN_READONLY; + oflags |= capi.SQLITE_OPEN_EXRESCODE; + const stack = wasm.pstack.pointer; + try { + const pPtr = wasm.pstack.allocPtr() ; + let rc = capi.sqlite3_open_v2(fn, pPtr, oflags, vfsName || wasm.ptr.null); + pDb = wasm.peekPtr(pPtr); + checkSqlite3Rc(pDb, rc); + capi.sqlite3_extended_result_codes(pDb, 1); + if(flagsStr.indexOf('t')>=0){ + capi.sqlite3_trace_v2(pDb, capi.SQLITE_TRACE_STMT, + __dbTraceToConsole, pDb); + } + }catch( e ){ + if( pDb ) capi.sqlite3_close_v2(pDb); + throw e; + }finally{ + wasm.pstack.restore(stack); + } + this.filename = + wasm.isPtr(fn) ? wasm.cstrToJs(fn) : fn; + + } + __ptrMap.set(this, pDb); + __stmtMap.set(this, Object.create(null)); + if( !opt['sqlite3*'] ){ + try{ + dbCtorApplySEEKey(this,opt); + + const pVfs = capi.sqlite3_js_db_vfs(pDb) + || toss3("Internal error: cannot get VFS for new db handle."); + const postInitSql = __vfsPostOpenCallback[pVfs]; + if(postInitSql){ + + if(postInitSql instanceof Function){ + postInitSql(this, sqlite3); + }else{ + checkSqlite3Rc( + pDb, capi.sqlite3_exec(pDb, postInitSql, 0, 0, 0) + ); + } + } + }catch(e){ + this.close(); + throw e; + } + } + }; + + + dbCtorHelper.setVfsPostOpenCallback = function(pVfs, callback){ + if( !(callback instanceof Function)){ + toss3("dbCtorHelper.setVfsPostOpenCallback() should not be used with "+ + "a non-function argument.",arguments); + } + __vfsPostOpenCallback[pVfs] = callback; + }; + + + dbCtorHelper.normalizeArgs = function(filename=':memory:',flags = 'c',vfs = null){ + const arg = {}; + if(1===arguments.length && arguments[0] && 'object'===typeof arguments[0]){ + Object.assign(arg, arguments[0]); + if(undefined===arg.flags) arg.flags = 'c'; + if(undefined===arg.vfs) arg.vfs = null; + if(undefined===arg.filename) arg.filename = ':memory:'; + }else{ + arg.filename = filename; + arg.flags = flags; + arg.vfs = vfs; + } + return arg; + }; + + const DB = function(...args){ + dbCtorHelper.apply(this, args); + }; + DB.dbCtorHelper = dbCtorHelper; + + + const BindTypes = { + null: 1, + number: 2, + string: 3, + boolean: 4, + blob: 5 + }; + if(wasm.bigIntEnabled){ + BindTypes.bigint = BindTypes.number; + } + + + const Stmt = function(){ + if(BindTypes!==arguments[2]){ + toss3(capi.SQLITE_MISUSE, "Do not call the Stmt constructor directly. Use DB.prepare()."); + } + this.db = arguments[0]; + __ptrMap.set(this, arguments[1]); + if( arguments.length>3 && !arguments[3] ){ + __doesNotOwnHandle.add(this); + } + }; + + + const affirmDbOpen = function(db){ + if(!db.pointer) toss3("DB has been closed."); + return db; + }; + + + const affirmColIndex = function(stmt,ndx){ + if((ndx !== (ndx|0)) || ndx<0 || ndx>=stmt.columnCount){ + toss3("Column index",ndx,"is out of range."); + } + return stmt; + }; + + + const parseExecArgs = function(db, args){ + const out = Object.create(null); + out.opt = Object.create(null); + switch(args.length){ + case 1: + if('string'===typeof args[0] || util.isSQLableTypedArray(args[0])){ + out.sql = args[0]; + }else if(Array.isArray(args[0])){ + out.sql = args[0]; + }else if(args[0] && 'object'===typeof args[0]){ + out.opt = args[0]; + out.sql = out.opt.sql; + } + break; + case 2: + out.sql = args[0]; + out.opt = args[1]; + break; + default: toss3("Invalid argument count for exec()."); + }; + out.sql = util.flexibleString(out.sql); + if('string'!==typeof out.sql){ + toss3("Missing SQL argument or unsupported SQL value type."); + } + const opt = out.opt; + switch(opt.returnValue){ + case 'resultRows': + if(!opt.resultRows) opt.resultRows = []; + out.returnVal = ()=>opt.resultRows; + break; + case 'saveSql': + if(!opt.saveSql) opt.saveSql = []; + out.returnVal = ()=>opt.saveSql; + break; + case undefined: + case 'this': + out.returnVal = ()=>db; + break; + default: + toss3("Invalid returnValue value:",opt.returnValue); + } + if(!opt.callback && !opt.returnValue && undefined!==opt.rowMode){ + if(!opt.resultRows) opt.resultRows = []; + out.returnVal = ()=>opt.resultRows; + } + if(opt.callback || opt.resultRows){ + switch((undefined===opt.rowMode) ? 'array' : opt.rowMode) { + case 'object': + out.cbArg = (stmt,cache)=>{ + if( !cache.columnNames ) cache.columnNames = stmt.getColumnNames([]); + + const row = stmt.get([]); + const rv = Object.create(null); + for( const i in cache.columnNames ) rv[cache.columnNames[i]] = row[i]; + return rv; + }; + break; + case 'array': out.cbArg = (stmt)=>stmt.get([]); break; + case 'stmt': + if(Array.isArray(opt.resultRows)){ + toss3("exec(): invalid rowMode for a resultRows array: must", + "be one of 'array', 'object',", + "a result column number, or column name reference."); + } + out.cbArg = (stmt)=>stmt; + break; + default: + if(util.isInt32(opt.rowMode)){ + out.cbArg = (stmt)=>stmt.get(opt.rowMode); + break; + }else if('string'===typeof opt.rowMode + && opt.rowMode.length>1 + && '$'===opt.rowMode[0]){ + + const $colName = opt.rowMode.substr(1); + out.cbArg = (stmt)=>{ + const rc = stmt.get(Object.create(null))[$colName]; + return (undefined===rc) + ? toss3(capi.SQLITE_NOTFOUND, + "exec(): unknown result column:",$colName) + : rc; + }; + break; + } + toss3("Invalid rowMode:",opt.rowMode); + } + } + return out; + }; + + + const __selectFirstRow = (db, sql, bind, ...getArgs)=>{ + const stmt = db.prepare(sql); + try { + const rc = stmt.bind(bind).step() ? stmt.get(...getArgs) : undefined; + stmt.reset(); + return rc; + }finally{ + stmt.finalize(); + } + }; + + + const __selectAll = + (db, sql, bind, rowMode)=>db.exec({ + sql, bind, rowMode, returnValue: 'resultRows' + }); + + + const __stmtGetColumn = (stmt, ndx, asType)=>{ + switch(undefined===asType + ? capi.sqlite3_column_type(stmt.pointer, ndx) + : asType){ + case capi.SQLITE_NULL: return null; + case capi.SQLITE_INTEGER:{ + if(wasm.bigIntEnabled){ + const rc = capi.sqlite3_column_int64(stmt.pointer, ndx); + if(rc>=Number.MIN_SAFE_INTEGER && rc<=Number.MAX_SAFE_INTEGER){ + return Number(rc).valueOf(); + } + return rc; + }else{ + const rc = capi.sqlite3_column_double(stmt.pointer, ndx); + if(rc>Number.MAX_SAFE_INTEGER || rccheckSqlite3Rc(db,resultCode); + + DB.prototype = { + + isOpen: function(){ + return !!this.pointer; + }, + + affirmOpen: function(){ + return affirmDbOpen(this); + }, + + close: function(){ + const pDb = this.pointer; + if(pDb){ + if(this.onclose?.before instanceof Function){ + try{this.onclose.before(this)} + catch(e){} + } + for(const s of Object.values(__stmtMap.get(this))){ + if(s?.pointer){ + try{s.finalize()} + catch(ex){} + } + } + __ptrMap.delete(this); + __stmtMap.delete(this); + if( !__doesNotOwnHandle.delete(this) ){ + capi.sqlite3_close_v2(pDb); + } + if(this.onclose?.after instanceof Function){ + try{this.onclose.after(this)} + catch(e){} + } + delete this.filename; + } + }, + + changes: function(total=false,sixtyFour=false){ + const p = affirmDbOpen(this).pointer; + if(total){ + return sixtyFour + ? capi.sqlite3_total_changes64(p) + : capi.sqlite3_total_changes(p); + }else{ + return sixtyFour + ? capi.sqlite3_changes64(p) + : capi.sqlite3_changes(p); + } + }, + + dbFilename: function(dbName='main'){ + return capi.sqlite3_db_filename(affirmDbOpen(this).pointer, dbName); + }, + + dbName: function(dbNumber=0){ + return capi.sqlite3_db_name(affirmDbOpen(this).pointer, dbNumber); + }, + + dbVfsName: function(dbName=0){ + let rc; + const pVfs = capi.sqlite3_js_db_vfs( + affirmDbOpen(this).pointer, dbName + ); + if(pVfs){ + const v = new capi.sqlite3_vfs(pVfs); + try{ rc = wasm.cstrToJs(v.$zName) } + finally { v.dispose() } + } + return rc; + }, + + prepare: function(sql){ + affirmDbOpen(this); + const stack = wasm.pstack.pointer; + let ppStmt, pStmt; + try{ + ppStmt = wasm.pstack.alloc(8); + DB.checkRc(this, capi.sqlite3_prepare_v2(this.pointer, sql, -1, ppStmt, null)); + pStmt = wasm.peekPtr(ppStmt); + } + finally { + wasm.pstack.restore(stack); + } + if(!pStmt) toss3("Cannot prepare empty SQL."); + const stmt = new Stmt(this, pStmt, BindTypes); + __stmtMap.get(this)[pStmt] = stmt; + return stmt; + }, + + exec: function(){ + affirmDbOpen(this); + const arg = parseExecArgs(this, arguments); + if(!arg.sql){ + return toss3("exec() requires an SQL string."); + } + const opt = arg.opt; + const callback = opt.callback; + const resultRows = + Array.isArray(opt.resultRows) ? opt.resultRows : undefined; + let stmt; + let bind = opt.bind; + let evalFirstResult = !!( + arg.cbArg || opt.columnNames || resultRows + ) ; + const stack = wasm.scopedAllocPush(); + const saveSql = Array.isArray(opt.saveSql) ? opt.saveSql : undefined; + try{ + const isTA = util.isSQLableTypedArray(arg.sql) + ; + + let sqlByteLen = isTA ? arg.sql.byteLength : wasm.jstrlen(arg.sql); + const ppStmt = wasm.scopedAlloc( + + (2 * wasm.ptr.size) + (sqlByteLen + 1) + ); + const pzTail = wasm.ptr.add(ppStmt, wasm.ptr.size) ; + let pSql = wasm.ptr.add(pzTail, wasm.ptr.size); + const pSqlEnd = wasm.ptr.add(pSql, sqlByteLen); + if(isTA) wasm.heap8().set(arg.sql, pSql); + else wasm.jstrcpy(arg.sql, wasm.heap8(), pSql, sqlByteLen, false); + wasm.poke8(wasm.ptr.add(pSql, sqlByteLen), 0); + while(pSql && wasm.peek8(pSql) + ){ + wasm.pokePtr([ppStmt, pzTail], 0); + DB.checkRc(this, capi.sqlite3_prepare_v3( + this.pointer, pSql, sqlByteLen, 0, ppStmt, pzTail + )); + const pStmt = wasm.peekPtr(ppStmt); + pSql = wasm.peekPtr(pzTail); + sqlByteLen = Number(wasm.ptr.add(pSqlEnd,-pSql)); + if(!pStmt) continue; + + if(saveSql) saveSql.push(capi.sqlite3_sql(pStmt).trim()); + stmt = new Stmt(this, pStmt, BindTypes); + if(bind && stmt.parameterCount){ + stmt.bind(bind); + bind = null; + } + if(evalFirstResult && stmt.columnCount){ + + let gotColNames = Array.isArray( + opt.columnNames + ) ? 0 : 1; + evalFirstResult = false; + if(arg.cbArg || resultRows){ + const cbArgCache = Object.create(null) + ; + for( ; stmt.step(); __execLock.delete(stmt) ){ + if(0===gotColNames++){ + stmt.getColumnNames(cbArgCache.columnNames = (opt.columnNames || [])); + } + __execLock.add(stmt); + const row = arg.cbArg(stmt,cbArgCache); + if(resultRows) resultRows.push(row); + if(callback && false === callback.call(opt, row, stmt)){ + break; + } + } + __execLock.delete(stmt); + } + if(0===gotColNames){ + + stmt.getColumnNames(opt.columnNames); + } + }else{ + stmt.step(); + } + stmt.reset( + ).finalize(); + stmt = null; + } + }finally{ + if(stmt){ + __execLock.delete(stmt); + stmt.finalize(); + } + wasm.scopedAllocPop(stack); + } + return arg.returnVal(); + }, + + + + createFunction: function f(name, xFunc, opt){ + const isFunc = (f)=>(f instanceof Function); + switch(arguments.length){ + case 1: + opt = name; + name = opt.name; + xFunc = opt.xFunc || 0; + break; + case 2: + if(!isFunc(xFunc)){ + opt = xFunc; + xFunc = opt.xFunc || 0; + } + break; + case 3: + break; + default: break; + } + if(!opt) opt = {}; + if('string' !== typeof name){ + toss3("Invalid arguments: missing function name."); + } + let xStep = opt.xStep || 0; + let xFinal = opt.xFinal || 0; + const xValue = opt.xValue || 0; + const xInverse = opt.xInverse || 0; + let isWindow = undefined; + if(isFunc(xFunc)){ + isWindow = false; + if(isFunc(xStep) || isFunc(xFinal)){ + toss3("Ambiguous arguments: scalar or aggregate?"); + } + xStep = xFinal = null; + }else if(isFunc(xStep)){ + if(!isFunc(xFinal)){ + toss3("Missing xFinal() callback for aggregate or window UDF."); + } + xFunc = null; + }else if(isFunc(xFinal)){ + toss3("Missing xStep() callback for aggregate or window UDF."); + }else{ + toss3("Missing function-type properties."); + } + if(false === isWindow){ + if(isFunc(xValue) || isFunc(xInverse)){ + toss3("xValue and xInverse are not permitted for non-window UDFs."); + } + }else if(isFunc(xValue)){ + if(!isFunc(xInverse)){ + toss3("xInverse must be provided if xValue is."); + } + isWindow = true; + }else if(isFunc(xInverse)){ + toss3("xValue must be provided if xInverse is."); + } + const pApp = opt.pApp; + if( undefined!==pApp + && null!==pApp + && !wasm.isPtr(pApp) ){ + toss3("Invalid value for pApp property. Must be a legal WASM pointer value."); + } + const xDestroy = opt.xDestroy || 0; + if(xDestroy && !isFunc(xDestroy)){ + toss3("xDestroy property must be a function."); + } + let fFlags = 0 ; + if(getOwnOption(opt, 'deterministic')) fFlags |= capi.SQLITE_DETERMINISTIC; + if(getOwnOption(opt, 'directOnly')) fFlags |= capi.SQLITE_DIRECTONLY; + if(getOwnOption(opt, 'innocuous')) fFlags |= capi.SQLITE_INNOCUOUS; + name = name.toLowerCase(); + const xArity = xFunc || xStep; + const arity = getOwnOption(opt, 'arity'); + const arityArg = ('number'===typeof arity + ? arity + : (xArity.length ? xArity.length-1 : 0)); + let rc; + if( isWindow ){ + rc = capi.sqlite3_create_window_function( + this.pointer, name, arityArg, + capi.SQLITE_UTF8 | fFlags, pApp || 0, + xStep, xFinal, xValue, xInverse, xDestroy); + }else{ + rc = capi.sqlite3_create_function_v2( + this.pointer, name, arityArg, + capi.SQLITE_UTF8 | fFlags, pApp || 0, + xFunc, xStep, xFinal, xDestroy); + } + DB.checkRc(this, rc); + return this; + }, + + selectValue: function(sql,bind,asType){ + return __selectFirstRow(this, sql, bind, 0, asType); + }, + + + selectValues: function(sql,bind,asType){ + const stmt = this.prepare(sql), rc = []; + try { + affirmColIndex(stmt, 0); + stmt.bind(bind); + while(stmt.step()) rc.push( + __stmtGetColumn(stmt, 0, asType) + ); + stmt.reset(); + }finally{ + stmt.finalize(); + } + return rc; + }, + + + selectArray: function(sql,bind){ + return __selectFirstRow(this, sql, bind, []); + }, + + + selectObject: function(sql,bind){ + return __selectFirstRow(this, sql, bind, {}); + }, + + + selectArrays: function(sql,bind){ + return __selectAll(this, sql, bind, 'array'); + }, + + + selectObjects: function(sql,bind){ + return __selectAll(this, sql, bind, 'object'); + }, + + + openStatementCount: function(){ + return this.pointer ? Object.keys(__stmtMap.get(this)).length : 0; + }, + + + transaction: function(callback){ + let opener = 'BEGIN'; + if(arguments.length>1){ + if(/[^a-zA-Z]/.test(arguments[0])){ + toss3(capi.SQLITE_MISUSE, "Invalid argument for BEGIN qualifier."); + } + opener += ' '+arguments[0]; + callback = arguments[1]; + } + affirmDbOpen(this).exec(opener); + try { + const rc = callback(this); + this.exec("COMMIT"); + return rc; + }catch(e){ + this.exec("ROLLBACK"); + throw e; + } + }, + + + savepoint: function(callback){ + affirmDbOpen(this).exec("SAVEPOINT oo1"); + try { + const rc = callback(this); + this.exec("RELEASE oo1"); + return rc; + }catch(e){ + this.exec("ROLLBACK to SAVEPOINT oo1; RELEASE SAVEPOINT oo1"); + throw e; + } + }, + + + checkRc: function(resultCode){ + return checkSqlite3Rc(this, resultCode); + }, + }; + + + DB.wrapHandle = function(pDb, takeOwnership=false){ + if( !pDb || !wasm.isPtr(pDb) ){ + throw new sqlite3.SQLite3Error(capi.SQLITE_MISUSE, + "Argument must be a WASM sqlite3 pointer"); + } + return new DB({ + + "sqlite3*": pDb, + "sqlite3*:takeOwnership": !!takeOwnership + }); + }; + + + const affirmStmtOpen = function(stmt){ + if(!stmt.pointer) toss3("Stmt has been closed."); + return stmt; + }; + + + const isSupportedBindType = function(v){ + let t = BindTypes[(null===v||undefined===v) ? 'null' : typeof v]; + switch(t){ + case BindTypes.boolean: + case BindTypes.null: + case BindTypes.number: + case BindTypes.string: + return t; + case BindTypes.bigint: + return wasm.bigIntEnabled ? t : undefined; + default: + return util.isBindableTypedArray(v) ? BindTypes.blob : undefined; + } + }; + + + const affirmSupportedBindType = function(v){ + + return isSupportedBindType(v) || toss3("Unsupported bind() argument type:",typeof v); + }; + + + const affirmParamIndex = function(stmt,key){ + const n = ('number'===typeof key) + ? key : capi.sqlite3_bind_parameter_index(stmt.pointer, key); + if( 0===n || !util.isInt32(n) ) toss3("Invalid bind() parameter name: "+key); + else if( n<1 || n>stmt.parameterCount ) toss3("Bind index",key,"is out of range."); + return n; + }; + + + const __execLock = new Set(); + + const __stmtMayGet = new Set(); + + + const affirmNotLockedByExec = function(stmt,currentOpName){ + if(__execLock.has(stmt)){ + toss3("Operation is illegal when statement is locked:",currentOpName); + } + return stmt; + }; + + + const bindOne = function f(stmt,ndx,bindType,val){ + affirmNotLockedByExec(affirmStmtOpen(stmt), 'bind()'); + if(!f._){ + f._tooBigInt = (v)=>toss3( + "BigInt value is too big to store without precision loss:", v + ); + f._ = { + string: function(stmt, ndx, val, asBlob){ + const [pStr, n] = wasm.allocCString(val, true); + const f = asBlob ? capi.sqlite3_bind_blob : capi.sqlite3_bind_text; + return f(stmt.pointer, ndx, pStr, n, capi.SQLITE_WASM_DEALLOC); + } + }; + } + affirmSupportedBindType(val); + ndx = affirmParamIndex(stmt,ndx); + let rc = 0; + switch((null===val || undefined===val) ? BindTypes.null : bindType){ + case BindTypes.null: + rc = capi.sqlite3_bind_null(stmt.pointer, ndx); + break; + case BindTypes.string: + rc = f._.string(stmt, ndx, val, false); + break; + case BindTypes.number: { + let m; + if(util.isInt32(val)) m = capi.sqlite3_bind_int; + else if('bigint'===typeof val){ + if(!util.bigIntFits64(val)){ + f._tooBigInt(val); + }else if(wasm.bigIntEnabled){ + m = capi.sqlite3_bind_int64; + }else if(util.bigIntFitsDouble(val)){ + val = Number(val); + m = capi.sqlite3_bind_double; + }else{ + f._tooBigInt(val); + } + }else{ + val = Number(val); + if(wasm.bigIntEnabled && Number.isInteger(val)){ + m = capi.sqlite3_bind_int64; + }else{ + m = capi.sqlite3_bind_double; + } + } + rc = m(stmt.pointer, ndx, val); + break; + } + case BindTypes.boolean: + rc = capi.sqlite3_bind_int(stmt.pointer, ndx, val ? 1 : 0); + break; + case BindTypes.blob: { + if('string'===typeof val){ + rc = f._.string(stmt, ndx, val, true); + break; + }else if(val instanceof ArrayBuffer){ + val = new Uint8Array(val); + }else if(!util.isBindableTypedArray(val)){ + toss3("Binding a value as a blob requires", + "that it be a string, Uint8Array, Int8Array, or ArrayBuffer."); + } + const pBlob = wasm.alloc(val.byteLength || 1); + wasm.heap8().set(val.byteLength ? val : [0], Number(pBlob)) + rc = capi.sqlite3_bind_blob(stmt.pointer, ndx, pBlob, val.byteLength, + capi.SQLITE_WASM_DEALLOC); + break; + } + default: + sqlite3.config.warn("Unsupported bind() argument type:",val); + toss3("Unsupported bind() argument type: "+(typeof val)); + } + if(rc) DB.checkRc(stmt.db.pointer, rc); + return stmt; + }; + + Stmt.prototype = { + + finalize: function(){ + const ptr = this.pointer; + if(ptr){ + affirmNotLockedByExec(this,'finalize()'); + const rc = (__doesNotOwnHandle.delete(this) + ? 0 + : capi.sqlite3_finalize(ptr)); + delete __stmtMap.get(this.db)[ptr]; + __ptrMap.delete(this); + __execLock.delete(this); + __stmtMayGet.delete(this); + delete this.parameterCount; + delete this.db; + return rc; + } + }, + + clearBindings: function(){ + affirmNotLockedByExec(affirmStmtOpen(this), 'clearBindings()') + capi.sqlite3_clear_bindings(this.pointer); + __stmtMayGet.delete(this); + return this; + }, + + reset: function(alsoClearBinds){ + affirmNotLockedByExec(this,'reset()'); + if(alsoClearBinds) this.clearBindings(); + const rc = capi.sqlite3_reset(affirmStmtOpen(this).pointer); + __stmtMayGet.delete(this); + checkSqlite3Rc(this.db, rc); + return this; + }, + + + resetNoThrow: function(alsoClearBinds){ + affirmNotLockedByExec(this,'reset()'); + if(alsoClearBinds) this.clearBindings(); + const rc = capi.sqlite3_reset(affirmStmtOpen(this).pointer); + __stmtMayGet.delete(this); + return rc; + }, + + + bind: function(){ + affirmStmtOpen(this); + let ndx, arg; + switch(arguments.length){ + case 1: ndx = 1; arg = arguments[0]; break; + case 2: ndx = arguments[0]; arg = arguments[1]; break; + default: toss3("Invalid bind() arguments."); + } + if(undefined===arg){ + + return this; + }else if(!this.parameterCount){ + toss3("This statement has no bindable parameters."); + } + __stmtMayGet.delete(this); + if(null===arg){ + + return bindOne(this, ndx, BindTypes.null, arg); + } + else if(Array.isArray(arg)){ + + if(1!==arguments.length){ + toss3("When binding an array, an index argument is not permitted."); + } + arg.forEach((v,i)=>bindOne(this, i+1, affirmSupportedBindType(v), v)); + return this; + }else if(arg instanceof ArrayBuffer){ + arg = new Uint8Array(arg); + } + if('object'===typeof arg + && !util.isBindableTypedArray(arg)){ + + if(1!==arguments.length){ + toss3("When binding an object, an index argument is not permitted."); + } + Object.keys(arg) + .forEach(k=>bindOne(this, k, + affirmSupportedBindType(arg[k]), + arg[k])); + return this; + }else{ + return bindOne(this, ndx, affirmSupportedBindType(arg), arg); + } + toss3("Should not reach this point."); + }, + + bindAsBlob: function(ndx,arg){ + affirmStmtOpen(this); + if(1===arguments.length){ + arg = ndx; + ndx = 1; + } + const t = affirmSupportedBindType(arg); + if(BindTypes.string !== t && BindTypes.blob !== t + && BindTypes.null !== t){ + toss3("Invalid value type for bindAsBlob()"); + } + return bindOne(this, ndx, BindTypes.blob, arg); + }, + + step: function(){ + affirmNotLockedByExec(this, 'step()'); + const rc = capi.sqlite3_step(affirmStmtOpen(this).pointer); + switch(rc){ + case capi.SQLITE_DONE: + __stmtMayGet.delete(this); + return false; + case capi.SQLITE_ROW: + __stmtMayGet.add(this); + return true; + default: + __stmtMayGet.delete(this); + sqlite3.config.warn("sqlite3_step() rc=",rc, + capi.sqlite3_js_rc_str(rc), + "SQL =", capi.sqlite3_sql(this.pointer)); + DB.checkRc(this.db.pointer, rc); + } + }, + + stepReset: function(){ + this.step(); + return this.reset(); + }, + + stepFinalize: function(){ + try{ + const rc = this.step(); + this.reset(); + return rc; + }finally{ + try{this.finalize()} + catch(e){} + } + }, + + + get: function(ndx,asType){ + if(!__stmtMayGet.has(affirmStmtOpen(this))){ + toss3("Stmt.step() has not (recently) returned true."); + } + if(Array.isArray(ndx)){ + let i = 0; + const n = this.columnCount; + while(itoss3("The pointer property is read-only.") + } + Object.defineProperty(Stmt.prototype, 'pointer', prop); + Object.defineProperty(DB.prototype, 'pointer', prop); + } + + Object.defineProperty(Stmt.prototype, 'columnCount', { + enumerable: false, + get: function(){return capi.sqlite3_column_count(this.pointer)}, + set: ()=>toss3("The columnCount property is read-only.") + }); + + Object.defineProperty(Stmt.prototype, 'parameterCount', { + enumerable: false, + get: function(){return capi.sqlite3_bind_parameter_count(this.pointer)}, + set: ()=>toss3("The parameterCount property is read-only.") + }); + + + Stmt.wrapHandle = function(oo1db, pStmt, takeOwnership=false){ + let ctor = Stmt; + if( !(oo1db instanceof DB) || !oo1db.pointer ){ + throw new sqlite3.SQLite3Error(sqlite3.SQLITE_MISUSE, + "First argument must be an opened "+ + "sqlite3.oo1.DB instance"); + } + if( !pStmt || !wasm.isPtr(pStmt) ){ + throw new sqlite3.SQLite3Error(sqlite3.SQLITE_MISUSE, + "Second argument must be a WASM "+ + "sqlite3_stmt pointer"); + } + return new Stmt(oo1db, pStmt, BindTypes, !!takeOwnership); + } + + + sqlite3.oo1 = { + DB, + Stmt + }; + +}); + + + +globalThis.sqlite3ApiBootstrap.initializers.push(function(sqlite3){ +const util = sqlite3.util; +sqlite3.initWorker1API = function(){ + 'use strict'; + const toss = (...args)=>{throw new Error(args.join(' '))}; + if(!(globalThis.WorkerGlobalScope instanceof Function)){ + toss("initWorker1API() must be run from a Worker thread."); + } + const sqlite3 = this.sqlite3 || toss("Missing this.sqlite3 object."); + const DB = sqlite3.oo1.DB; + + + const getDbId = function(db){ + let id = wState.idMap.get(db); + if(id) return id; + id = 'db#'+(++wState.idSeq)+':'+ + Math.floor(Math.random() * 100000000)+':'+ + Math.floor(Math.random() * 100000000); + + wState.idMap.set(db, id); + return id; + }; + + + const wState = { + + dbList: [], + + idSeq: 0, + + idMap: new WeakMap, + + xfer: [], + open: function(opt){ + const db = new DB(opt); + this.dbs[getDbId(db)] = db; + if(this.dbList.indexOf(db)<0) this.dbList.push(db); + return db; + }, + close: function(db,alsoUnlink){ + if(db){ + delete this.dbs[getDbId(db)]; + const filename = db.filename; + const pVfs = util.sqlite3__wasm_db_vfs(db.pointer, 0); + db.close(); + const ddNdx = this.dbList.indexOf(db); + if(ddNdx>=0) this.dbList.splice(ddNdx, 1); + if(alsoUnlink && filename && pVfs){ + util.sqlite3__wasm_vfs_unlink(pVfs, filename); + } + } + }, + + post: function(msg,xferList){ + if(xferList && xferList.length){ + globalThis.postMessage( msg, Array.from(xferList) ); + xferList.length = 0; + }else{ + globalThis.postMessage(msg); + } + }, + + dbs: Object.create(null), + + getDb: function(id,require=true){ + return this.dbs[id] + || (require ? toss("Unknown (or closed) DB ID:",id) : undefined); + } + }; + + + const affirmDbOpen = function(db = wState.dbList[0]){ + return (db && db.pointer) ? db : toss("DB is not opened."); + }; + + + const getMsgDb = function(msgData,affirmExists=true){ + const db = wState.getDb(msgData.dbId,false) || wState.dbList[0]; + return affirmExists ? affirmDbOpen(db) : db; + }; + + const getDefaultDbId = function(){ + return wState.dbList[0] && getDbId(wState.dbList[0]); + }; + + const isSpecialDbFilename = (n)=>{ + return ""===n || ':'===n[0]; + }; + + + const wMsgHandler = { + open: function(ev){ + const oargs = Object.create(null), args = (ev.args || Object.create(null)); + if(args.simulateError){ + toss("Throwing because of simulateError flag."); + } + const rc = Object.create(null); + oargs.vfs = args.vfs; + oargs.filename = args.filename || ""; + const db = wState.open(oargs); + rc.filename = db.filename; + rc.persistent = !!sqlite3.capi.sqlite3_js_db_uses_vfs(db.pointer, "opfs"); + rc.dbId = getDbId(db); + rc.vfs = db.dbVfsName(); + return rc; + }, + + close: function(ev){ + const db = getMsgDb(ev,false); + const response = { + filename: db && db.filename + }; + if(db){ + const doUnlink = ((ev.args && 'object'===typeof ev.args) + ? !!ev.args.unlink : false); + wState.close(db, doUnlink); + } + return response; + }, + + exec: function(ev){ + const rc = ( + 'string'===typeof ev.args + ) ? {sql: ev.args} : (ev.args || Object.create(null)); + if('stmt'===rc.rowMode){ + toss("Invalid rowMode for 'exec': stmt mode", + "does not work in the Worker API."); + }else if(!rc.sql){ + toss("'exec' requires input SQL."); + } + const db = getMsgDb(ev); + if(rc.callback || Array.isArray(rc.resultRows)){ + + db._blobXfer = wState.xfer; + } + const theCallback = rc.callback; + let rowNumber = 0; + const hadColNames = !!rc.columnNames; + if('string' === typeof theCallback){ + if(!hadColNames) rc.columnNames = []; + + rc.callback = function(row,stmt){ + wState.post({ + type: theCallback, + columnNames: rc.columnNames, + rowNumber: ++rowNumber, + row: row + }, wState.xfer); + } + } + try { + const changeCount = !!rc.countChanges + ? db.changes(true,(64===rc.countChanges)) + : undefined; + db.exec(rc); + if(undefined !== changeCount){ + rc.changeCount = db.changes(true,64===rc.countChanges) - changeCount; + } + const lastInsertRowId = !!rc.lastInsertRowId + ? sqlite3.capi.sqlite3_last_insert_rowid(db) + : undefined; + if( undefined!==lastInsertRowId ){ + rc.lastInsertRowId = lastInsertRowId; + } + if(rc.callback instanceof Function){ + rc.callback = theCallback; + + wState.post({ + type: theCallback, + columnNames: rc.columnNames, + rowNumber: null , + row: undefined + }); + } + }finally{ + delete db._blobXfer; + if(rc.callback) rc.callback = theCallback; + } + return rc; + }, + + 'config-get': function(){ + const rc = Object.create(null), src = sqlite3.config; + [ + 'bigIntEnabled' + ].forEach(function(k){ + if(Object.getOwnPropertyDescriptor(src, k)) rc[k] = src[k]; + }); + rc.version = sqlite3.version; + rc.vfsList = sqlite3.capi.sqlite3_js_vfs_list(); + return rc; + }, + + + export: function(ev){ + const db = getMsgDb(ev); + const response = { + byteArray: sqlite3.capi.sqlite3_js_db_export(db.pointer), + filename: db.filename, + mimetype: 'application/x-sqlite3' + }; + wState.xfer.push(response.byteArray.buffer); + return response; + }, + + toss: function(ev){ + toss("Testing worker exception"); + } + }; + + globalThis.onmessage = async function(ev){ + ev = ev.data; + let result, dbId = ev.dbId, evType = ev.type; + const arrivalTime = performance.now(); + try { + if(wMsgHandler.hasOwnProperty(evType) && + wMsgHandler[evType] instanceof Function){ + result = await wMsgHandler[evType](ev); + }else{ + toss("Unknown db worker message type:",ev.type); + } + }catch(err){ + evType = 'error'; + result = { + operation: ev.type, + message: err.message, + errorClass: err.name, + input: ev + }; + if(err.stack){ + result.stack = ('string'===typeof err.stack) + ? err.stack.split(/\n\s*/) : err.stack; + } + if(0) sqlite3.config.warn("Worker is propagating an exception to main thread.", + "Reporting it _here_ for the stack trace:",err,result); + } + if(!dbId){ + dbId = result.dbId + || getDefaultDbId(); + } + + + wState.post({ + type: evType, + dbId: dbId, + messageId: ev.messageId, + workerReceivedTime: arrivalTime, + workerRespondTime: performance.now(), + departureTime: ev.departureTime, + + + + + + + result: result + }, wState.xfer); + }; + globalThis.postMessage({type:'sqlite3-api',result:'worker1-ready'}); +}.bind({sqlite3}); +}); + + + +'use strict'; +globalThis.sqlite3ApiBootstrap.initializers.push(function(sqlite3){ + const wasm = sqlite3.wasm, capi = sqlite3.capi, toss = sqlite3.util.toss3; + const vfs = Object.create(null); + sqlite3.vfs = vfs; + + + capi.sqlite3_vfs.prototype.registerVfs = function(asDefault=false){ + if(!(this instanceof sqlite3.capi.sqlite3_vfs)){ + toss("Expecting a sqlite3_vfs-type argument."); + } + const rc = capi.sqlite3_vfs_register(this, asDefault ? 1 : 0); + if(rc){ + toss("sqlite3_vfs_register(",this,") failed with rc",rc); + } + if(this.pointer !== capi.sqlite3_vfs_find(this.$zName)){ + toss("BUG: sqlite3_vfs_find(vfs.$zName) failed for just-installed VFS", + this); + } + return this; + }; + + + vfs.installVfs = function(opt){ + let count = 0; + const propList = ['io','vfs']; + for(const key of propList){ + const o = opt[key]; + if(o){ + ++count; + o.struct.installMethods(o.methods, !!o.applyArgcCheck); + if('vfs'===key){ + if(!o.struct.$zName && 'string'===typeof o.name){ + o.struct.addOnDispose( + o.struct.$zName = wasm.allocCString(o.name) + ); + } + o.struct.registerVfs(!!o.asDefault); + } + } + } + if(!count) toss("Misuse: installVfs() options object requires at least", + "one of:", propList); + return this; + }; +}); + + + +'use strict'; +globalThis.sqlite3ApiBootstrap.initializers.push(function(sqlite3){ + if( !sqlite3.wasm.exports.sqlite3_declare_vtab ){ + return; + } + const wasm = sqlite3.wasm, capi = sqlite3.capi, toss = sqlite3.util.toss3; + const vtab = Object.create(null); + sqlite3.vtab = vtab; + + const sii = capi.sqlite3_index_info; + + sii.prototype.nthConstraint = function(n, asPtr=false){ + if(n<0 || n>=this.$nConstraint) return false; + const ptr = wasm.ptr.add( + this.$aConstraint, + sii.sqlite3_index_constraint.structInfo.sizeof * n + ); + return asPtr ? ptr : new sii.sqlite3_index_constraint(ptr); + }; + + + sii.prototype.nthConstraintUsage = function(n, asPtr=false){ + if(n<0 || n>=this.$nConstraint) return false; + const ptr = wasm.ptr.add( + this.$aConstraintUsage, + sii.sqlite3_index_constraint_usage.structInfo.sizeof * n + ); + return asPtr ? ptr : new sii.sqlite3_index_constraint_usage(ptr); + }; + + + sii.prototype.nthOrderBy = function(n, asPtr=false){ + if(n<0 || n>=this.$nOrderBy) return false; + const ptr = wasm.ptr.add( + this.$aOrderBy, + sii.sqlite3_index_orderby.structInfo.sizeof * n + ); + return asPtr ? ptr : new sii.sqlite3_index_orderby(ptr); + }; + + + const __xWrapFactory = function(methodName,StructType){ + + return function(ptr,removeMapping=false){ + if(0===arguments.length) ptr = new StructType; + if(ptr instanceof StructType){ + + this.set(ptr.pointer, ptr); + return ptr; + }else if(!wasm.isPtr(ptr)){ + sqlite3.SQLite3Error.toss("Invalid argument to",methodName+"()"); + } + let rc = this.get(ptr); + if(removeMapping) this.delete(ptr); + return rc; + }.bind(new Map); + }; + + + const StructPtrMapper = function(name, StructType){ + const __xWrap = __xWrapFactory(name,StructType); + + return Object.assign(Object.create(null),{ + + StructType, + + create: (ppOut)=>{ + const rc = __xWrap(); + wasm.pokePtr(ppOut, rc.pointer); + return rc; + }, + + get: (pCObj)=>__xWrap(pCObj), + + unget: (pCObj)=>__xWrap(pCObj,true), + + dispose: (pCObj)=>__xWrap(pCObj,true)?.dispose?.() + }); + }; + + + vtab.xVtab = StructPtrMapper('xVtab', capi.sqlite3_vtab); + + + vtab.xCursor = StructPtrMapper('xCursor', capi.sqlite3_vtab_cursor); + + + vtab.xIndexInfo = (pIdxInfo)=>new capi.sqlite3_index_info(pIdxInfo); + + + vtab.xError = function f(methodName, err, defaultRc){ + if(f.errorReporter instanceof Function){ + try{f.errorReporter("sqlite3_module::"+methodName+"(): "+err.message);} + catch(e){} + } + let rc; + if(err instanceof sqlite3.WasmAllocError) rc = capi.SQLITE_NOMEM; + else if(arguments.length>2) rc = defaultRc; + else if(err instanceof sqlite3.SQLite3Error) rc = err.resultCode; + return rc || capi.SQLITE_ERROR; + }; + + vtab.xError.errorReporter = 1 ? sqlite3.config.error.bind(sqlite3.config) : false; + + + vtab.xRowid = (ppRowid64, value)=>wasm.poke(ppRowid64, value, 'i64'); + + + vtab.setupModule = function(opt){ + let createdMod = false; + const mod = (this instanceof capi.sqlite3_module) + ? this : (opt.struct || (createdMod = new capi.sqlite3_module())); + try{ + const methods = opt.methods || toss("Missing 'methods' object."); + for(const e of Object.entries({ + + + xConnect: 'xCreate', xDisconnect: 'xDestroy' + })){ + + const k = e[0], v = e[1]; + if(true === methods[k]) methods[k] = methods[v]; + else if(true === methods[v]) methods[v] = methods[k]; + } + if(opt.catchExceptions){ + const fwrap = function(methodName, func){ + if(['xConnect','xCreate'].indexOf(methodName) >= 0){ + return function(pDb, pAux, argc, argv, ppVtab, pzErr){ + try{return func(...arguments) || 0} + catch(e){ + if(!(e instanceof sqlite3.WasmAllocError)){ + wasm.dealloc(wasm.peekPtr(pzErr)); + wasm.pokePtr(pzErr, wasm.allocCString(e.message)); + } + return vtab.xError(methodName, e); + } + }; + }else{ + return function(...args){ + try{return func(...args) || 0} + catch(e){ + return vtab.xError(methodName, e); + } + }; + } + }; + const mnames = [ + 'xCreate', 'xConnect', 'xBestIndex', 'xDisconnect', + 'xDestroy', 'xOpen', 'xClose', 'xFilter', 'xNext', + 'xEof', 'xColumn', 'xRowid', 'xUpdate', + 'xBegin', 'xSync', 'xCommit', 'xRollback', + 'xFindFunction', 'xRename', 'xSavepoint', 'xRelease', + 'xRollbackTo', 'xShadowName' + ]; + const remethods = Object.create(null); + for(const k of mnames){ + const m = methods[k]; + if(!(m instanceof Function)) continue; + else if('xConnect'===k && methods.xCreate===m){ + remethods[k] = methods.xCreate; + }else if('xCreate'===k && methods.xConnect===m){ + remethods[k] = methods.xConnect; + }else{ + remethods[k] = fwrap(k, m); + } + } + mod.installMethods(remethods, false); + }else{ + + + mod.installMethods( + methods, !!opt.applyArgcCheck + ); + } + if(0===mod.$iVersion){ + let v; + if('number'===typeof opt.iVersion) v = opt.iVersion; + else if(mod.$xIntegrity) v = 4; + else if(mod.$xShadowName) v = 3; + else if(mod.$xSavePoint || mod.$xRelease || mod.$xRollbackTo) v = 2; + else v = 1; + mod.$iVersion = v; + } + }catch(e){ + if(createdMod) createdMod.dispose(); + throw e; + } + return mod; + }; + + + capi.sqlite3_module.prototype.setupModule = function(opt){ + return vtab.setupModule.call(this, opt); + }; +}); + + + +globalThis.sqlite3ApiBootstrap.initializers.push(function(sqlite3){ + if( sqlite3.config.disable?.vfs?.kvvfs ){ + return; + } + 'use strict'; + const capi = sqlite3.capi, + sqlite3_kvvfs_methods = capi.sqlite3_kvvfs_methods, + KVVfsFile = capi.KVVfsFile, + pKvvfs = sqlite3.capi.sqlite3_vfs_find("kvvfs") + + + delete capi.sqlite3_kvvfs_methods; + delete capi.KVVfsFile; + + if( !pKvvfs ) return ; + if( 0 ){ + + capi.sqlite3_vfs_register(pKvvfs, 1); + } + + const util = sqlite3.util, + wasm = sqlite3.wasm, + toss3 = util.toss3, + hop = (o,k)=>Object.prototype.hasOwnProperty.call(o,k); + + const kvvfsMethods = new sqlite3_kvvfs_methods( + + wasm.exports.sqlite3__wasm_kvvfs_methods() + ); + util.assert( 32<=kvvfsMethods.$nKeySize, "unexpected kvvfsMethods.$nKeySize: "+kvvfsMethods.$nKeySize); + + + const cache = Object.assign(Object.create(null),{ + + rxJournalSuffix: /-journal$/, + + zKeyJrnl: wasm.allocCString("jrnl"), + + zKeySz: wasm.allocCString("sz"), + + keySize: kvvfsMethods.$nKeySize, + + buffer: Object.assign(Object.create(null),{ + + n: kvvfsMethods.$nBufferSize, + + pool: Object.create(null) + }) + }); + + + cache.memBuffer = (id=0)=>cache.buffer.pool[id] ??= wasm.alloc(cache.buffer.n); + + + cache.memBufferFree = (id)=>{ + const b = cache.buffer.pool[id]; + if( b ){ + wasm.dealloc(b); + delete cache.buffer.pool[id]; + } + }; + + const noop = ()=>{}; + const debug = sqlite3.__isUnderTest + ? (...args)=>sqlite3.config.debug?.("kvvfs:", ...args) + : noop; + const warn = (...args)=>sqlite3.config.warn?.("kvvfs:", ...args); + const error = (...args)=>sqlite3.config.error?.("kvvfs:", ...args); + + + class KVVfsStorage { + #map = Object.create(null); + #keys = null; + #size = 0; + + constructor(){ + this.clear(); + } + + #getKeys(){ + return this.#keys ??= Object.keys(this.#map); + } + + key(n){ + if(n < 0 || n >= this.#size) return null; + return this.#getKeys()[n]; + } + + getItem(k){ + return this.#map[k] ?? null; + } + + setItem(k,v){ + if( !(k in this.#map) ){ + ++this.#size; + this.#keys = null; + } + this.#map[k] = ''+v; + } + + removeItem(k){ + if( k in this.#map ){ + delete this.#map[k]; + --this.#size; + this.#keys = null; + } + } + + clear(){ + this.#map = Object.create(null); + this.#keys = null; + this.#size = 0; + } + + get length() { + return this.#size; + } + }; + + + const kvvfsIsPersistentName = (v)=>'local'===v || 'session'===v; + + + const kvvfsKeyPrefix = (v)=>kvvfsIsPersistentName(v) ? 'kvvfs-'+v+'-' : ''; + + + const validateStorageName = function(n,mayBeJournal=false){ + if( kvvfsIsPersistentName(n) ) return; + const len = (new Blob([n])).size; + if( !len ) toss3(capi.SQLITE_MISUSE, "Empty name is not permitted."); + let maxLen = cache.keySize - 1; + if( cache.rxJournalSuffix.test(n) ){ + if( !mayBeJournal ){ + toss3(capi.SQLITE_MISUSE, + "Storage names may not have a '-journal' suffix."); + } + }else if( ['-wal','-shm'].filter(v=>n.endsWith(v)).length ){ + toss3(capi.SQLITE_MISUSE, + "Storage names may not have a -wal or -shm suffix."); + }else{ + maxLen -= 8 ; + } + if( len > maxLen ){ + toss3(capi.SQLITE_RANGE, "Storage name is too long. Limit =", maxLen); + } + let i; + for( i = 0; i < len; ++i ){ + const ch = n.codePointAt(i); + if( ch<32 ){ + toss3(capi.SQLITE_RANGE, + "Illegal character ("+ch+"d) in storage name:",n); + } + } + }; + + + const newStorageObj = (name,storage=undefined)=>Object.assign(Object.create(null),{ + + jzClass: name, + + refc: 1, + + deleteAtRefc0: false, + + storage: storage || new KVVfsStorage, + + keyPrefix: kvvfsKeyPrefix(name), + + files: [], + + listeners: undefined + }); + + + const kvvfs = sqlite3.kvvfs = Object.create(null); + if( sqlite3.__isUnderTest ){ + + kvvfs.log = Object.assign(Object.create(null),{ + xOpen: false, + xClose: false, + xWrite: false, + xRead: false, + xSync: false, + xAccess: false, + xFileControl: false, + xRcrdRead: false, + xRcrdWrite: false, + xRcrdDelete: false, + }); + } + + + const deleteStorage = function(store){ + const other = cache.rxJournalSuffix.test(store.jzClass) + ? store.jzClass.replace(cache.rxJournalSuffix,'') + : store.jzClass+'-journal'; + kvvfs?.log?.xClose + && debug("cleaning up storage handles [", store.jzClass, other,"]",store); + delete cache.storagePool[store.jzClass]; + delete cache.storagePool[other]; + if( !sqlite3.__isUnderTest ){ + + delete store.storage; + delete store.refc; + } + }; + + + const installStorageAndJournal = (store)=> + cache.storagePool[store.jzClass] = + cache.storagePool[store.jzClass+'-journal'] = store; + + + const nameOfThisThreadStorage = '.'; + + + cache.storagePool = Object.assign(Object.create(null),{ + + [nameOfThisThreadStorage]: newStorageObj(nameOfThisThreadStorage) + }); + + if( globalThis.Storage ){ + + if( globalThis.localStorage instanceof globalThis.Storage ){ + cache.storagePool.local = newStorageObj('local', globalThis.localStorage); + } + if( globalThis.sessionStorage instanceof globalThis.Storage ){ + cache.storagePool.session = newStorageObj('session', globalThis.sessionStorage); + } + } + + cache.builtinStorageNames = Object.keys(cache.storagePool); + + const isBuiltinName = (n)=>cache.builtinStorageNames.indexOf(n)>-1; + + + for(const k of Object.keys(cache.storagePool)){ + + const orig = cache.storagePool[k]; + cache.storagePool[k+'-journal'] = orig; + } + + cache.setError = (e=undefined, dfltErrCode=capi.SQLITE_ERROR)=>{ + if( e ){ + cache.lastError = e; + return (e.resultCode | 0) || dfltErrCode; + } + delete cache.lastError; + return 0; + }; + + cache.popError = ()=>{ + const e = cache.lastError; + delete cache.lastError; + return e; + }; + + + const catchForNotify = (e)=>{ + warn("kvvfs.listener handler threw:",e); + }; + + const kvvfsDecode = wasm.exports.sqlite3__wasm_kvvfs_decode; + const kvvfsEncode = wasm.exports.sqlite3__wasm_kvvfs_encode; + + + const notifyListeners = async function(eventName,store,...args){ + try{ + + if( store.keyPrefix && args[0] ){ + args[0] = args[0].replace(store.keyPrefix,''); + } + let u8enc, z0, z1, wcache; + for(const ear of store.listeners){ + const ev = Object.create(null); + ev.storageName = store.jzClass; + ev.type = eventName; + const decodePages = ear.decodePages; + const f = ear.events[eventName]; + if( f ){ + if( !ear.includeJournal && args[0]==='jrnl' ){ + continue; + } + if( 'write'===eventName && ear.decodePages && +args[0]>0 ){ + + ev.data = [args[0]]; + if( wcache?.[args[0]] ){ + ev.data[1] = wcache[args[0]]; + continue; + } + u8enc ??= new TextEncoder('utf-8'); + z0 ??= cache.memBuffer(10); + z1 ??= cache.memBuffer(11); + const u = u8enc.encode(args[1]); + const heap = wasm.heap8u(); + heap.set(u, Number(z0)); + heap[wasm.ptr.addn(z0, u.length)] = 0; + const rc = kvvfsDecode(z0, z1, cache.buffer.n); + if( rc>0 ){ + wcache ??= Object.create(null); + wcache[args[0]] + = ev.data[1] + = heap.slice(Number(z1), wasm.ptr.addn(z1,rc)); + }else{ + continue; + } + }else{ + ev.data = args.length + ? ((args.length===1) ? args[0] : args) + : undefined; + } + try{f(ev)?.catch?.(catchForNotify)} + catch(e){ + warn("notifyListeners [",store.jzClass,"]",eventName,e); + } + } + } + }catch(e){ + catchForNotify(e); + } + }; + + + const storageForZClass = (zClass)=> + 'string'===typeof zClass + ? cache.storagePool[zClass] + : cache.storagePool[wasm.cstrToJs(zClass)]; + + + const kvvfsMakeKey = wasm.exports.sqlite3__wasm_kvvfsMakeKey; + + const zKeyForStorage = (store, zClass, zKey)=>{ + + return (zClass && store.keyPrefix) ? kvvfsMakeKey(zClass, zKey) : zKey; + }; + + const jsKeyForStorage = (store,zClass,zKey)=> + wasm.cstrToJs(zKeyForStorage(store, zClass, zKey)); + + const storageGetDbSize = (store)=>+store.storage.getItem(store.keyPrefix + "sz"); + + + const pFileHandles = new Map(); + + + const mapPathToLocks = new Map(); + + + const unlockFile = function(fh, lockType){ + if(fh.lockType<=lockType) return; + const lk = mapPathToLocks.get(fh.jzClass); + if(fh.lockType>capi.SQLITE_LOCK_SHARED){ + lk.lockType = capi.SQLITE_LOCK_SHARED; + } + if(lockType===capi.SQLITE_LOCK_NONE && 0===--lk.nShared + ){ + mapPathToLocks.delete(fh.jzClass); + } + fh.lockType = lockType; + }; + + + const originalMethods = { + vfs: Object.create(null), + ioDb: Object.create(null), + ioJrnl: Object.create(null) + }; + + + const originalIoMethods = (kvvfsFile)=> + originalMethods[kvvfsFile.$isJournal ? 'ioJrnl' : 'ioDb']; + + const pVfs = new capi.sqlite3_vfs(kvvfsMethods.$pVfs); + const pIoDb = new capi.sqlite3_io_methods(kvvfsMethods.$pIoDb); + const pIoJrnl = new capi.sqlite3_io_methods(kvvfsMethods.$pIoJrnl); + const recordHandler = + Object.create(null); + const kvvfsInternal = Object.assign(Object.create(null),{ + pFileHandles, + cache, + storageForZClass, + KVVfsStorage, + + disablePageSizeChange: true + }); + if( kvvfs.log ){ + + kvvfs.internal = kvvfsInternal; + } + + + const methodOverrides = { + + + recordHandler: { + xRcrdRead: (zClass, zKey, zBuf, nBuf)=>{ + try{ + const jzClass = wasm.cstrToJs(zClass); + const store = storageForZClass(jzClass); + if( !store ) return -1; + const jXKey = jsKeyForStorage(store, zClass, zKey); + kvvfs?.log?.xRcrdRead && warn("xRcrdRead", jzClass, jXKey, nBuf, store ); + const jV = store.storage.getItem(jXKey); + if(null===jV) return -1; + const nV = jV.length ; + if( 0 ){ + debug("xRcrdRead", jXKey, store, jV); + } + if(nBuf<=0) return nV; + else if(1===nBuf){ + wasm.poke(zBuf, 0); + return nV; + }else if( nBuf nV + 1 ){ + nBuf = nV + 1; + } + if( 0 ){ + debug("xRcrdRead", nBuf, zClass, wasm.cstrToJs(zClass), + wasm.cstrToJs(zKey), nV, jV, store); + } + const nCopy = nV{ + try { + const store = storageForZClass(zClass); + const jxKey = jsKeyForStorage(store, zClass, zKey); + const jData = wasm.cstrToJs(zData); + kvvfs?.log?.xRcrdWrite && warn("xRcrdWrite",jxKey, store); + store.storage.setItem(jxKey, jData); + store.listeners && notifyListeners('write', store, jxKey, jData); + return 0; + }catch(e){ + error("kvrecordWrite()",e); + return cache.setError(e, capi.SQLITE_IOERR); + } + }, + + xRcrdDelete: (zClass, zKey)=>{ + try { + const store = storageForZClass(zClass); + const jxKey = jsKeyForStorage(store, zClass, zKey); + kvvfs?.log?.xRcrdDelete && warn("xRcrdDelete",jxKey, store); + store.storage.removeItem(jxKey); + store.listeners && notifyListeners('delete', store, jxKey); + return 0; + }catch(e){ + error("kvrecordDelete()",e); + return cache.setError(e, capi.SQLITE_IOERR); + } + } + }, + + + vfs:{ + + xOpen: function(pProtoVfs,zName,pProtoFile,flags,pOutFlags){ + cache.popError(); + let zToFree ; + try{ + if( !zName ){ + zToFree = wasm.allocCString(""+pProtoFile+"." + +(Math.random() * 100000 | 0)); + zName = zToFree; + } + const jzClass = wasm.cstrToJs(zName); + kvvfs?.log?.xOpen && debug("xOpen",jzClass,"flags =",flags); + validateStorageName(jzClass, true); + if( (flags & (capi.SQLITE_OPEN_MAIN_DB + | capi.SQLITE_OPEN_TEMP_DB + | capi.SQLITE_OPEN_TRANSIENT_DB)) + && cache.rxJournalSuffix.test(jzClass) ){ + toss3(capi.SQLITE_ERROR, + "DB files may not have a '-journal' suffix."); + } + let s = storageForZClass(jzClass); + if( !s && !(flags & capi.SQLITE_OPEN_CREATE) ){ + toss3(capi.SQLITE_ERROR, "Storage not found:", jzClass); + } + const rc = originalMethods.vfs.xOpen(pProtoVfs, zName, pProtoFile, + flags, pOutFlags); + if( rc ) return rc; + let deleteAt0 = !!(capi.SQLITE_OPEN_DELETEONCLOSE & flags); + if(wasm.isPtr(arguments[1])){ + if(capi.sqlite3_uri_boolean(zName, "delete-on-close", 0)){ + deleteAt0 = true; + } + } + const f = new KVVfsFile(pProtoFile); + util.assert(f.$zClass, "Missing f.$zClass"); + f.addOnDispose(zToFree); + zToFree = undefined; + + if( s ){ + ++s.refc; + + s.files.push(f); + wasm.poke32(pOutFlags, flags); + }else{ + wasm.poke32(pOutFlags, flags | capi.SQLITE_OPEN_CREATE); + util.assert( !f.$isJournal, "Opening a journal before its db? "+jzClass ); + + const nm = jzClass.replace(cache.rxJournalSuffix,''); + s = newStorageObj(nm); + installStorageAndJournal(s); + s.files.push(f); + s.deleteAtRefc0 = deleteAt0; + kvvfs?.log?.xOpen + && debug("xOpen installed storage handle [",nm, nm+"-journal","]", s); + } + pFileHandles.set(pProtoFile, Object.assign(Object.create(null),{ + store: s, + file: f, + jzClass, + lockType: 0 + })); + s.listeners && notifyListeners('open', s, s.files.length); + return 0; + }catch(e){ + warn("xOpen:",e); + return cache.setError(e); + }finally{ + zToFree && wasm.dealloc(zToFree); + } + }, + + xDelete: function(pVfs, zName, iSyncFlag){ + cache.popError(); + try{ + const jzName = wasm.cstrToJs(zName); + if( cache.rxJournalSuffix.test(jzName) ){ + recordHandler.xRcrdDelete(zName, cache.zKeyJrnl); + } + return 0; + }catch(e){ + warn("xDelete",e); + return cache.setError(e); + } + }, + + xAccess: function(pProtoVfs, zPath, flags, pResOut){ + cache.popError(); + try{ + const s = storageForZClass(zPath); + const jzPath = s?.jzClass || wasm.cstrToJs(zPath); + if( kvvfs?.log?.xAccess ){ + debug("xAccess",jzPath,"flags =", + flags,"*pResOut =",wasm.peek32(pResOut), + "store =",s); + } + if( !s ){ + + + + try{validateStorageName(jzPath)} + catch(e){ + + wasm.poke32(pResOut, 0); + return 0; + } + } + if( s ){ + const key = s.keyPrefix+ + (cache.rxJournalSuffix.test(jzPath) ? "jrnl" : "1"); + const res = s.storage.getItem(key) ? 0 : 1; + + + wasm.poke32(pResOut, res); + }else{ + wasm.poke32(pResOut, 0); + } + return 0; + }catch(e){ + error('xAccess',e); + return cache.setError(e); + } + }, + + xRandomness: function(pVfs, nOut, pOut){ + const heap = wasm.heap8u(); + let i = 0; + const npOut = Number(pOut); + for(; i < nOut; ++i) heap[npOut + i] = (Math.random()*255000) & 0xFF; + return nOut; + }, + + xGetLastError: function(pVfs,nOut,pOut){ + const e = cache.popError(); + debug('xGetLastError',e); + if(e){ + const scope = wasm.scopedAllocPush(); + try{ + const [cMsg, n] = wasm.scopedAllocCString(e.message, true); + wasm.cstrncpy(pOut, cMsg, nOut); + if(n > nOut) wasm.poke8(wasm.ptr.add(pOut,nOut,-1), 0); + debug("set xGetLastError",e.message); + return (e.resultCode | 0) || capi.SQLITE_IOERR; + }catch(e){ + return capi.SQLITE_NOMEM; + }finally{ + wasm.scopedAllocPop(scope); + } + } + return 0; + }, + + + xSleep: (pVfs,ms)=>0, + + }, + + + ioDb:{ + + xClose: function(pFile){ + cache.popError(); + try{ + const h = pFileHandles.get(pFile); + kvvfs?.log?.xClose && debug("xClose", pFile, h); + if( h ){ + pFileHandles.delete(pFile); + unlockFile(h, capi.SQLITE_LOCK_NONE); + const s = h.store; + s.files = s.files.filter((v)=>v!==h.file); + if( --s.refc<=0 && s.deleteAtRefc0 ){ + deleteStorage(s); + } + originalMethods.ioDb.xClose(pFile); + h.file.dispose(); + s.listeners && notifyListeners('close', s, s.files.length); + }else{ + + } + return 0; + }catch(e){ + error("xClose",e); + return cache.setError(e); + } + }, + + xFileControl: function(pFile, opId, pArg){ + cache.popError(); + try{ + const h = pFileHandles.get(pFile); + util.assert(h, "Missing KVVfsFile handle"); + kvvfs?.log?.xFileControl && debug("xFileControl",h,'op =',opId); + if( opId===capi.SQLITE_FCNTL_PRAGMA + && kvvfsInternal.disablePageSizeChange ){ + + + const zName = wasm.peekPtr(wasm.ptr.add(pArg, wasm.ptr.size)); + if( "page_size"===wasm.cstrToJs(zName) ){ + kvvfs?.log?.xFileControl + && debug("xFileControl pragma",wasm.cstrToJs(zName)); + const zVal = wasm.peekPtr(wasm.ptr.add(pArg, 2*wasm.ptr.size)); + if( zVal ){ + + kvvfs?.log?.xFileControl + && warn("xFileControl pragma", h, + "NOT setting page size to", wasm.cstrToJs(zVal)); + h.file.$szPage = -1; + return 0; + }else if( h.file.$szPage>0 ){ + kvvfs?.log?.xFileControl && + warn("xFileControl", h, "getting page size",h.file.$szPage); + wasm.pokePtr(pArg, wasm.allocCString(""+h.file.$szPage) + ); + return 0; + } + } + } + const rc = originalMethods.ioDb.xFileControl(pFile, opId, pArg); + if( 0==rc && capi.SQLITE_FCNTL_SYNC===opId ){ + h.store.listeners && notifyListeners('sync', h.store, false); + } + return rc; + }catch(e){ + error("xFileControl",e); + return cache.setError(e); + } + }, + + xSync: function(pFile,flags){ + cache.popError(); + try{ + const h = pFileHandles.get(pFile); + kvvfs?.log?.xSync && debug("xSync", h); + util.assert(h, "Missing KVVfsFile handle"); + const rc = originalMethods.ioDb.xSync(pFile, flags); + if( 0==rc && h.store.listeners ) notifyListeners('sync', h.store, true); + return rc; + }catch(e){ + error("xSync",e); + return cache.setError(e); + } + }, + + xLock: function(pFile, iLock){ + cache.popError(); + try{ + const h = pFileHandles.get(pFile); + if( capi.SQLITE_LOCK_NONE!==iLock ){ + + originalMethods.ioDb.xLock(pFile, iLock); + } + + if(h.lockType>=iLock) return 0; + let lk = mapPathToLocks.get(h.jzClass); + if(!lk){ + lk = Object.assign(Object.create(null),{ + nShared: 0, lockType: capi.SQLITE_LOCK_NONE + }); + mapPathToLocks.set(h.jzClass, lk); + } + if(h.lockType!==lk.lockType + && (lk.lockType>=capi.SQLITE_LOCK_PENDING + || iLock>capi.SQLITE_LOCK_SHARED)){ + return capi.SQLITE_BUSY; + } + if(iLock===capi.SQLITE_LOCK_SHARED){ + ++lk.nShared; + if(lk.lockType===capi.SQLITE_LOCK_NONE) lk.lockType = iLock; + }else if(iLock===capi.SQLITE_LOCK_EXCLUSIVE && lk.nShared>1){ + if(h.lockType===capi.SQLITE_LOCK_RESERVED){ + h.lockType = lk.lockType = capi.SQLITE_LOCK_PENDING; + } + return capi.SQLITE_BUSY; + }else{ + lk.lockType = iLock; + } + h.lockType = iLock; + return 0; + }catch(e){ + error("xLock", e); + return cache.setError(e); + } + }, + + xUnlock: function(pFile, iLock){ + cache.popError(); + try{ + if( capi.SQLITE_LOCK_NONE===iLock ){ + + originalMethods.ioDb.xUnlock(pFile, iLock); + } + const h = pFileHandles.get(pFile); + unlockFile(h, iLock); + return 0; + }catch(e){ + error("xUnlock", e); + return cache.setError(e); + } + }, + + xCheckReservedLock: function(pFile, pOut){ + cache.popError(); + try{ + const h = pFileHandles.get(pFile); + const lk = mapPathToLocks.get(h.jzClass); + wasm.poke32(pOut, + (!!lk && lk.lockType>capi.SQLITE_LOCK_SHARED) + ? 1 : 0) + return 0; + }catch(e){ + error("xCheckReservedLock", e); + return cache.setError(e); + } + }, + + + }, + + ioJrnl:{ + + xClose: true, + xLock: true, + xUnlock: true, + xCheckReservedLock: true, + } + }; + + + try { + util.assert( cache.buffer.n>1024*129, "Heap buffer is not large enough" + ); + for(const e of Object.entries(methodOverrides.recordHandler)){ + + const k = e[0], f = e[1]; + recordHandler[k] = f; + if( 0 ){ + + kvvfsMethods.installMethod(k, f); + }else{ + kvvfsMethods[kvvfsMethods.memberKey(k)] = + wasm.installFunction(kvvfsMethods.memberSignature(k), f); + } + } + for(const e of Object.entries(methodOverrides.vfs)){ + + const k = e[0], f = e[1], km = pVfs.memberKey(k), + member = pVfs.structInfo.members[k] + || util.toss("Missing pVfs.structInfo[",k,"]"); + originalMethods.vfs[k] = wasm.functionEntry(pVfs[km]); + pVfs[km] = wasm.installFunction(member.signature, f); + } + for(const e of Object.entries(methodOverrides.ioDb)){ + + const k = e[0], f = e[1], km = pIoDb.memberKey(k); + originalMethods.ioDb[k] = wasm.functionEntry(pIoDb[km]) + || util.toss("Missing native pIoDb[",km,"]"); + pIoDb[km] = wasm.installFunction(pIoDb.memberSignature(k), f); + } + for(const e of Object.entries(methodOverrides.ioJrnl)){ + + const k = e[0], f = e[1], km = pIoJrnl.memberKey(k); + originalMethods.ioJrnl[k] = wasm.functionEntry(pIoJrnl[km]) + || util.toss("Missing native pIoJrnl[",km,"]"); + if( true===f ){ + + pIoJrnl[km] = pIoDb[km] || util.toss("Missing copied pIoDb[",km,"]"); + }else{ + pIoJrnl[km] = wasm.installFunction(pIoJrnl.memberSignature(k), f); + } + } + }finally{ + kvvfsMethods.dispose(); + pVfs.dispose(); + pIoDb.dispose(); + pIoJrnl.dispose(); + } + + + + + const sqlite3_js_kvvfs_clear = function callee(which){ + if( ''===which ){ + return callee('local') + callee('session'); + } + const store = storageForZClass(which); + if( !store ) return 0; + if( store.files.length ){ + if( globalThis.localStorage===store.storage + || globalThis.sessionStorage===store.storage ){ + + }else{ + + toss3(capi.SQLITE_ACCESS, + "Cannot clear in-use database storage."); + } + } + const s = store.storage; + const toRm = [] ; + let i, n = s.length; + + for( i = 0; i < n; ++i ){ + const k = s.key(i); + + if(!store.keyPrefix || k.startsWith(store.keyPrefix)) toRm.push(k); + } + toRm.forEach((kk)=>s.removeItem(kk)); + + return toRm.length; + }; + + + const sqlite3_js_kvvfs_size = function callee(which){ + if( ''===which ){ + return callee('local') + callee('session'); + } + const store = storageForZClass(which); + if( !store ) return 0; + const s = store.storage; + let i, sz = 0; + for(i = 0; i < s.length; ++i){ + const k = s.key(i); + if(!store.keyPrefix || k.startsWith(store.keyPrefix)){ + sz += k.length; + sz += s.getItem(k).length; + } + } + return sz * 2 ; + }; + + + const sqlite3_js_kvvfs_export = function callee(...args){ + let opt; + if( 1===args.length && 'object'===typeof args[0] ){ + opt = args[0]; + }else if(args.length){ + opt = Object.assign(Object.create(null),{ + name: args[0], + + }); + } + const store = opt ? storageForZClass(opt.name) : null; + if( !store ){ + toss3(capi.SQLITE_NOTFOUND, + "There is no kvvfs storage named",opt?.name); + } + + const s = store.storage; + const rc = Object.assign(Object.create(null),{ + name: store.jzClass, + timestamp: Date.now(), + pages: [] + }); + const pages = Object.create(null); + let xpages; + const keyPrefix = store.keyPrefix; + const rxTail = keyPrefix + ? /^kvvfs-[^-]+-(\w+)/ + : undefined; + let i = 0, n = s.length; + for( ; i < n; ++i ){ + const k = s.key(i); + if( !keyPrefix || k.startsWith(keyPrefix) ){ + let kk = (keyPrefix ? rxTail.exec(k) : undefined)?.[1] ?? k; + switch( kk ){ + case 'jrnl': + if( opt.includeJournal ) rc.journal = s.getItem(k); + break; + case 'sz': + rc.size = +s.getItem(k); + break; + default: + kk = +kk ; + if( !util.isInt32(kk) || kk<=0 ){ + toss3(capi.SQLITE_RANGE, "Malformed kvvfs key: "+k); + } + if( opt.decodePages ){ + const spg = s.getItem(k), + n = spg.length, + z = cache.memBuffer(0), + zDec = cache.memBuffer(1), + heap = wasm.heap8u(); + let i = 0; + for( ; i < n; ++i ){ + heap[wasm.ptr.add(z, i)] = spg.codePointAt(i) & 0xff; + } + heap[wasm.ptr.add(z, i)] = 0; + + const nDec = kvvfsDecode( + z, zDec, cache.buffer.n + ); + + pages[kk] = heap.slice(Number(zDec), wasm.ptr.addn(zDec, nDec)); + }else{ + pages[kk] = s.getItem(k); + } + break; + } + } + } + if( opt.decodePages ) cache.memBufferFree(1); + + Object.keys(pages).map((v)=>+v).sort().forEach( + (v)=>rc.pages.push(pages[v]) + ); + return rc; + }; + + + const sqlite3_js_kvvfs_import = function(exp, overwrite=false){ + if( !exp?.timestamp + || !exp.name + || undefined===exp.size + || !Array.isArray(exp.pages) ){ + toss3(capi.SQLITE_MISUSE, "Malformed export object."); + }else if( !exp.size + || (exp.size !== (exp.size | 0)) + + || exp.size>=0x7fffffff ){ + toss3(capi.SQLITE_RANGE, "Invalid db size: "+exp.size); + } + + validateStorageName(exp.name); + let store = storageForZClass(exp.name); + const isNew = !store; + if( store ){ + if( !overwrite ){ + + toss3(capi.SQLITE_ACCESS, + "Storage '"+exp.name+"' already exists and", + "overwrite was not specified."); + }else if( !store.files || !store.jzClass ){ + toss3(capi.SQLITE_ERROR, + "Internal storage object", exp.name,"seems to be malformed."); + }else if( store.files.length ){ + toss3(capi.SQLITE_IOERR_ACCESS, + "Cannot import db storage while it is in use."); + } + sqlite3_js_kvvfs_clear(exp.name); + }else{ + store = newStorageObj(exp.name); + + } + + + const keyPrefix = kvvfsKeyPrefix(exp.name); + let zEnc; + try{ + ; + const s = store.storage; + s.setItem(keyPrefix+'sz', exp.size); + if( exp.journal ) s.setItem(keyPrefix+'jrnl', exp.journal); + if( exp.pages[0] instanceof Uint8Array ){ + + + exp.pages.forEach((u,ndx)=>{ + const n = u.length; + if( 0 && cache.fixedPageSize !== n ){ + util.toss3(capi.SQLITE_RANGE,"Unexpected page size:", n); + } + zEnc ??= cache.memBuffer(1); + const zBin = cache.memBuffer(0), + heap = wasm.heap8u(); + + heap.set(u, Number(zBin)); + heap[wasm.ptr.addn(zBin,n)] = 0; + const rc = kvvfsEncode(zBin, n, zEnc); + util.assert( rc < cache.buffer.n, + "Impossibly long output - possibly smashed the heap" ); + util.assert( 0===wasm.peek8(wasm.ptr.add(zEnc,rc)), + "Expecting NUL-terminated encoded output" ); + const jenc = wasm.cstrToJs(zEnc); + + s.setItem(keyPrefix+(ndx+1), jenc); + }); + }else if( exp.pages[0] ){ + + exp.pages.forEach((v,ndx)=>s.setItem(keyPrefix+(ndx+1), v)); + } + if( isNew ) installStorageAndJournal(store); + }catch{ + if( !isNew ){ + try{sqlite3_js_kvvfs_clear(exp.name);}catch(ee){} + } + }finally{ + if( zEnc ) cache.memBufferFree(1); + } + return this; + }; + + + const sqlite3_js_kvvfs_reserve = function(name){ + let store = storageForZClass(name); + if( store ){ + ++store.refc; + return; + } + validateStorageName(name); + installStorageAndJournal(newStorageObj(name)); + }; + + + const sqlite3_js_kvvfs_unlink = function(name){ + const store = storageForZClass(name); + if( !store + || kvvfsIsPersistentName(store.jzClass) + || isBuiltinName(store.jzClass) + || cache.rxJournalSuffix.test(name) ) return false; + if( store.refc > store.files.length || 0===store.files.length ){ + if( --store.refc<=0 ){ + + deleteStorage(store); + } + return true; + } + return false; + }; + + + const sqlite3_js_kvvfs_listen = function(opt){ + if( !opt || 'object'!==typeof opt ){ + toss3(capi.SQLITE_MISUSE, "Expecting a listener object."); + } + let store = storageForZClass(opt.storage); + if( !store ){ + if( opt.storage && opt.reserve ){ + sqlite3_js_kvvfs_reserve(opt.storage); + store = storageForZClass(opt.storage); + util.assert(store, + "Unexpectedly cannot fetch reserved storage " + +opt.storage); + }else{ + toss3(capi.SQLITE_NOTFOUND,"No such storage:",opt.storage); + } + } + if( opt.events ){ + (store.listeners ??= []).push(opt); + } + }; + + + const sqlite3_js_kvvfs_unlisten = function(opt){ + const store = storageForZClass(opt?.storage); + if( store?.listeners && opt.events ){ + const n = store.listeners.length; + store.listeners = store.listeners.filter((v)=>v!==opt); + const rc = n>store.listeners.length; + if( !store.listeners.length ){ + + store.listeners = undefined; + } + return rc; + } + return false; + }; + + sqlite3.kvvfs.reserve = sqlite3_js_kvvfs_reserve; + sqlite3.kvvfs.import = sqlite3_js_kvvfs_import; + sqlite3.kvvfs.export = sqlite3_js_kvvfs_export; + sqlite3.kvvfs.unlink = sqlite3_js_kvvfs_unlink; + sqlite3.kvvfs.listen = sqlite3_js_kvvfs_listen; + sqlite3.kvvfs.unlisten = sqlite3_js_kvvfs_unlisten; + sqlite3.kvvfs.exists = (name)=>!!storageForZClass(name); + sqlite3.kvvfs.estimateSize = sqlite3_js_kvvfs_size; + sqlite3.kvvfs.clear = sqlite3_js_kvvfs_clear; + + + if( globalThis.Storage ){ + + capi.sqlite3_js_kvvfs_size = (which="")=>sqlite3_js_kvvfs_size(which); + capi.sqlite3_js_kvvfs_clear = (which="")=>sqlite3_js_kvvfs_clear(which); + } + + if(sqlite3.oo1?.DB){ + + const DB = sqlite3.oo1.DB; + sqlite3.oo1.JsStorageDb = function( + storageName = sqlite3.oo1.JsStorageDb.defaultStorageName + ){ + const opt = DB.dbCtorHelper.normalizeArgs(...arguments); + opt.vfs = 'kvvfs'; + if( 0 ){ + + if( opt.flags ) opt.flags = 'cw'+opt.flags; + else opt.flags = 'cw'; + } + switch( opt.filename ){ + + case ":sessionStorage:": opt.filename = 'session'; break; + case ":localStorage:": opt.filename = 'local'; break; + } + const m = /(file:(\/\/)?)([^?]+)/.exec(opt.filename); + validateStorageName( m ? m[3] : opt.filename); + DB.dbCtorHelper.call(this, opt); + }; + sqlite3.oo1.JsStorageDb.defaultStorageName + = cache.storagePool.session ? 'session' : nameOfThisThreadStorage; + const jdb = sqlite3.oo1.JsStorageDb; + jdb.prototype = Object.create(DB.prototype); + jdb.clearStorage = sqlite3_js_kvvfs_clear; + + jdb.prototype.clearStorage = function(){ + return jdb.clearStorage(this.affirmOpen().dbFilename(), true); + }; + + jdb.storageSize = sqlite3_js_kvvfs_size; + + jdb.prototype.storageSize = function(){ + return jdb.storageSize(this.affirmOpen().dbFilename(), true); + }; + } + + if( sqlite3.__isUnderTest && sqlite3.vtab ){ + + const cols = Object.assign(Object.create(null),{ + rowid: {type: 'INTEGER'}, + name: {type: 'TEXT'}, + nRef: {type: 'INTEGER'}, + nOpen: {type: 'INTEGER'}, + isTransient: {type: 'INTEGER'}, + dbSize: {type: 'INTEGER'} + }); + Object.keys(cols).forEach((v,i)=>cols[v].colId = i); + + const VT = sqlite3.vtab; + const ProtoCursor = Object.assign(Object.create(null),{ + row: function(){ + return cache.storagePool[this.names[this.rowid]]; + } + }); + Object.assign(Object.create(ProtoCursor),{ + rowid: 0, + names: Object.keys(cache.storagePool) + .filter(v=>!cache.rxJournalSuffix.test(v)) + }); + const cursorState = function(cursor, reset){ + const o = (cursor instanceof capi.sqlite3_vtab_cursor) + ? cursor + : VT.xCursor.get(cursor); + if( reset || !o.vTabState ){ + o.vTabState = Object.assign(Object.create(ProtoCursor),{ + rowid: 0, + names: Object.keys(cache.storagePool) + .filter(v=>!cache.rxJournalSuffix.test(v)) + }); + } + return o.vTabState; + }; + + const dbg = 1 ? ()=>{} : (...args)=>debug("vtab",...args); + + const theModule = function f(){ + return f.mod ??= new sqlite3.capi.sqlite3_module().setupModule({ + catchExceptions: true, + methods: { + xConnect: function(pDb, pAux, argc, argv, ppVtab, pzErr){ + dbg("xConnect"); + try{ + const xcol = []; + Object.keys(cols).forEach((k)=>{ + xcol.push(k+" "+cols[k].type); + }); + const rc = capi.sqlite3_declare_vtab( + pDb, "CREATE TABLE ignored("+xcol.join(',')+")" + ); + if(0===rc){ + const t = VT.xVtab.create(ppVtab); + util.assert( + (t === VT.xVtab.get(wasm.peekPtr(ppVtab))), + "output pointer check failed" + ); + } + return rc; + }catch(e){ + return VT.xError('xConnect', e, capi.SQLITE_ERROR); + } + }, + xCreate: wasm.ptr.null, + + xDisconnect: function(pVtab){ + dbg("xDisconnect",...arguments); + VT.xVtab.dispose(pVtab); + return 0; + }, + xOpen: function(pVtab, ppCursor){ + dbg("xOpen",...arguments); + VT.xCursor.create(ppCursor); + return 0; + }, + xClose: function(pCursor){ + dbg("xClose",...arguments); + const c = VT.xCursor.unget(pCursor); + delete c.vTabState; + c.dispose(); + return 0; + }, + xNext: function(pCursor){ + dbg("xNext",...arguments); + const c = VT.xCursor.get(pCursor); + ++cursorState(c).rowid; + return 0; + }, + xColumn: function(pCursor, pCtx, iCol){ + dbg("xColumn",...arguments); + + const st = cursorState(pCursor); + const store = st.row(); + util.assert(store, "Unexpected xColumn call"); + switch(iCol){ + case cols.rowid.colId: + capi.sqlite3_result_int(pCtx, st.rowid); + break; + case cols.name.colId: + capi.sqlite3_result_text(pCtx, store.jzClass, -1, capi.SQLITE_TRANSIENT); + break; + case cols.nRef.colId: + capi.sqlite3_result_int(pCtx, store.refc); + break; + case cols.nOpen.colId: + capi.sqlite3_result_int(pCtx, store.files.length); + break; + case cols.isTransient.colId: + capi.sqlite3_result_int(pCtx, !!store.deleteAtRefc0); + break; + case cols.dbSize.colId: + capi.sqlite3_result_int(pCtx, storageGetDbSize(store)); + break; + default: + capi.sqlite3_result_error(pCtx, "Invalid column id: "+iCol); + return capi.SQLITE_RANGE; + } + return 0; + }, + xRowid: function(pCursor, ppRowid64){ + dbg("xRowid",...arguments); + const st = cursorState(pCursor); + VT.xRowid(ppRowid64, st.rowid); + return 0; + }, + xEof: function(pCursor){ + const st = cursorState(pCursor); + dbg("xEof?="+(!st.row()),...arguments); + return !st.row(); + }, + xFilter: function(pCursor, idxNum, idxCStr, + argc, argv){ + dbg("xFilter",...arguments); + const st = cursorState(pCursor, true); + return 0; + }, + xBestIndex: function(pVtab, pIdxInfo){ + dbg("xBestIndex",...arguments); + + const pii = new capi.sqlite3_index_info(pIdxInfo); + pii.$estimatedRows = cache.storagePool.size; + pii.$estimatedCost = 1.0; + pii.dispose(); + return 0; + } + } + }); + }; + + sqlite3.kvvfs.create_module = function(pDb, name="sqlite_kvvfs"){ + return capi.sqlite3_create_module(pDb, name, theModule(), + wasm.ptr.null); + }; + + } + + +}); + +globalThis.sqlite3ApiBootstrap.initializers.push(function(sqlite3){ + 'use strict'; + if( sqlite3.config.disable?.vfs?.opfs && + sqlite3.config.disable.vfs['opfs-vfs'] ){ + return; + } + const toss = sqlite3.util.toss, + capi = sqlite3.capi, + util = sqlite3.util, + wasm = sqlite3.wasm; + + + const opfsUtil = sqlite3.opfs = Object.create(null); + + + opfsUtil.thisThreadHasOPFS = ()=>{ + return globalThis.FileSystemHandle && + globalThis.FileSystemDirectoryHandle && + globalThis.FileSystemFileHandle && + globalThis.FileSystemFileHandle.prototype.createSyncAccessHandle && + navigator?.storage?.getDirectory; + }; + + + opfsUtil.getRootDir = async function f(){ + return f.promise ??= navigator.storage.getDirectory().then(d=>{ + opfsUtil.rootDirectory = d; + return d; + }).catch(e=>{ + delete f.promise; + throw e; + }); + }; + + + opfsUtil.getResolvedPath = function(filename,splitIt){ + const p = new URL(filename, "file://irrelevant").pathname; + return splitIt ? p.split('/').filter((v)=>!!v) : p; + }; + + + opfsUtil.getDirForFilename = async function f(absFilename, createDirs = false){ + const path = opfsUtil.getResolvedPath(absFilename, true); + const filename = path.pop(); + let dh = await opfsUtil.getRootDir(); + for(const dirName of path){ + if(dirName){ + dh = await dh.getDirectoryHandle(dirName, {create: !!createDirs}); + } + } + return [dh, filename]; + }; + + + opfsUtil.mkdir = async function(absDirName){ + try { + await opfsUtil.getDirForFilename(absDirName+"/filepart", true); + return true; + }catch(e){ + + return false; + } + }; + + + opfsUtil.entryExists = async function(fsEntryName){ + try { + const [dh, fn] = await opfsUtil.getDirForFilename(fsEntryName); + await dh.getFileHandle(fn); + return true; + }catch(e){ + return false; + } + }; + + + opfsUtil.randomFilename = function f(len=16){ + if(!f._chars){ + f._chars = "abcdefghijklmnopqrstuvwxyz"+ + "ABCDEFGHIJKLMNOPQRSTUVWXYZ"+ + "012346789"; + f._n = f._chars.length; + } + const a = []; + let i = 0; + for( ; i < len; ++i){ + const ndx = Math.random() * (f._n * 64) % f._n | 0; + a[i] = f._chars[ndx]; + } + return a.join(""); + + }; + + + opfsUtil.treeList = async function(){ + const doDir = async function callee(dirHandle,tgt){ + tgt.name = dirHandle.name; + tgt.dirs = []; + tgt.files = []; + for await (const handle of dirHandle.values()){ + if('directory' === handle.kind){ + const subDir = Object.create(null); + tgt.dirs.push(subDir); + await callee(handle, subDir); + }else{ + tgt.files.push(handle.name); + } + } + }; + const root = Object.create(null); + const dir = await opfsUtil.getRootDir(); + await doDir(dir, root); + return root; + }; + + + opfsUtil.rmfr = async function(){ + const rd = await opfsUtil.getRootDir(); + const dir = rd, opt = {recurse: true}; + for await (const handle of dir.values()){ + dir.removeEntry(handle.name, opt); + } + }; + + + opfsUtil.unlink = async function(fsEntryName, recursive = false, + throwOnError = false){ + try { + const [hDir, filenamePart] = + await opfsUtil.getDirForFilename(fsEntryName, false); + await hDir.removeEntry(filenamePart, {recursive}); + return true; + }catch(e){ + if(throwOnError){ + throw new Error("unlink(",arguments[0],") failed: "+e.message,{ + cause: e + }); + } + return false; + } + }; + + + opfsUtil.traverse = async function(opt){ + const defaultOpt = { + recursive: true, + directory: await opfsUtil.getRootDir() + }; + if('function'===typeof opt){ + opt = {callback:opt}; + } + opt = Object.assign(defaultOpt, opt||{}); + const doDir = async function callee(dirHandle, depth){ + for await (const handle of dirHandle.values()){ + if(false === opt.callback(handle, dirHandle, depth)) return false; + else if(opt.recursive && 'directory' === handle.kind){ + if(false === await callee(handle, depth + 1)) break; + } + } + }; + doDir(opt.directory, 0); + }; + + + const importDbChunked = async function(filename, callback){ + const [hDir, fnamePart] = await opfsUtil.getDirForFilename(filename, true); + const hFile = await hDir.getFileHandle(fnamePart, {create:true}); + let sah = await hFile.createSyncAccessHandle(); + let nWrote = 0, chunk, checkedHeader = false, err = false; + try{ + sah.truncate(0); + while( undefined !== (chunk = await callback()) ){ + if(chunk instanceof ArrayBuffer) chunk = new Uint8Array(chunk); + if( !checkedHeader && 0===nWrote && chunk.byteLength>=15 ){ + util.affirmDbHeader(chunk); + checkedHeader = true; + } + sah.write(chunk, {at: nWrote}); + nWrote += chunk.byteLength; + } + if( nWrote < 512 || 0!==nWrote % 512 ){ + toss("Input size",nWrote,"is not correct for an SQLite database."); + } + if( !checkedHeader ){ + const header = new Uint8Array(20); + sah.read( header, {at: 0} ); + util.affirmDbHeader( header ); + } + sah.write(new Uint8Array([1,1]), {at: 18}); + return nWrote; + }catch(e){ + await sah.close(); + sah = undefined; + await hDir.removeEntry( fnamePart ).catch(()=>{}); + throw e; + }finally { + if( sah ) await sah.close(); + } + }; + + + opfsUtil.importDb = async function(filename, bytes){ + if( bytes instanceof Function ){ + return importDbChunked(filename, bytes); + } + if(bytes instanceof ArrayBuffer) bytes = new Uint8Array(bytes); + util.affirmIsDb(bytes); + const n = bytes.byteLength; + const [hDir, fnamePart] = await opfsUtil.getDirForFilename(filename, true); + let sah, err, nWrote = 0; + try { + const hFile = await hDir.getFileHandle(fnamePart, {create:true}); + sah = await hFile.createSyncAccessHandle(); + sah.truncate(0); + nWrote = sah.write(bytes, {at: 0}); + if(nWrote != n){ + toss("Expected to write "+n+" bytes but wrote "+nWrote+"."); + } + sah.write(new Uint8Array([1,1]), {at: 18}) ; + return nWrote; + }catch(e){ + if( sah ){ await sah.close(); sah = undefined; } + await hDir.removeEntry( fnamePart ).catch(()=>{}); + throw e; + }finally{ + if( sah ) await sah.close(); + } + }; + + + opfsUtil.vfsInstallationFeatureCheck = function(vfsName){ + if( !globalThis.SharedArrayBuffer || !globalThis.Atomics ){ + toss("Cannot install OPFS: Missing SharedArrayBuffer and/or Atomics.", + "The server must emit the COOP/COEP response headers to enable those.", + "See https://sqlite.org/wasm/doc/trunk/persistence.md#coop-coep"); + }else if( 'undefined'===typeof WorkerGlobalScope ){ + toss("The OPFS sqlite3_vfs cannot run in the main thread", + "because it requires Atomics.wait()."); + }else if( !globalThis.FileSystemHandle || + !globalThis.FileSystemDirectoryHandle || + !globalThis.FileSystemFileHandle?.prototype?.createSyncAccessHandle || + !navigator?.storage?.getDirectory ){ + toss("Missing required OPFS APIs."); + }else if( 'opfs-wl'===vfsName && !globalThis.Atomics.waitAsync ){ + toss('The',vfsName,'VFS requires Atomics.waitAsync(), which is not available.'); + } + }; + + + opfsUtil.initOptions = function callee(vfsName, options){ + const urlParams = new URL(globalThis.location.href).searchParams; + if( urlParams.has(vfsName+'-disable') ){ + + return; + } + try{ + opfsUtil.vfsInstallationFeatureCheck(vfsName); + }catch(e){ + return; + } + options = util.nu(options); + options.vfsName = vfsName; + options.verbose ??= urlParams.has('opfs-verbose') + ? +urlParams.get('opfs-verbose') : 1; + options.sanityChecks ??= urlParams.has('opfs-sanity-check'); + + if( !opfsUtil.proxyUri ){ + opfsUtil.proxyUri = "sqlite3-opfs-async-proxy.js"; + if( sqlite3.scriptInfo?.sqlite3Dir ){ + + opfsUtil.proxyUri = ( + sqlite3.scriptInfo.sqlite3Dir + opfsUtil.proxyUri + ); + } + } + options.proxyUri ??= opfsUtil.proxyUri; + if('function' === typeof options.proxyUri){ + options.proxyUri = options.proxyUri(); + } + + return opfsUtil.options = options; + }; + + + opfsUtil.createVfsState = function(){ + const state = util.nu(); + const options = opfsUtil.options; + state.verbose = options.verbose; + + const loggers = [ + sqlite3.config.error, + sqlite3.config.warn, + sqlite3.config.log + ]; + const vfsName = options.vfsName + || toss("Maintenance required: missing VFS name"); + const logImpl = (level,...args)=>{ + if(state.verbose>level) loggers[level](vfsName+":",...args); + }; + const log = (...args)=>logImpl(2, ...args), + warn = (...args)=>logImpl(1, ...args), + error = (...args)=>logImpl(0, ...args), + capi = sqlite3.capi, + wasm = sqlite3.wasm; + + const opfsVfs = state.vfs = new capi.sqlite3_vfs(); + const opfsIoMethods = opfsVfs.ioMethods = new capi.sqlite3_io_methods(); + + opfsIoMethods.$iVersion = 1; + opfsVfs.$iVersion = 2; + opfsVfs.$szOsFile = capi.sqlite3_file.structInfo.sizeof; + opfsVfs.$mxPathname = 1024; + opfsVfs.$zName = wasm.allocCString(vfsName); + opfsVfs.addOnDispose( + '$zName', opfsVfs.$zName, opfsIoMethods + + ); + + opfsVfs.metrics = util.nu({ + counters: util.nu(), + dump: function(){ + let k, n = 0, t = 0, w = 0; + for(k in state.opIds){ + const m = metrics[k]; + n += m.count; + t += m.time; + w += m.wait; + m.avgTime = (m.count && m.time) ? (m.time / m.count) : 0; + m.avgWait = (m.count && m.wait) ? (m.wait / m.count) : 0; + } + sqlite3.config.log(globalThis.location.href, + "metrics for",globalThis.location.href,":",metrics, + "\nTotal of",n,"op(s) for",t, + "ms (incl. "+w+" ms of waiting on the async side)"); + sqlite3.config.log("Serialization metrics:",opfsVfs.metrics.counters.s11n); + opfsVfs.worker?.postMessage?.({type:'opfs-async-metrics'}); + }, + reset: function(){ + let k; + const r = (m)=>(m.count = m.time = m.wait = 0); + const m = opfsVfs.metrics.counters; + for(k in state.opIds){ + r(m[k] = Object.create(null)); + } + let s = m.s11n = Object.create(null); + s = s.serialize = Object.create(null); + s.count = s.time = 0; + s = m.s11n.deserialize = Object.create(null); + s.count = s.time = 0; + } + }); + + + state.asyncIdleWaitTime = 150; + + + state.asyncS11nExceptions = 1; + + state.fileBufferSize = 1024 * 64; + state.sabS11nOffset = state.fileBufferSize; + + state.sabS11nSize = opfsVfs.$mxPathname * 2; + + state.sabIO = new SharedArrayBuffer( + state.fileBufferSize + + state.sabS11nSize + ); + + + state.opIds = Object.create(null); + { + + let i = 0; + + state.opIds.whichOp = i++; + + state.opIds.rc = i++; + + state.opIds.xAccess = i++; + state.opIds.xClose = i++; + state.opIds.xDelete = i++; + state.opIds.xDeleteNoWait = i++; + state.opIds.xFileSize = i++; + state.opIds.xLock = i++; + state.opIds.xOpen = i++; + state.opIds.xRead = i++; + state.opIds.xSleep = i++; + state.opIds.xSync = i++; + state.opIds.xTruncate = i++; + state.opIds.xUnlock = i++; + state.opIds.xWrite = i++; + state.opIds.mkdir = i++ ; + + state.opIds['opfs-async-metrics'] = i++; + state.opIds['opfs-async-shutdown'] = i++; + + state.opIds.retry = i++; + state.sabOP = new SharedArrayBuffer( + i * 4); + } + + state.sq3Codes = Object.create(null); + for(const k of [ + 'SQLITE_ACCESS_EXISTS', + 'SQLITE_ACCESS_READWRITE', + 'SQLITE_BUSY', + 'SQLITE_CANTOPEN', + 'SQLITE_ERROR', + 'SQLITE_IOERR', + 'SQLITE_IOERR_ACCESS', + 'SQLITE_IOERR_CLOSE', + 'SQLITE_IOERR_DELETE', + 'SQLITE_IOERR_FSYNC', + 'SQLITE_IOERR_LOCK', + 'SQLITE_IOERR_READ', + 'SQLITE_IOERR_SHORT_READ', + 'SQLITE_IOERR_TRUNCATE', + 'SQLITE_IOERR_UNLOCK', + 'SQLITE_IOERR_WRITE', + 'SQLITE_LOCK_EXCLUSIVE', + 'SQLITE_LOCK_NONE', + 'SQLITE_LOCK_PENDING', + 'SQLITE_LOCK_RESERVED', + 'SQLITE_LOCK_SHARED', + 'SQLITE_LOCKED', + 'SQLITE_MISUSE', + 'SQLITE_NOTFOUND', + 'SQLITE_OPEN_CREATE', + 'SQLITE_OPEN_DELETEONCLOSE', + 'SQLITE_OPEN_MAIN_DB', + 'SQLITE_OPEN_READONLY', + 'SQLITE_LOCK_NONE', + 'SQLITE_LOCK_SHARED', + 'SQLITE_LOCK_RESERVED', + 'SQLITE_LOCK_PENDING', + 'SQLITE_LOCK_EXCLUSIVE' + ]){ + state.sq3Codes[k] = + capi[k] ?? toss("Maintenance required: not found:",k); + } + + state.opfsFlags = Object.assign(Object.create(null),{ + + OPFS_UNLOCK_ASAP: 0x01, + + OPFS_UNLINK_BEFORE_OPEN: 0x02, + + defaultUnlockAsap: false + }); + + opfsVfs.metrics.reset(); + const metrics = opfsVfs.metrics.counters; + + + const opRun = opfsVfs.opRun = (op,...args)=>{ + const opNdx = state.opIds[op] || toss(opfsVfs.vfsName+": Invalid op ID:",op); + state.s11n.serialize(...args); + Atomics.store(state.sabOPView, state.opIds.rc, -1); + Atomics.store(state.sabOPView, state.opIds.whichOp, opNdx); + Atomics.notify(state.sabOPView, state.opIds.whichOp) + ; + const t = performance.now(); + while('not-equal'!==Atomics.wait(state.sabOPView, state.opIds.rc, -1)){ + + } + + const rc = Atomics.load(state.sabOPView, state.opIds.rc); + metrics[op].wait += performance.now() - t; + if(rc && state.asyncS11nExceptions){ + const err = state.s11n.deserialize(); + if(err) error(op+"() async error:",...err); + } + return rc; + }; + + const opTimer = Object.create(null); + opTimer.op = undefined; + opTimer.start = undefined; + const mTimeStart = opfsVfs.mTimeStart = (op)=>{ + opTimer.start = performance.now(); + opTimer.op = op; + ++metrics[op].count; + }; + const mTimeEnd = opfsVfs.mTimeEnd = ()=>( + metrics[opTimer.op].time += performance.now() - opTimer.start + ); + + + const __openFiles = opfsVfs.__openFiles = Object.create(null); + + + const ioSyncWrappers = opfsVfs.ioSyncWrappers = util.nu({ + xCheckReservedLock: function(pFile,pOut){ + + wasm.poke(pOut, 0, 'i32'); + return 0; + }, + xClose: function(pFile){ + mTimeStart('xClose'); + let rc = 0; + const f = __openFiles[pFile]; + if(f){ + delete __openFiles[pFile]; + rc = opRun('xClose', pFile); + if(f.sq3File) f.sq3File.dispose(); + } + mTimeEnd(); + return rc; + }, + xDeviceCharacteristics: function(pFile){ + return capi.SQLITE_IOCAP_UNDELETABLE_WHEN_OPEN; + }, + xFileControl: function(pFile, opId, pArg){ + + return capi.SQLITE_NOTFOUND; + }, + xFileSize: function(pFile,pSz64){ + mTimeStart('xFileSize'); + let rc = opRun('xFileSize', pFile); + if(0==rc){ + try { + const sz = state.s11n.deserialize()[0]; + wasm.poke(pSz64, sz, 'i64'); + }catch(e){ + error("Unexpected error reading xFileSize() result:",e); + rc = state.sq3Codes.SQLITE_IOERR; + } + } + mTimeEnd(); + return rc; + }, + xRead: function(pFile,pDest,n,offset64){ + mTimeStart('xRead'); + const f = __openFiles[pFile]; + let rc; + try { + rc = opRun('xRead',pFile, n, Number(offset64)); + if(0===rc || capi.SQLITE_IOERR_SHORT_READ===rc){ + + wasm.heap8u().set(f.sabView.subarray(0, n), Number(pDest)); + } + }catch(e){ + error("xRead(",arguments,") failed:",e,f); + rc = capi.SQLITE_IOERR_READ; + } + mTimeEnd(); + return rc; + }, + xSync: function(pFile,flags){ + mTimeStart('xSync'); + const rc = opRun('xSync', pFile, flags); + mTimeEnd(); + return rc; + }, + xTruncate: function(pFile,sz64){ + mTimeStart('xTruncate'); + const rc = opRun('xTruncate', pFile, Number(sz64)); + mTimeEnd(); + return rc; + }, + xWrite: function(pFile,pSrc,n,offset64){ + mTimeStart('xWrite'); + const f = __openFiles[pFile]; + let rc; + try { + f.sabView.set(wasm.heap8u().subarray( + Number(pSrc), Number(pSrc) + n + )); + rc = opRun('xWrite', pFile, n, Number(offset64)); + }catch(e){ + error("xWrite(",arguments,") failed:",e,f); + rc = capi.SQLITE_IOERR_WRITE; + } + mTimeEnd(); + return rc; + } + }); + + + const vfsSyncWrappers = opfsVfs.vfsSyncWrappers = { + xAccess: function(pVfs,zName,flags,pOut){ + mTimeStart('xAccess'); + const rc = opRun('xAccess', wasm.cstrToJs(zName)); + wasm.poke( pOut, (rc ? 0 : 1), 'i32' ); + mTimeEnd(); + return 0; + }, + xCurrentTime: function(pVfs,pOut){ + wasm.poke(pOut, 2440587.5 + (new Date().getTime()/86400000), + 'double'); + return 0; + }, + xCurrentTimeInt64: function(pVfs,pOut){ + wasm.poke(pOut, (2440587.5 * 86400000) + new Date().getTime(), + 'i64'); + return 0; + }, + xDelete: function(pVfs, zName, doSyncDir){ + mTimeStart('xDelete'); + const rc = opRun('xDelete', wasm.cstrToJs(zName), doSyncDir, false); + mTimeEnd(); + return rc; + }, + xFullPathname: function(pVfs,zName,nOut,pOut){ + + const i = wasm.cstrncpy(pOut, zName, nOut); + return i( + TypeIds[typeof v] + || toss("Maintenance required: this value type cannot be serialized.",v) + ); + const getTypeIdById = (tid)=>{ + switch(tid){ + case TypeIds.number.id: return TypeIds.number; + case TypeIds.bigint.id: return TypeIds.bigint; + case TypeIds.boolean.id: return TypeIds.boolean; + case TypeIds.string.id: return TypeIds.string; + default: toss("Invalid type ID:",tid); + } + }; + + + state.s11n.deserialize = function(clear=false){ + const t = performance.now(); + const argc = viewU8[0]; + const rc = argc ? [] : null; + if(argc){ + const typeIds = []; + let offset = 1, i, n, v; + for(i = 0; i < argc; ++i, ++offset){ + typeIds.push(getTypeIdById(viewU8[offset])); + } + for(i = 0; i < argc; ++i){ + const t = typeIds[i]; + if(t.getter){ + v = viewDV[t.getter](offset, state.littleEndian); + offset += t.size; + }else{ + n = viewDV.getInt32(offset, state.littleEndian); + offset += 4; + v = textDecoder.decode(viewU8.slice(offset, offset+n)); + offset += n; + } + rc.push(v); + } + } + if(clear) viewU8[0] = 0; + + return rc; + }; + + + state.s11n.serialize = function(...args){ + const t = performance.now(); + if(args.length){ + + const typeIds = []; + let i = 0, offset = 1; + viewU8[0] = args.length & 0xff ; + for(; i < args.length; ++i, ++offset){ + + typeIds.push(getTypeId(args[i])); + viewU8[offset] = typeIds[i].id; + } + for(i = 0; i < args.length; ++i) { + + const t = typeIds[i]; + if(t.setter){ + viewDV[t.setter](offset, args[i], state.littleEndian); + offset += t.size; + }else{ + const s = textEncoder.encode(args[i]); + viewDV.setInt32(offset, s.byteLength, state.littleEndian); + offset += 4; + viewU8.set(s, offset); + offset += s.byteLength; + } + } + + }else{ + viewU8[0] = 0; + } + }; + + + return state.s11n; +}; + opfsVfs.initS11n = initS11n; + + + opfsVfs.bindVfs = function(ioMethods, callback){ + Object.assign(opfsVfs.ioSyncWrappers, ioMethods); + const thePromise = new Promise(function(promiseResolve_, promiseReject_){ + let promiseWasRejected = undefined; + const promiseReject = (err)=>{ + promiseWasRejected = true; + opfsVfs.dispose(); + return promiseReject_(err); + }; + const promiseResolve = ()=>{ + try{ + callback(sqlite3, opfsVfs); + }catch(e){ + return promiseReject(e); + } + promiseWasRejected = false; + return promiseResolve_(sqlite3); + }; + const options = opfsUtil.options; + const proxyUri = options.proxyUri +( + (options.proxyUri.indexOf('?')<0) ? '?' : '&' + )+'vfs='+vfsName; + + const W = opfsVfs.worker = + new Worker(new URL(proxyUri, import.meta.url)); + let zombieTimer = setTimeout(()=>{ + + if(undefined===promiseWasRejected){ + promiseReject( + new Error("Timeout while waiting for OPFS async proxy worker.") + ); + } + }, 4000); + W._originalOnError = W.onerror ; + W.onerror = function(err){ + + + error("Error initializing OPFS asyncer:",err); + promiseReject(new Error("Loading OPFS async Worker failed for unknown reasons.")); + }; + + const opRun = opfsVfs.opRun; + + const sanityCheck = function(){ + const scope = wasm.scopedAllocPush(); + const sq3File = new capi.sqlite3_file(); + try{ + const fid = sq3File.pointer; + const openFlags = capi.SQLITE_OPEN_CREATE + | capi.SQLITE_OPEN_READWRITE + + | capi.SQLITE_OPEN_MAIN_DB; + const pOut = wasm.scopedAlloc(8); + const dbFile = "/sanity/check/file"+opfsUtil.randomFilename(8); + const zDbFile = wasm.scopedAllocCString(dbFile); + let rc; + state.s11n.serialize("This is ä string."); + rc = state.s11n.deserialize(); + log("deserialize() says:",rc); + if("This is ä string."!==rc[0]) toss("String d13n error."); + opfsVfs.vfsSyncWrappers.xAccess(opfsVfs.pointer, zDbFile, 0, pOut); + rc = wasm.peek(pOut,'i32'); + log("xAccess(",dbFile,") exists ?=",rc); + rc = opfsVfs.vfsSyncWrappers.xOpen(opfsVfs.pointer, zDbFile, + fid, openFlags, pOut); + log("open rc =",rc,"state.sabOPView[xOpen] =", + state.sabOPView[state.opIds.xOpen]); + if(0!==rc){ + error("open failed with code",rc); + return; + } + opfsVfs.vfsSyncWrappers.xAccess(opfsVfs.pointer, zDbFile, 0, pOut); + rc = wasm.peek(pOut,'i32'); + if(!rc) toss("xAccess() failed to detect file."); + rc = opfsVfs.ioSyncWrappers.xSync(sq3File.pointer, 0); + if(rc) toss('sync failed w/ rc',rc); + rc = opfsVfs.ioSyncWrappers.xTruncate(sq3File.pointer, 1024); + if(rc) toss('truncate failed w/ rc',rc); + wasm.poke(pOut,0,'i64'); + rc = opfsVfs.ioSyncWrappers.xFileSize(sq3File.pointer, pOut); + if(rc) toss('xFileSize failed w/ rc',rc); + log("xFileSize says:",wasm.peek(pOut, 'i64')); + rc = opfsVfs.ioSyncWrappers.xWrite(sq3File.pointer, zDbFile, 10, 1); + if(rc) toss("xWrite() failed!"); + const readBuf = wasm.scopedAlloc(16); + rc = opfsVfs.ioSyncWrappers.xRead(sq3File.pointer, readBuf, 6, 2); + wasm.poke(readBuf+6,0); + let jRead = wasm.cstrToJs(readBuf); + log("xRead() got:",jRead); + if("sanity"!==jRead) toss("Unexpected xRead() value."); + if(opfsVfs.vfsSyncWrappers.xSleep){ + log("xSleep()ing before close()ing..."); + opfsVfs.vfsSyncWrappers.xSleep(opfsVfs.pointer,2000); + log("waking up from xSleep()"); + } + rc = opfsVfs.ioSyncWrappers.xClose(fid); + log("xClose rc =",rc,"sabOPView =",state.sabOPView); + log("Deleting file:",dbFile); + opfsVfs.vfsSyncWrappers.xDelete(opfsVfs.pointer, zDbFile, 0x1234); + opfsVfs.vfsSyncWrappers.xAccess(opfsVfs.pointer, zDbFile, 0, pOut); + rc = wasm.peek(pOut,'i32'); + if(rc) toss("Expecting 0 from xAccess(",dbFile,") after xDelete()."); + warn("End of OPFS sanity checks."); + }finally{ + sq3File.dispose(); + wasm.scopedAllocPop(scope); + } + }; + + W.onmessage = function({data}){ + + switch(data.type){ + case 'opfs-unavailable': + + promiseReject(new Error(data.payload.join(' '))); + break; + case 'opfs-async-loaded': + + delete state.vfs; + W.postMessage({type: 'opfs-async-init', args: util.nu(state)}); + break; + case 'opfs-async-inited': { + + if(true===promiseWasRejected){ + break ; + } + clearTimeout(zombieTimer); + zombieTimer = null; + try { + sqlite3.vfs.installVfs({ + io: {struct: opfsVfs.ioMethods, methods: opfsVfs.ioSyncWrappers}, + vfs: {struct: opfsVfs, methods: opfsVfs.vfsSyncWrappers} + }); + state.sabOPView = new Int32Array(state.sabOP); + state.sabFileBufView = new Uint8Array(state.sabIO, 0, state.fileBufferSize); + state.sabS11nView = new Uint8Array(state.sabIO, state.sabS11nOffset, state.sabS11nSize); + opfsVfs.initS11n(); + opfsVfs.initS11n = null; + if(options.sanityChecks){ + warn("Running sanity checks because of opfs-sanity-check URL arg..."); + sanityCheck(); + } + if(opfsUtil.thisThreadHasOPFS()){ + opfsUtil.getRootDir().then((d)=>{ + W.onerror = W._originalOnError; + delete W._originalOnError; + log("End of OPFS sqlite3_vfs setup.", opfsVfs); + promiseResolve(); + }).catch(promiseReject); + }else{ + promiseResolve(); + } + }catch(e){ + error(e); + promiseReject(e); + } + break; + } + case 'debug': + warn("debug message from worker:",data); + break; + default: { + const errMsg = ( + "Unexpected message from the OPFS async worker: " + + JSON.stringify(data) + ); + error(errMsg); + promiseReject(new Error(errMsg)); + break; + } + } + }; + }); + return thePromise; + }; + + return state; + }; + +}); + +'use strict'; +globalThis.sqlite3ApiBootstrap.initializers.push(function(sqlite3){ + if( !sqlite3.opfs || sqlite3.config.disable?.vfs?.opfs ){ + return; + } + const util = sqlite3.util, + opfsUtil = sqlite3.opfs || sqlite3.util.toss("Missing sqlite3.opfs"); + +const installOpfsVfs = async function(options){ + options = opfsUtil.initOptions('opfs',options); + if( !options ) return sqlite3; + const capi = sqlite3.capi, + state = opfsUtil.createVfsState(), + opfsVfs = state.vfs, + metrics = opfsVfs.metrics.counters, + mTimeStart = opfsVfs.mTimeStart, + mTimeEnd = opfsVfs.mTimeEnd, + opRun = opfsVfs.opRun, + debug = (...args)=>sqlite3.config.debug("opfs:",...args), + warn = (...args)=>sqlite3.config.warn("opfs:",...args), + __openFiles = opfsVfs.__openFiles; + + + + return opfsVfs.bindVfs(util.nu({ + xLock: function(pFile,lockType){ + mTimeStart('xLock'); + ++metrics.xLock.count; + const f = __openFiles[pFile]; + let rc = 0; + + if( f.lockType ) { + f.lockType = lockType; + }else{ + rc = opRun('xLock', pFile, lockType); + if( 0===rc ) f.lockType = lockType; + } + mTimeEnd(); + return rc; + }, + xUnlock: function(pFile,lockType){ + mTimeStart('xUnlock'); + ++metrics.xUnlock.count; + const f = __openFiles[pFile]; + let rc = 0; + if( capi.SQLITE_LOCK_NONE === lockType + && f.lockType ){ + rc = opRun('xUnlock', pFile, lockType); + } + if( 0===rc ) f.lockType = lockType; + mTimeEnd(); + return rc; + } + }), function(sqlite3, vfs){ + + if(sqlite3.oo1){ + const OpfsDb = function(...args){ + const opt = sqlite3.oo1.DB.dbCtorHelper.normalizeArgs(...args); + opt.vfs = vfs.$zName; + sqlite3.oo1.DB.dbCtorHelper.call(this, opt); + }; + OpfsDb.prototype = Object.create(sqlite3.oo1.DB.prototype); + sqlite3.oo1.OpfsDb = OpfsDb; + OpfsDb.importDb = opfsUtil.importDb; + if( true ){ + + sqlite3.oo1.DB.dbCtorHelper.setVfsPostOpenCallback( + opfsVfs.pointer, + function(oo1Db, sqlite3){ + + sqlite3.capi.sqlite3_busy_timeout(oo1Db, 10000); + } + ); + } + } + }); +}; +globalThis.sqlite3ApiBootstrap.initializersAsync.push(async (sqlite3)=>{ + return installOpfsVfs().catch((e)=>{ + sqlite3.config.warn("Ignoring inability to install 'opfs' sqlite3_vfs:",e); + }) +}); +}); + +globalThis.sqlite3ApiBootstrap.initializers.push(function(sqlite3){ + 'use strict'; + if( sqlite3.config.disable?.vfs?.['opfs-sahpool'] ){ + return; + } + + const toss = sqlite3.util.toss; + const toss3 = sqlite3.util.toss3; + const initPromises = Object.create(null) ; + const capi = sqlite3.capi; + const util = sqlite3.util; + const wasm = sqlite3.wasm; + + const SECTOR_SIZE = 4096; + const HEADER_MAX_PATH_SIZE = 512; + const HEADER_FLAGS_SIZE = 4; + const HEADER_DIGEST_SIZE = 8; + const HEADER_CORPUS_SIZE = HEADER_MAX_PATH_SIZE + HEADER_FLAGS_SIZE; + const HEADER_OFFSET_FLAGS = HEADER_MAX_PATH_SIZE; + const HEADER_OFFSET_DIGEST = HEADER_CORPUS_SIZE; + const HEADER_OFFSET_DATA = SECTOR_SIZE; + + const PERSISTENT_FILE_TYPES = + capi.SQLITE_OPEN_MAIN_DB | + capi.SQLITE_OPEN_MAIN_JOURNAL | + capi.SQLITE_OPEN_SUPER_JOURNAL | + capi.SQLITE_OPEN_WAL; + const FLAG_COMPUTE_DIGEST_V2 = capi.SQLITE_OPEN_MEMORY + ; + + + const OPAQUE_DIR_NAME = ".opaque"; + + + const getRandomName = ()=>Math.random().toString(36).slice(2); + + const textDecoder = new TextDecoder(); + const textEncoder = new TextEncoder(); + + const optionDefaults = Object.assign(Object.create(null),{ + name: 'opfs-sahpool', + directory: undefined , + initialCapacity: 6, + clearOnInit: false, + + verbosity: 2, + forceReinitIfPreviouslyFailed: false, + preserveOnInitFailure: false + }); + + + const loggers = [ + sqlite3.config.error, + sqlite3.config.warn, + sqlite3.config.log + ]; + const log = sqlite3.config.log; + const warn = sqlite3.config.warn; + const error = sqlite3.config.error; + + + const __mapVfsToPool = new Map(); + const getPoolForVfs = (pVfs)=>__mapVfsToPool.get(pVfs); + const setPoolForVfs = (pVfs,pool)=>{ + if(pool) __mapVfsToPool.set(pVfs, pool); + else __mapVfsToPool.delete(pVfs); + }; + + const __mapSqlite3File = new Map(); + const getPoolForPFile = (pFile)=>__mapSqlite3File.get(pFile); + const setPoolForPFile = (pFile,pool)=>{ + if(pool) __mapSqlite3File.set(pFile, pool); + else __mapSqlite3File.delete(pFile); + }; + + + const ioMethods = { + xCheckReservedLock: function(pFile,pOut){ + const pool = getPoolForPFile(pFile); + pool.log('xCheckReservedLock'); + pool.storeErr(); + const file = pool.getOFileForS3File(pFile); + wasm.poke32( + pOut, + pool.hasReservedLock(file.path) ? 1 : 0 + + ); + return 0; + }, + xClose: function(pFile){ + const pool = getPoolForPFile(pFile); + pool.storeErr(); + const file = pool.getOFileForS3File(pFile); + if(file) { + try{ + pool.log(`xClose ${file.path}`); + pool.unlockFile(file, capi.SQLITE_LOCK_NONE); + pool.mapS3FileToOFile(pFile, false); + file.sah.flush(); + if(file.flags & capi.SQLITE_OPEN_DELETEONCLOSE){ + pool.deletePath(file.path); + } + }catch(e){ + return pool.storeErr(e, capi.SQLITE_IOERR); + } + } + return 0; + }, + xDeviceCharacteristics: function(pFile){ + return capi.SQLITE_IOCAP_UNDELETABLE_WHEN_OPEN; + }, + xFileControl: function(pFile, opId, pArg){ + return capi.SQLITE_NOTFOUND; + }, + xFileSize: function(pFile,pSz64){ + const pool = getPoolForPFile(pFile); + pool.log(`xFileSize`); + const file = pool.getOFileForS3File(pFile); + const size = file.sah.getSize() - HEADER_OFFSET_DATA; + + wasm.poke64(pSz64, BigInt(size)); + return 0; + }, + xLock: function(pFile,lockType){ + const pool = getPoolForPFile(pFile); + pool.log(`xLock ${lockType}`); + pool.storeErr(); + const file = pool.getOFileForS3File(pFile); + return pool.lockFile(file, lockType); + }, + xRead: function(pFile,pDest,n,offset64){ + const pool = getPoolForPFile(pFile); + pool.storeErr(); + const file = pool.getOFileForS3File(pFile); + pool.log(`xRead ${file.path} ${n} @ ${offset64}`); + try { + const nRead = file.sah.read( + wasm.heap8u().subarray(Number(pDest), Number(pDest)+n), + {at: HEADER_OFFSET_DATA + Number(offset64)} + ); + if(nRead < n){ + wasm.heap8u().fill(0, Number(pDest) + nRead, Number(pDest) + n); + return capi.SQLITE_IOERR_SHORT_READ; + } + return 0; + }catch(e){ + return pool.storeErr(e, capi.SQLITE_IOERR); + } + }, + xSectorSize: function(pFile){ + return SECTOR_SIZE; + }, + xSync: function(pFile,flags){ + const pool = getPoolForPFile(pFile); + pool.log(`xSync ${flags}`); + pool.storeErr(); + const file = pool.getOFileForS3File(pFile); + + try{ + file.sah.flush(); + return 0; + }catch(e){ + return pool.storeErr(e, capi.SQLITE_IOERR); + } + }, + xTruncate: function(pFile,sz64){ + const pool = getPoolForPFile(pFile); + pool.log(`xTruncate ${sz64}`); + pool.storeErr(); + const file = pool.getOFileForS3File(pFile); + + try{ + file.sah.truncate(HEADER_OFFSET_DATA + Number(sz64)); + return 0; + }catch(e){ + return pool.storeErr(e, capi.SQLITE_IOERR); + } + }, + xUnlock: function(pFile,lockType){ + const pool = getPoolForPFile(pFile); + pool.log('xUnlock'); + const file = pool.getOFileForS3File(pFile); + pool.unlockFile(file, lockType); + return 0; + }, + xWrite: function(pFile,pSrc,n,offset64){ + const pool = getPoolForPFile(pFile); + pool.storeErr(); + const file = pool.getOFileForS3File(pFile); + pool.log(`xWrite ${file.path} ${n} ${offset64}`); + try{ + const nBytes = file.sah.write( + wasm.heap8u().subarray(Number(pSrc), Number(pSrc)+n), + { at: HEADER_OFFSET_DATA + Number(offset64) } + ); + return n===nBytes ? 0 : toss("Unknown write() failure."); + }catch(e){ + return pool.storeErr(e, capi.SQLITE_IOERR); + } + } + }; + + const opfsIoMethods = new capi.sqlite3_io_methods(); + opfsIoMethods.$iVersion = 1; + sqlite3.vfs.installVfs({ + io: {struct: opfsIoMethods, methods: ioMethods} + }); + + + const vfsMethods = { + xAccess: function(pVfs,zName,flags,pOut){ + + const pool = getPoolForVfs(pVfs); + pool.storeErr(); + try{ + const name = pool.getPath(zName); + wasm.poke32(pOut, pool.hasFilename(name) ? 1 : 0); + }catch(e){ + + wasm.poke32(pOut, 0); + } + return 0; + }, + xCurrentTime: function(pVfs,pOut){ + wasm.poke(pOut, 2440587.5 + (new Date().getTime()/86400000), + 'double'); + return 0; + }, + xCurrentTimeInt64: function(pVfs,pOut){ + wasm.poke(pOut, (2440587.5 * 86400000) + new Date().getTime(), + 'i64'); + return 0; + }, + xDelete: function(pVfs, zName, doSyncDir){ + const pool = getPoolForVfs(pVfs); + pool.log(`xDelete ${wasm.cstrToJs(zName)}`); + pool.storeErr(); + try{ + pool.deletePath(pool.getPath(zName)); + return 0; + }catch(e){ + pool.storeErr(e); + return capi.SQLITE_IOERR_DELETE; + } + }, + xFullPathname: function(pVfs,zName,nOut,pOut){ + + + const i = wasm.cstrncpy(pOut, zName, nOut); + return i nOut) wasm.poke8(wasm.ptr.add(pOut,nOut,-1), 0); + }catch(e){ + return capi.SQLITE_NOMEM; + }finally{ + wasm.scopedAllocPop(scope); + } + } + return e ? (e.sqlite3Rc || capi.SQLITE_IOERR) : 0; + }, + + xOpen: function f(pVfs, zName, pFile, flags, pOutFlags){ + const pool = getPoolForVfs(pVfs); + try{ + flags &= ~FLAG_COMPUTE_DIGEST_V2; + pool.log(`xOpen ${wasm.cstrToJs(zName)} ${flags}`); + + const path = (zName && wasm.peek8(zName)) + ? pool.getPath(zName) + : getRandomName(); + let sah = pool.getSAHForPath(path); + if(sah && (flags & capi.SQLITE_OPEN_CREATE) + && (flags & capi.SQLITE_OPEN_EXCLUSIVE)){ + toss('file already exists:', path); + } + if(!sah && (flags & capi.SQLITE_OPEN_CREATE)) { + + if(pool.getFileCount() < pool.getCapacity()) { + + sah = pool.nextAvailableSAH(); + pool.setAssociatedPath(sah, path, flags); + }else{ + + toss('SAH pool is full. Cannot create file',path); + } + } + if(!sah){ + toss('file not found:',path); + } + + + const file = {path, flags, sah}; + pool.mapS3FileToOFile(pFile, file); + file.lockType = capi.SQLITE_LOCK_NONE; + const sq3File = new capi.sqlite3_file(pFile); + sq3File.$pMethods = opfsIoMethods.pointer; + sq3File.dispose(); + wasm.poke32(pOutFlags, flags); + return 0; + }catch(e){ + pool.storeErr(e); + return capi.SQLITE_CANTOPEN; + } + } + }; + + + const createOpfsVfs = function(vfsName){ + if( sqlite3.capi.sqlite3_vfs_find(vfsName)){ + toss3("VFS name is already registered:", vfsName); + } + const opfsVfs = new capi.sqlite3_vfs(); + + const pDVfs = capi.sqlite3_vfs_find(null); + const dVfs = pDVfs + ? new capi.sqlite3_vfs(pDVfs) + : null ; + opfsVfs.$iVersion = 2; + opfsVfs.$szOsFile = capi.sqlite3_file.structInfo.sizeof; + opfsVfs.$mxPathname = HEADER_MAX_PATH_SIZE; + opfsVfs.addOnDispose( + opfsVfs.$zName = wasm.allocCString(vfsName), + ()=>setPoolForVfs(opfsVfs.pointer, 0) + ); + + if(dVfs){ + + opfsVfs.$xRandomness = dVfs.$xRandomness; + dVfs.dispose(); + } + if(!opfsVfs.$xRandomness && !vfsMethods.xRandomness){ + + vfsMethods.xRandomness = function(pVfs, nOut, pOut){ + const heap = wasm.heap8u(); + let i = 0; + const npOut = Number(pOut); + for(; i < nOut; ++i) heap[npOut + i] = (Math.random()*255000) & 0xFF; + return i; + }; + } + + vfsMethods.xSleep = (pVfs,ms)=>0; + sqlite3.vfs.installVfs({ + vfs: {struct: opfsVfs, methods: vfsMethods} + }); + return opfsVfs; + }; + + + class OpfsSAHPool { + + vfsDir; + + #dhVfsRoot; + + #dhOpaque; + + #dhVfsParent; + + #mapSAHToName = new Map(); + + #mapFilenameToSAH = new Map(); + + #availableSAH = new Set(); + + #mapS3FileToOFile = new Map(); + + #mapPathToLock = new Map(); + + + + + #apBody = new Uint8Array(HEADER_CORPUS_SIZE); + + #dvBody; + + + #cVfs; + + + #verbosity; + + + constructor(options = Object.create(null)){ + this.#verbosity = options.verbosity ?? optionDefaults.verbosity; + this.vfsName = options.name || optionDefaults.name; + this.#cVfs = createOpfsVfs(this.vfsName); + setPoolForVfs(this.#cVfs.pointer, this); + this.vfsDir = options.directory || ("."+this.vfsName); + this.#dvBody = + new DataView(this.#apBody.buffer, this.#apBody.byteOffset); + this.isReady = this + .reset(!!(options.clearOnInit ?? optionDefaults.clearOnInit)) + .then(()=>{ + if(this.$error) throw this.$error; + return this.getCapacity() + ? Promise.resolve(undefined) + : this.addCapacity(options.initialCapacity + || optionDefaults.initialCapacity); + }); + } + + #logImpl(level,...args){ + if(this.#verbosity>level) loggers[level](this.vfsName+":",...args); + }; + log(...args){this.#logImpl(2, ...args)}; + warn(...args){this.#logImpl(1, ...args)}; + error(...args){this.#logImpl(0, ...args)}; + + + getVfs(){return this.#cVfs} + + + getCapacity(){return this.#mapSAHToName.size} + + + getFileCount(){return this.#mapFilenameToSAH.size} + + + getFileNames(){ + const rc = []; + for(const n of this.#mapFilenameToSAH.keys()) rc.push(n); + return rc; + } + + + async addCapacity(n){ + for(let i = 0; i < n; ++i){ + const name = getRandomName(); + const h = await this.#dhOpaque.getFileHandle(name, {create:true}); + const ah = await h.createSyncAccessHandle(); + this.#mapSAHToName.set(ah,name); + this.setAssociatedPath(ah, '', 0); + + } + return this.getCapacity(); + } + + + async reduceCapacity(n){ + if(!Number.isSafeInteger(n) || n < 0){ + toss('Invalid capacity reduction:', n); + } + let nRm = 0; + for(const ah of Array.from(this.#availableSAH)){ + if(nRm === n || this.getFileCount() === this.getCapacity()){ + break; + } + const name = this.#mapSAHToName.get(ah); + ah.close(); + this.#mapSAHToName.delete(ah); + this.#availableSAH.delete(ah); + await this.#dhOpaque.removeEntry(name); + ++nRm; + } + return nRm; + } + + + releaseAccessHandles(){ + for(const ah of this.#mapSAHToName.keys()) ah.close(); + this.#mapSAHToName.clear(); + this.#mapFilenameToSAH.clear(); + this.#availableSAH.clear(); + this.#mapS3FileToOFile.clear(); + this.#mapPathToLock.clear(); + } + + + async acquireAccessHandles(clearFiles=false){ + const files = []; + for await (const [name,h] of this.#dhOpaque){ + if('file'===h.kind){ + files.push([name,h]); + } + } + const results = await Promise.allSettled(files.map(async([name,h])=>{ + const ah = await h.createSyncAccessHandle() + this.#mapSAHToName.set(ah, name); + if(clearFiles){ + ah.truncate(HEADER_OFFSET_DATA); + this.setAssociatedPath(ah, '', 0); + }else{ + const path = this.getAssociatedPath(ah); + if(path){ + this.#mapFilenameToSAH.set(path, ah); + }else{ + this.#availableSAH.add(ah); + } + } + })); + const failure = results.find((r)=>'rejected'===r.status) + ; + if( failure ){ + this.storeErr(failure.reason); + this.releaseAccessHandles(); + throw failure.reason; + } + } + + + getAssociatedPath(sah){ + sah.read(this.#apBody, {at: 0}); + + + const flags = this.#dvBody.getUint32(HEADER_OFFSET_FLAGS); + if(this.#apBody[0] && + ((flags & capi.SQLITE_OPEN_DELETEONCLOSE) || + (flags & PERSISTENT_FILE_TYPES)===0)){ + warn(`Removing file with unexpected flags ${flags.toString(16)}`, + this.#apBody); + this.setAssociatedPath(sah, '', 0); + return ''; + } + + const fileDigest = new Uint32Array(HEADER_DIGEST_SIZE / 4); + sah.read(fileDigest, {at: HEADER_OFFSET_DIGEST}); + const compDigest = this.computeDigest(this.#apBody, flags); + + if(fileDigest.every((v,i) => v===compDigest[i])){ + + const pathBytes = this.#apBody.findIndex((v)=>0===v); + if(0===pathBytes){ + + + sah.truncate(HEADER_OFFSET_DATA); + } + + return pathBytes + ? textDecoder.decode(this.#apBody.subarray(0,pathBytes)) + : ''; + }else{ + + warn('Disassociating file with bad digest.'); + this.setAssociatedPath(sah, '', 0); + return ''; + } + } + + + setAssociatedPath(sah, path, flags){ + const enc = textEncoder.encodeInto(path, this.#apBody); + if(HEADER_MAX_PATH_SIZE <= enc.written + 1){ + toss("Path too long:",path); + } + if(path && flags){ + + flags |= FLAG_COMPUTE_DIGEST_V2; + } + this.#apBody.fill(0, enc.written, HEADER_MAX_PATH_SIZE); + this.#dvBody.setUint32(HEADER_OFFSET_FLAGS, flags); + const digest = this.computeDigest(this.#apBody, flags); + + sah.write(this.#apBody, {at: 0}); + sah.write(digest, {at: HEADER_OFFSET_DIGEST}); + sah.flush(); + + if(path){ + this.#mapFilenameToSAH.set(path, sah); + this.#availableSAH.delete(sah); + }else{ + + sah.truncate(HEADER_OFFSET_DATA); + this.#availableSAH.add(sah); + } + } + + + computeDigest(byteArray, fileFlags){ + if( fileFlags & FLAG_COMPUTE_DIGEST_V2 ){ + let h1 = 0xdeadbeef; + let h2 = 0x41c6ce57; + for(const v of byteArray){ + h1 = Math.imul(h1 ^ v, 2654435761); + h2 = Math.imul(h2 ^ v, 104729); + } + return new Uint32Array([h1>>>0, h2>>>0]); + }else{ + + return new Uint32Array([0,0]); + } + } + + + async reset(clearFiles){ + await this.isReady; + let h = await navigator.storage.getDirectory(); + let prev, prevName; + for(const d of this.vfsDir.split('/')){ + if(d){ + prev = h; + h = await h.getDirectoryHandle(d,{create:true}); + } + } + this.#dhVfsRoot = h; + this.#dhVfsParent = prev; + this.#dhOpaque = await this.#dhVfsRoot.getDirectoryHandle( + OPAQUE_DIR_NAME,{create:true} + ); + this.releaseAccessHandles(); + return this.acquireAccessHandles(clearFiles); + } + + + getPath(arg) { + if(wasm.isPtr(arg)) arg = wasm.cstrToJs(arg); + return ((arg instanceof URL) + ? arg + : new URL(arg, 'file://localhost/')).pathname; + } + + + deletePath(path) { + const sah = this.#mapFilenameToSAH.get(path); + if(sah) { + + this.#mapFilenameToSAH.delete(path); + this.setAssociatedPath(sah, '', 0); + } + return !!sah; + } + + + storeErr(e,code){ + if(e){ + e.sqlite3Rc = code || capi.SQLITE_IOERR; + this.error(e); + } + this.$error = e; + return code; + } + + popErr(){ + const rc = this.$error; + this.$error = undefined; + return rc; + } + + + nextAvailableSAH(){ + const [rc] = this.#availableSAH.keys(); + return rc; + } + + + getOFileForS3File(pFile){ + return this.#mapS3FileToOFile.get(pFile); + } + + mapS3FileToOFile(pFile,file){ + if(file){ + this.#mapS3FileToOFile.set(pFile, file); + setPoolForPFile(pFile, this); + }else{ + this.#mapS3FileToOFile.delete(pFile); + setPoolForPFile(pFile, false); + } + } + + + hasFilename(name){ + return this.#mapFilenameToSAH.has(name) + } + + + getSAHForPath(path){ + return this.#mapFilenameToSAH.get(path); + } + + + async removeVfs(){ + if(!this.#cVfs.pointer || !this.#dhOpaque) return false; + capi.sqlite3_vfs_unregister(this.#cVfs.pointer); + this.#cVfs.dispose(); + delete initPromises[this.vfsName]; + try{ + this.releaseAccessHandles(); + await this.#dhVfsRoot.removeEntry(OPAQUE_DIR_NAME, {recursive: true}); + this.#dhOpaque = undefined; + await this.#dhVfsParent.removeEntry( + this.#dhVfsRoot.name, {recursive: true} + ); + this.#dhVfsRoot = this.#dhVfsParent = undefined; + }catch(e){ + sqlite3.config.error(this.vfsName,"removeVfs() failed with no recovery strategy:",e); + + } + return true; + } + + + pauseVfs(){ + if(this.#mapS3FileToOFile.size>0){ + sqlite3.SQLite3Error.toss( + capi.SQLITE_MISUSE, "Cannot pause VFS", + this.vfsName,"because it has opened files." + ); + } + if(this.#mapSAHToName.size>0){ + capi.sqlite3_vfs_unregister(this.vfsName); + this.releaseAccessHandles(); + } + return this; + } + + + isPaused(){ + return 0===this.#mapSAHToName.size; + } + + + async unpauseVfs(){ + if(0===this.#mapSAHToName.size){ + return this.acquireAccessHandles(false). + then(()=>capi.sqlite3_vfs_register(this.#cVfs, 0),this); + } + return this; + } + + + exportFile(name){ + const sah = this.#mapFilenameToSAH.get(name) || toss("File not found:",name); + const n = sah.getSize() - HEADER_OFFSET_DATA; + const b = new Uint8Array(n>0 ? n : 0); + if(n>0){ + const nRead = sah.read(b, {at: HEADER_OFFSET_DATA}); + if(nRead != n){ + toss("Expected to read "+n+" bytes but read "+nRead+"."); + } + } + return b; + } + + + async importDbChunked(name, callback){ + const sah = this.#mapFilenameToSAH.get(name) + || this.nextAvailableSAH() + || toss("No available handles to import to."); + sah.truncate(0); + let nWrote = 0, chunk, checkedHeader = false, err = false; + try{ + while( undefined !== (chunk = await callback()) ){ + if(chunk instanceof ArrayBuffer) chunk = new Uint8Array(chunk); + if( !checkedHeader && 0===nWrote && chunk.byteLength>=15 ){ + util.affirmDbHeader(chunk); + checkedHeader = true; + } + const n = sah.write(chunk, {at: HEADER_OFFSET_DATA + nWrote}); + if( n!==chunk.byteLength ){ + toss("Expected to write "+chunk.byteLength+ + "bytes but wrote"+n+"."); + } + nWrote += chunk.byteLength; + } + if( nWrote < 512 || 0!==nWrote % 512 ){ + toss("Input size",nWrote,"is not correct for an SQLite database."); + } + if( !checkedHeader ){ + const header = new Uint8Array(20); + sah.read( header, {at: HEADER_OFFSET_DATA} ); + util.affirmDbHeader( header ); + } + sah.write(new Uint8Array([1,1]), { + at: HEADER_OFFSET_DATA + 18 + }); + }catch(e){ + this.setAssociatedPath(sah, '', 0); + throw e; + } + this.setAssociatedPath(sah, name, capi.SQLITE_OPEN_MAIN_DB); + return nWrote; + } + + + importDb(name, bytes){ + if( bytes instanceof ArrayBuffer ) bytes = new Uint8Array(bytes); + else if( bytes instanceof Function ) return this.importDbChunked(name, bytes); + util.affirmIsDb(bytes); + const sah = this.#mapFilenameToSAH.get(name) + || this.nextAvailableSAH() + || toss("No available handles to import to."); + const n = bytes.byteLength; + const nWrote = sah.write(bytes, {at: HEADER_OFFSET_DATA}); + if(nWrote != n){ + this.setAssociatedPath(sah, '', 0); + toss("Expected to write "+n+" bytes but wrote "+nWrote+"."); + }else{ + sah.truncate(HEADER_OFFSET_DATA + n + ); + sah.write(new Uint8Array([1,1]), {at: HEADER_OFFSET_DATA+18} + ); + this.setAssociatedPath(sah, name, capi.SQLITE_OPEN_MAIN_DB); + } + return nWrote; + } + + + hasReservedLock(filename){ + const lk = this.#mapPathToLock.get(filename); + return !!lk && lk.lockType>capi.SQLITE_LOCK_SHARED; + } + + + lockFile(file, lockType){ + if(file.lockType>=lockType) return 0; + let lk = this.#mapPathToLock.get(file.path); + if(!lk){ + lk = Object.assign(Object.create(null),{ + nShared: 0, lockType: capi.SQLITE_LOCK_NONE + }); + this.#mapPathToLock.set(file.path, lk); + } + if(file.lockType!==lk.lockType + && (lk.lockType>=capi.SQLITE_LOCK_PENDING + || lockType>capi.SQLITE_LOCK_SHARED)){ + return capi.SQLITE_BUSY; + } + if(lockType===capi.SQLITE_LOCK_SHARED){ + ++lk.nShared; + if(lk.lockType===capi.SQLITE_LOCK_NONE) lk.lockType = lockType; + }else if(lockType===capi.SQLITE_LOCK_EXCLUSIVE && lk.nShared>1){ + if(file.lockType===capi.SQLITE_LOCK_RESERVED){ + file.lockType = lk.lockType = capi.SQLITE_LOCK_PENDING; + } + return capi.SQLITE_BUSY; + }else{ + lk.lockType = lockType; + } + file.lockType = lockType; + return 0; + } + + + unlockFile(file, lockType){ + if(file.lockType<=lockType) return; + const lk = this.#mapPathToLock.get(file.path); + if(file.lockType>capi.SQLITE_LOCK_SHARED){ + lk.lockType = capi.SQLITE_LOCK_SHARED; + } + if(lockType===capi.SQLITE_LOCK_NONE && 0===--lk.nShared + ){ + this.#mapPathToLock.delete(file.path); + } + file.lockType = lockType; + } + + }; + + + class OpfsSAHPoolUtil { + + #p; + + constructor(sahPool){ + this.#p = sahPool; + this.vfsName = sahPool.vfsName; + } + + async addCapacity(n){ return this.#p.addCapacity(n) } + + async reduceCapacity(n){ return this.#p.reduceCapacity(n) } + + getCapacity(){ return this.#p.getCapacity(this.#p) } + + getFileCount(){ return this.#p.getFileCount() } + getFileNames(){ return this.#p.getFileNames() } + + async reserveMinimumCapacity(min){ + const c = this.#p.getCapacity(); + return (c < min) ? this.#p.addCapacity(min - c) : c; + } + + exportFile(name){ return this.#p.exportFile(name) } + + importDb(name, bytes){ return this.#p.importDb(name,bytes) } + + async wipeFiles(){ return this.#p.reset(true) } + + unlink(filename){ return this.#p.deletePath(filename) } + + async removeVfs(){ return this.#p.removeVfs() } + + pauseVfs(){ this.#p.pauseVfs(); return this; } + async unpauseVfs(){ return this.#p.unpauseVfs().then(()=>this); } + isPaused(){ return this.#p.isPaused() } + + }; + + + const apiVersionCheck = async ()=>{ + const dh = await navigator.storage.getDirectory(); + const fn = '.opfs-sahpool-sync-check-'+getRandomName(); + const fh = await dh.getFileHandle(fn, { create: true }); + const ah = await fh.createSyncAccessHandle(); + const close = ah.close(); + await close; + await dh.removeEntry(fn); + if(close?.then){ + toss("The local OPFS API is too old for opfs-sahpool:", + "it has an async FileSystemSyncAccessHandle.close() method."); + } + return true; + }; + + + sqlite3.installOpfsSAHPoolVfs = async function(options=Object.create(null)){ + options = Object.assign(Object.create(null), optionDefaults, (options||{})); + const vfsName = options.name; + if(options.$testThrowPhase1){ + throw options.$testThrowPhase1; + } + if(initPromises[vfsName]){ + try { + const p = await initPromises[vfsName]; + + return p; + }catch(e){ + + if( options.forceReinitIfPreviouslyFailed ){ + delete initPromises[vfsName]; + + }else{ + throw e; + } + } + } + if(!globalThis.FileSystemHandle || + !globalThis.FileSystemDirectoryHandle || + !globalThis.FileSystemFileHandle || + !globalThis.FileSystemFileHandle.prototype.createSyncAccessHandle || + !navigator?.storage?.getDirectory){ + return (initPromises[vfsName] = Promise.reject(new Error("Missing required OPFS APIs."))); + } + + + const unregisterVfs = (thePool)=>{ + const cVfs = thePool.getVfs(); + if( cVfs?.pointer ){ + capi.sqlite3_vfs_unregister(cVfs.pointer); + cVfs.dispose(); + thePool.releaseAccessHandles(); + } + }; + + + return initPromises[vfsName] = apiVersionCheck().then(async function(){ + if(options.$testThrowPhase2){ + throw options.$testThrowPhase2; + } + const thePool = new OpfsSAHPool(options); + return thePool.isReady.then(async()=>{ + + const poolUtil = new OpfsSAHPoolUtil(thePool); + if(sqlite3.oo1){ + const oo1 = sqlite3.oo1; + const theVfs = thePool.getVfs(); + const OpfsSAHPoolDb = function(...args){ + const opt = oo1.DB.dbCtorHelper.normalizeArgs(...args); + opt.vfs = theVfs.$zName; + oo1.DB.dbCtorHelper.call(this, opt); + }; + OpfsSAHPoolDb.prototype = Object.create(oo1.DB.prototype); + poolUtil.OpfsSAHPoolDb = OpfsSAHPoolDb; + } + thePool.log("VFS initialized."); + return poolUtil; + }).catch(async (e)=>{ + if( options.preserveOnInitFailure ){ + unregisterVfs(thePool); + }else{ + await thePool.removeVfs().catch(()=>{}); + } + throw e; + }); + }).catch((err)=>{ + + return initPromises[vfsName] = Promise.reject(err); + }); + }; +}); + +'use strict'; +globalThis.sqlite3ApiBootstrap.initializers.push(function(sqlite3){ + if( !sqlite3.opfs || sqlite3.config.disable?.vfs?.['opfs-wl'] ){ + return; + } + const util = sqlite3.util, + toss = sqlite3.util.toss; + const opfsUtil = sqlite3.opfs; + const vfsName = 'opfs-wl'; + +const installOpfsWlVfs = async function(options){ + options = opfsUtil.initOptions(vfsName,options); + if( !options ) return sqlite3; + const capi = sqlite3.capi, + state = opfsUtil.createVfsState(), + opfsVfs = state.vfs, + metrics = opfsVfs.metrics.counters, + mTimeStart = opfsVfs.mTimeStart, + mTimeEnd = opfsVfs.mTimeEnd, + opRun = opfsVfs.opRun, + debug = (...args)=>sqlite3.config.debug(vfsName+":",...args), + warn = (...args)=>sqlite3.config.warn(vfsName+":",...args), + __openFiles = opfsVfs.__openFiles; + + + + return opfsVfs.bindVfs(util.nu({ + xLock: function(pFile,lockType){ + mTimeStart('xLock'); + + const f = __openFiles[pFile]; + const rc = opRun('xLock', pFile, lockType); + if( !rc ) f.lockType = lockType; + mTimeEnd(); + return rc; + }, + xUnlock: function(pFile,lockType){ + mTimeStart('xUnlock'); + const f = __openFiles[pFile]; + const rc = opRun('xUnlock', pFile, lockType); + if( !rc ) f.lockType = lockType; + mTimeEnd(); + return rc; + } + }), function(sqlite3, vfs){ + + if(sqlite3.oo1){ + const OpfsWlDb = function(...args){ + const opt = sqlite3.oo1.DB.dbCtorHelper.normalizeArgs(...args); + opt.vfs = vfs.$zName; + sqlite3.oo1.DB.dbCtorHelper.call(this, opt); + }; + OpfsWlDb.prototype = Object.create(sqlite3.oo1.DB.prototype); + sqlite3.oo1.OpfsWlDb = OpfsWlDb; + OpfsWlDb.importDb = opfsUtil.importDb; + + } + }); +}; +globalThis.sqlite3ApiBootstrap.initializersAsync.push(async (sqlite3)=>{ + return installOpfsWlVfs().catch((e)=>{ + sqlite3.config.warn("Ignoring inability to install the",vfsName,"sqlite3_vfs:",e); + }); +}); +}); + +try{ + + + + const bootstrapConfig = Object.assign( + Object.create(null), + + { + memory: ('undefined'!==typeof wasmMemory) + ? wasmMemory + : EmscriptenModule['wasmMemory'], + exports: ('undefined'!==typeof wasmExports) + ? wasmExports + : (Object.prototype.hasOwnProperty.call(EmscriptenModule,'wasmExports') + ? EmscriptenModule['wasmExports'] + : EmscriptenModule['asm']) + }, + globalThis.sqlite3ApiBootstrap.defaultConfig, + globalThis.sqlite3ApiConfig || {} + ); + + sqlite3InitScriptInfo.debugModule("Bootstrapping lib config", bootstrapConfig); + + + const p = globalThis.sqlite3ApiBootstrap(bootstrapConfig); + delete globalThis.sqlite3ApiBootstrap; + return p ; +}catch(e){ + console.error("sqlite3ApiBootstrap() error:",e); + throw e; +} + + +}; + + + + + + + + + + + + return Module; +} + + + + + +const toExportForESM = +(function(){ + + + const originalInit = sqlite3InitModule; + if(!originalInit){ + throw new Error("Expecting sqlite3InitModule to be defined by the Emscripten build."); + } + + const sIMS = globalThis.sqlite3InitModuleState = Object.assign(Object.create(null),{ + moduleScript: globalThis?.document?.currentScript, + isWorker: ('undefined' !== typeof WorkerGlobalScope), + location: globalThis.location, + urlParams: globalThis?.location?.href + ? new URL(globalThis.location.href).searchParams + : new URLSearchParams(), + + wasmFilename: 'sqlite3.wasm' + }); + sIMS.debugModule = + sIMS.urlParams.has('sqlite3.debugModule') + ? (...args)=>console.warn('sqlite3.debugModule:',...args) + : ()=>{}; + + if(sIMS.urlParams.has('sqlite3.dir')){ + sIMS.sqlite3Dir = sIMS.urlParams.get('sqlite3.dir') +'/'; + }else if(sIMS.moduleScript){ + const li = sIMS.moduleScript.src.split('/'); + li.pop(); + sIMS.sqlite3Dir = li.join('/') + '/'; + } + + const sIM = globalThis.sqlite3InitModule = function ff(...args){ + + sIMS.emscriptenLocateFile = args[0]?.locateFile ; + sIMS.emscriptenInstantiateWasm = args[0]?.instantiateWasm ; + return originalInit(...args).then((EmscriptenModule)=>{ + sIMS.debugModule("sqlite3InitModule() sIMS =",sIMS); + sIMS.debugModule("sqlite3InitModule() EmscriptenModule =",EmscriptenModule); + const s = EmscriptenModule.runSQLite3PostLoadInit( + sIMS, + EmscriptenModule , + !!ff.__isUnderTest + ); + sIMS.debugModule("sqlite3InitModule() sqlite3 =",s); + + + return s; + }).catch((e)=>{ + console.error("Exception loading sqlite3 module:",e); + throw e; + }); + }; + sIM.ready = originalInit.ready; + + if(sIMS.moduleScript){ + let src = sIMS.moduleScript.src.split('/'); + src.pop(); + sIMS.scriptDir = src.join('/') + '/'; + } + sIMS.debugModule('extern-post-js.c-pp.js sqlite3InitModuleState =',sIMS); + return sIM; +})(); +sqlite3InitModule = toExportForESM; +export default sqlite3InitModule; diff --git a/vendor/sqlite3mc/2.6.1-3.54.0/sqlite3.wasm b/vendor/sqlite3mc/2.6.1-3.54.0/sqlite3.wasm new file mode 100644 index 0000000000000000000000000000000000000000..97011627911de83621a42e260e7a0dcc9ae40c2e GIT binary patch literal 1072003 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

Bc+`{!!J4f;K64o@9Er@eaq;5i9FzLVZ+ z3lXmG#yzSMa0t;R;}mO)r*7(2>j`q)6pv)lY+Tsl@DheKYsi(s`B5epV^I!-e%NR@ zltCNEYpa@`9e}XVRG~v16b&(+et+}50dJwzm)CVUrkemxOHaF*52h5 z1(jcl(|(_aM2DWKbWNVl`k!Tl$cB*hY-$u@LoISL{0ILB`0OLfuX*XQksnJf#^ebq zL6hX*8}%ET)3pndh~XO1_Oo-evxglL9ds!3tHNcY^O7u8 zGAivGG4IF$$k{L#vPIG6F6k=Fgh0@`R2` zvEzi!IqAnYd3KH#z>KoQ=pR27i70O_2hi-#@JxQR6NI$CfS;*e!J%Pwho#K~p{&_9 zfJWnF=Wac-v+lh(a_?FjMQnEx$j-*B(AvSPjFZk^RB=SB3G?$8OrWg()(-(iDtkfS%?W(~7UNL8+6Jg7y22`3!7W zmz|3kX2R5tQUlQ^g%gLt zvk9>A|FEy^tz!2a?8nufKL+=`p+*nQu%z_;^hq!eD$af;4oIlO9r zdAjf2PfKuNpzGjQTuTm&Op7ZK>pNZzst{hx!b|8Gp-aPm8mIg5<)aBC;su7)!7UIr znK76^)OG5Q{4N38y9A8zFHg{vel?aUMf7+3MtIoeH}N=}zqwn=~h^;qYzcLwEbtE<)nJrsP;daM(1+j%#f1wxKv%OY%{^d5fMPCy^D4dmPh=VoD{BLG zR$(=_`TS_Hb7HxA`7e~sk-Q=BL&>Hr0{ev|m_(1TR~rO4hgwBEPzSs*@oaCK7hBqi zR{TMd1O{A|Z< z3-4@OxZ^W^duO!ZV4UEDUUWrf9T^pu&9(bGY?T&$lxN`O@$EJz0q7oHz95xVJlvCq zWL$3E9w~+pju4-@6fTlk$g5#BMDmIIUHdd|na1uy{F*M;Wt(-`p23s2Y$pz$0W}cO zW`$-FIbb)59E4vaLrNy`4W!(+4WDjb*hKzW<+zLTTL^JyJg+yS4w;B<#N-<#YGsNZ zo#AlUO|pZ3_&UR@8?0KG^55f#QtRHMY~1-D?PN&obZrP=6j z^0vtcRTG6Jx6+Qg<+tI3MO~lQ{GJTC4cvf1nIEGsH!gnTX%cga2$cUT^cYbb5OJ>P z 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 #7__N5(#+AE9NaXA?BVOc6L6OJ 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 U<5B)S$O=cc+9Ixkw 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 z

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

} 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

?R;@0&rX@IuP9{KA~6W54ewibCq1~T0O}r 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 z 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

Nevmb40GwDP@4#!|U;;&C`AI7n~4igO0p;tev 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 z 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 BdZGXT literal 0 HcmV?d00001 From a30f3f480a2537386395326798ecf1ef2b002f9a Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 04:57:58 +0100 Subject: [PATCH 02/16] fix: keep the vendored database engine byte-exact on Windows checkouts --- .gitattributes | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitattributes b/.gitattributes index c9944983b..b944ef477 100644 --- a/.gitattributes +++ b/.gitattributes @@ -4,3 +4,5 @@ .husky/* text eol=lf # Demo fixtures record exact byte sizes of these files; never normalise them. public/demo/** -text +# Checked against pinned hashes by scripts/fetch-sqlite3mc.mjs; never normalise them. +vendor/sqlite3mc/** -text From bad156eaed84381c9d3236b65b439b9b41d76854 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 04:59:39 +0100 Subject: [PATCH 03/16] refactor: let each part of the local store expose its own calls to the page --- lib/store/__tests__/mail-sync.test.ts | 29 ++++++++++++++++++++++- lib/store/area.ts | 8 +++++++ lib/store/client/store-client.ts | 4 ++++ lib/store/engine/account-engine.ts | 34 +++++++++++++++++++++------ lib/store/worker/host.ts | 6 +++++ lib/store/worker/protocol.ts | 2 ++ 6 files changed, 75 insertions(+), 8 deletions(-) diff --git a/lib/store/__tests__/mail-sync.test.ts b/lib/store/__tests__/mail-sync.test.ts index dd8b70728..80e1c69fe 100644 --- a/lib/store/__tests__/mail-sync.test.ts +++ b/lib/store/__tests__/mail-sync.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'; import { mkdtempSync, rmSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { DEFAULT_SCOPE, type StoreEvent } from '../area'; +import { DEFAULT_SCOPE, type StoreContext, type StoreEvent } from '../area'; import { AccountEngine } from '../engine/account-engine'; import { runChangesLoop } from '../engine/changes'; import { TransportError, type MethodCall } from '../engine/transport'; @@ -320,6 +320,33 @@ describe('when the server is busy or gone', () => { }); }); +describe('calls into an area', () => { + const noteArea = { + name: 'note', + migrations: [{ id: 9999, name: 'note', sql: 'CREATE TABLE note (id INTEGER PRIMARY KEY, text TEXT)' }], + methods: {}, + syncers: [], + calls: { + 'note.add': (ctx: StoreContext, params: { text: string }) => ctx.db.run('INSERT INTO note (text) VALUES (?)', [params.text]).lastInsertRowid, + }, + }; + + it('reaches the area and runs its migration', async () => { + const engine = new AccountEngine({ + account: 'a@fake.test', db: openMemoryStore(), session: FAKE_SESSION, transport: new FakeJmap(), areas: [mailArea, noteArea], scope: EVERYTHING, + }); + expect(await engine.areaCall('note.add', { text: 'hello' })).toBe(1); + await expect(engine.areaCall('note.nothing', {})).rejects.toThrow('Unknown store call'); + }); + + it('refuses two areas defining the same name', () => { + expect(() => new AccountEngine({ + account: 'a@fake.test', db: openMemoryStore(), session: FAKE_SESSION, transport: new FakeJmap(), + areas: [noteArea, { ...noteArea, name: 'other', migrations: [] }], scope: EVERYTHING, + })).toThrow('Two store areas define note.add'); + }); +}); + describe('answers from the store', () => { async function seeded() { const server = new FakeJmap(); diff --git a/lib/store/area.ts b/lib/store/area.ts index fa757e37d..fceed110e 100644 --- a/lib/store/area.ts +++ b/lib/store/area.ts @@ -93,9 +93,17 @@ export interface MethodContext extends StoreContext { export type MethodHandler = (ctx: MethodContext, args: Record, call: MethodCall) => MethodResult | Promise; +/** + * A call from the page that isn't JMAP: outbox rows, usage, clearing. Reached + * from any tab with `store.area(account, 'outbox.list', params)`. Names are + * unique across areas and start with the area's name. + */ +export type AreaCall = (ctx: StoreContext, params: never) => unknown | Promise; + export interface StoreArea { name: string; migrations: readonly Migration[]; methods: Readonly>; syncers: readonly Syncer[]; + calls?: Readonly>; } diff --git a/lib/store/client/store-client.ts b/lib/store/client/store-client.ts index 2b47961a6..5ae621748 100644 --- a/lib/store/client/store-client.ts +++ b/lib/store/client/store-client.ts @@ -258,4 +258,8 @@ export class StoreClient { status(account: string): Promise { return this.rpc('status', { account }); } + + area(account: string, name: string, params?: unknown): Promise { + return this.rpc('area', { account, name, params }) as Promise; + } } diff --git a/lib/store/engine/account-engine.ts b/lib/store/engine/account-engine.ts index 3b460e3a0..6281afc71 100644 --- a/lib/store/engine/account-engine.ts +++ b/lib/store/engine/account-engine.ts @@ -4,7 +4,7 @@ * the UI. Nothing here touches the DOM, so tests run it on node:sqlite. */ -import { MethodError, type MethodHandler, type StoreArea, type StoreEvent, type StoreScope, type SyncContext, type SyncStateRow, type Syncer } from '../area'; +import { MethodError, type AreaCall, type MethodHandler, type StoreArea, type StoreContext, type StoreEvent, type StoreScope, type SyncContext, type SyncStateRow, type Syncer } from '../area'; import { migrate, type StorageAdapter } from '../storage'; import { answerRequest, buildMethodTable } from './responder'; import { TransportError, type JmapResponse, type JmapTransport, type MethodCall, type StoreSession } from './transport'; @@ -35,6 +35,7 @@ export class AccountEngine { private readonly transport: JmapTransport; private readonly syncers: readonly Syncer[]; private readonly methods: ReadonlyMap; + private readonly calls = new Map(); private readonly emitter: (event: StoreEvent) => void; private readonly now: () => number; private readonly sleep: (ms: number) => Promise; @@ -52,6 +53,12 @@ export class AccountEngine { this.transport = options.transport; this.syncers = options.areas.flatMap((area) => area.syncers); this.methods = buildMethodTable(options.areas); + for (const area of options.areas) { + for (const [name, call] of Object.entries(area.calls ?? {})) { + if (this.calls.has(name)) throw new Error(`Two store areas define ${name}`); + this.calls.set(name, call); + } + } this.emitter = options.emit ?? (() => {}); this.now = options.now ?? Date.now; this.sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))); @@ -195,18 +202,31 @@ export class AccountEngine { }; } + private storeContext(): StoreContext { + return { + account: this.account, + db: this.db, + session: this.session, + scope: this.scope, + now: this.now, + emit: this.emitter, + }; + } + + /** A non-JMAP call into one of the areas. */ + async areaCall(name: string, params: unknown): Promise { + const call = this.calls.get(name); + if (!call) throw new Error(`Unknown store call ${name}`); + return call(this.storeContext(), params as never); + } + /** An answer from the device. Null when nothing in the request could be answered. */ async answer(calls: MethodCall[]): Promise { if (this.closed) throw new MethodError('serverUnavailable', 'The local store is closed'); const primary = this.session.primaryAccounts['urn:ietf:params:jmap:mail'] ?? Object.keys(this.session.accounts)[0]; return answerRequest( { - account: this.account, - db: this.db, - session: this.session, - scope: this.scope, - now: this.now, - emit: this.emitter, + ...this.storeContext(), defaultJmap: primary, invertedKeywordSort: this.invertedKeywordSort, }, diff --git a/lib/store/worker/host.ts b/lib/store/worker/host.ts index 25e63e270..636c231c9 100644 --- a/lib/store/worker/host.ts +++ b/lib/store/worker/host.ts @@ -149,6 +149,12 @@ export class StoreHost { const { account } = params as Parameters[0]; return this.status(account); } + case 'area': { + const { account, name, params: callParams } = params as Parameters[0]; + const open = this.accounts.get(account); + if (!open) throw new Error('The local store is not open for this account'); + return open.engine.areaCall(name, callParams); + } default: throw new Error(`Unknown store call ${String(method)}`); } diff --git a/lib/store/worker/protocol.ts b/lib/store/worker/protocol.ts index 8b3257648..ed5a08f96 100644 --- a/lib/store/worker/protocol.ts +++ b/lib/store/worker/protocol.ts @@ -26,6 +26,8 @@ export interface StoreRpc { noteStateChange(params: { account: string; changed: Record> }): void; setOnline(params: { online: boolean }): void; status(params: { account: string }): StoreStatus | null; + /** A non-JMAP call into an area of the store. */ + area(params: { account: string; name: string; params: unknown }): unknown; } export interface StoreStatus { From c65d18eaaeb23253eab7bedf19d221f5534df479 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 05:26:01 +0100 Subject: [PATCH 04/16] feat: open mail, calendar and contact files from the OS and share into a new draft The installed app only handled mailto: and webcal: launches. The launch consumer dropped files, and nothing could hand a File to the composer or the import dialogs, because the pending mailto is JSON in sessionStorage. - manifest: `shortcuts` named in the browser's language, `file_handlers` for .eml, .ics and .vcf, and `share_target`, all with the base path and per-domain branding. Lite's manifest is unchanged. - `lib/launch-handoff.ts`: the one place files and links wait, in memory and in the Cache API, so they survive a sign-in that leaves the page. The desktop bridge can call the same functions, since launchQueue never fires in Electron. Sign-out clears it. - launch consumer: routes files by type, and takes a shortcut to an open window, which focus-existing would otherwise only focus. - .eml opens in a new host for the preview, also before sign-in, with the strictest device policy deciding about saving. .ics and .vcf open their import dialogs through a new `initialFile` prop. A .ics needs the server, so offline the dialog says so and carries on once back online. - composer: `initialFiles` goes through `addFiles`, so plugin vetoes and size limits still apply, also from a Pro compose tab. - `sw.js` takes the share POST before the offline check and keeps the files for the page. Without a worker, `/protocol/share` passes text and links on and the page says the files are lost. `protocol` joins the app sections and the handler pages join the shell, so a mailto: launch offline no longer gets a network error. Browsers without launchQueue or a service worker behave as before. --- app/(main)/protocol/share/route.ts | 51 +++ app/manifest.ts | 61 ++- .../ical-import-initial-file.test.tsx | 110 +++++ components/calendar/calendar-app.tsx | 15 + components/calendar/ical-import-modal.tsx | 51 ++- .../contact-import-initial-file.test.tsx | 62 +++ components/contacts/contact-import-dialog.tsx | 26 +- components/contacts/contacts-app.tsx | 18 + .../composer-attach-from-files.test.tsx | 56 +++ components/email/email-composer.tsx | 16 + components/mail/mail-app.tsx | 75 +++- components/pro/pro-compose-tab-body.tsx | 1 + .../protocol-launch-handler-provider.test.tsx | 287 ++++++++++++++ components/protocol/launch-file-host.tsx | 62 +++ .../protocol-launch-handler-provider.tsx | 98 ++++- hooks/use-handoff-files.ts | 28 ++ lib/__tests__/launch-handoff.test.ts | 346 ++++++++++++++++ lib/__tests__/manifest-launch.test.ts | 200 ++++++++++ lib/__tests__/sign-out-cleanup.test.ts | 8 + lib/__tests__/sw-harness.ts | 6 +- lib/__tests__/sw-share-target.test.ts | 152 +++++++ lib/launch-handoff.ts | 375 ++++++++++++++++++ lib/offline/__tests__/shell-pages.test.ts | 26 ++ lib/offline/shell.ts | 2 +- lib/sign-out-cleanup.ts | 3 + locales/ar/common.json | 11 + locales/ca/common.json | 11 + locales/cs/common.json | 11 + locales/da/common.json | 11 + locales/de/common.json | 11 + locales/en/common.json | 11 + locales/es/common.json | 11 + locales/et/common.json | 11 + locales/fa/common.json | 11 + locales/fr/common.json | 11 + locales/he/common.json | 11 + locales/hu/common.json | 11 + locales/it/common.json | 11 + locales/ja/common.json | 11 + locales/ko/common.json | 11 + locales/lv/common.json | 11 + locales/mn/common.json | 11 + locales/nb/common.json | 11 + locales/nl/common.json | 11 + locales/pl/common.json | 11 + locales/pt/common.json | 11 + locales/ro/common.json | 11 + locales/ru/common.json | 11 + locales/sk/common.json | 11 + locales/th/common.json | 11 + locales/tr/common.json | 11 + locales/uk/common.json | 11 + locales/vi/common.json | 11 + locales/zh-TW/common.json | 11 + locales/zh/common.json | 11 + public/sw.js | 56 ++- stores/pro-tab-store.ts | 2 + 57 files changed, 2490 insertions(+), 33 deletions(-) create mode 100644 app/(main)/protocol/share/route.ts create mode 100644 components/calendar/__tests__/ical-import-initial-file.test.tsx create mode 100644 components/contacts/__tests__/contact-import-initial-file.test.tsx create mode 100644 components/protocol/__tests__/protocol-launch-handler-provider.test.tsx create mode 100644 components/protocol/launch-file-host.tsx create mode 100644 hooks/use-handoff-files.ts create mode 100644 lib/__tests__/launch-handoff.test.ts create mode 100644 lib/__tests__/manifest-launch.test.ts create mode 100644 lib/__tests__/sw-share-target.test.ts create mode 100644 lib/launch-handoff.ts create mode 100644 lib/offline/__tests__/shell-pages.test.ts diff --git a/app/(main)/protocol/share/route.ts b/app/(main)/protocol/share/route.ts new file mode 100644 index 000000000..ea0ed58c5 --- /dev/null +++ b/app/(main)/protocol/share/route.ts @@ -0,0 +1,51 @@ +/** + * The manifest's share_target, when no service worker took it (public/sw.js + * normally answers first): the first visit, a browser without one, or a worker + * from before this existed. Nothing keeps files here, so the text and links go + * on in the address and the page says the files are gone + * (components/protocol/protocol-launch-handler-provider.tsx). + * + * Open to any origin, like a mailto: link: it writes nothing and acts as nobody. + */ + +const BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? "").replace(/\/+$/, ""); +// A form with text and links is small. Anything bigger carries files, and +// isn't read into memory just to throw them away. +const MAX_READ_BYTES = 256 * 1024; + +export const dynamic = "force-dynamic"; + +function land(query: string): Response { + return new Response(null, { status: 303, headers: { Location: `${BASE_PATH}/${query}` } }); +} + +export async function POST(request: Request): Promise { + const length = Number(request.headers.get("content-length") ?? ""); + const params = new URLSearchParams({ share: "unavailable" }); + if (!Number.isFinite(length) || length <= 0 || length > MAX_READ_BYTES) { + params.set("share_files", "1"); + return land(`?${params}`); + } + try { + const form = await request.formData(); + const field = (name: string, max: number) => { + const value = form.get(name); + if (typeof value === "string" && value) params.set(`share_${name}`, value.slice(0, max)); + }; + // Short enough for what proxies in front allow in an address. + field("title", 300); + field("text", 4000); + field("url", 2000); + if (form.getAll("files").some((file) => typeof file !== "string" && file.size > 0)) { + params.set("share_files", "1"); + } + } catch { + params.set("share_files", "1"); + } + return land(`?${params}`); +} + +/** Nothing to share: the app. */ +export function GET(): Response { + return land(""); +} diff --git a/app/manifest.ts b/app/manifest.ts index 1a06f3b58..53376bc12 100644 --- a/app/manifest.ts +++ b/app/manifest.ts @@ -1,5 +1,8 @@ import type { MetadataRoute } from "next"; import { headers } from "next/headers"; +import { getTranslations } from "next-intl/server"; +import { localeFromAcceptLanguage } from "@/i18n/locale-matcher"; +import { routing } from "@/i18n/routing"; import { configManager } from "@/lib/admin/config-manager"; import { matchDomainBranding, @@ -15,8 +18,27 @@ type WebAppProtocolHandler = { url: string; }; +type WebAppFileHandler = { + action: string; + accept: Record; +}; + +type WebAppShareTarget = { + action: string; + method: "POST"; + enctype: "multipart/form-data"; + params: { + title: string; + text: string; + url: string; + files: Array<{ name: string; accept: string[] }>; + }; +}; + type ExtendedManifest = MetadataRoute.Manifest & { protocol_handlers?: WebAppProtocolHandler[]; + file_handlers?: WebAppFileHandler[]; + share_target?: WebAppShareTarget; launch_handler?: { client_mode?: "navigate-existing" | "auto" | "focus-existing" | "navigate-new" | Array<"navigate-existing" | "auto" | "focus-existing" | "navigate-new">; @@ -32,7 +54,8 @@ const withBase = (p: string) => `${BASE_PATH}${p}`; export default async function manifest(): Promise { await configManager.ensureLoaded(); - const host = pickRequestHost(await headers()); + const requestHeaders = await headers(); + const host = pickRequestHost(requestHeaders); const domainOverrides = matchDomainBranding( host, parseDomainBranding(configManager.get("domainBranding", [])), @@ -83,6 +106,17 @@ export default async function manifest(): Promise { { src: withBase("/icon-maskable-dark-512x512.png"), sizes: "512x512", type: "image/png", purpose: "maskable" }, ]; + // Shortcut names show in the OS menu, in the language the browser asks in. + const t = await getTranslations({ + locale: localeFromAcceptLanguage(requestHeaders.get("accept-language"), routing.locales) ?? routing.defaultLocale, + namespace: "pwaLaunch", + }); + const shortcutIcons = [{ + src: withBase(hasCustomIcon ? "/api/pwa-icon/192" : "/icon-192x192.png"), + sizes: "192x192", + type: "image/png", + }]; + return { name: appName, short_name: shortName, @@ -118,6 +152,31 @@ export default async function manifest(): Promise { { protocol: "mailto", url: withBase("/protocol/mailto?url=%s") }, { protocol: "webcal", url: withBase("/protocol/webcal?url=%s") }, ], + shortcuts: [ + { name: t("shortcut_new_message"), url: withBase("/?compose=1"), icons: shortcutIcons }, + { name: t("shortcut_calendar"), url: withBase("/calendar"), icons: shortcutIcons }, + { name: t("shortcut_contacts"), url: withBase("/contacts"), icons: shortcutIcons }, + ], + // Where a file goes is decided by its type, in lib/launch-handoff.ts. The + // actions are the pages a new window should start on. + file_handlers: [ + { action: withBase("/mail"), accept: { "message/rfc822": [".eml"] } }, + { action: withBase("/calendar"), accept: { "text/calendar": [".ics", ".ical"] } }, + { action: withBase("/contacts"), accept: { "text/vcard": [".vcf", ".vcard"], "text/x-vcard": [".vcf", ".vcard"] } }, + ], + // Not under /api/: proxy.ts refuses a POST from outside there. The service + // worker takes it (public/sw.js); without one, app/(main)/protocol/share. + share_target: { + action: withBase("/protocol/share"), + method: "POST", + enctype: "multipart/form-data", + params: { + title: "title", + text: "text", + url: "url", + files: [{ name: "files", accept: ["*/*"] }], + }, + }, launch_handler: { client_mode: ["focus-existing", "navigate-new"], }, diff --git a/components/calendar/__tests__/ical-import-initial-file.test.tsx b/components/calendar/__tests__/ical-import-initial-file.test.tsx new file mode 100644 index 000000000..72a8959aa --- /dev/null +++ b/components/calendar/__tests__/ical-import-initial-file.test.tsx @@ -0,0 +1,110 @@ +import { act, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useOfflineStore } from '@/lib/offline/state'; +import { ICalImportModal } from '../ical-import-modal'; + +// A .ics opened from the OS. The server reads it, so without a connection the +// dialog has to say so instead of calling a good file invalid. + +vi.mock('@/stores/calendar-store', () => ({ + useCalendarStore: (selector: (state: { importEvents: () => Promise }) => unknown) => selector({ importEvents: async () => 1 }), +})); +vi.mock('@/stores/settings-store', () => ({ + useSettingsStore: (selector: (state: { timeFormat: string }) => unknown) => selector({ timeFormat: '24h' }), +})); +vi.mock('@/components/ui/dialog', () => ({ + Dialog: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +const calendars = [{ id: 'cal-1', name: 'Home', isDefault: true }] as never[]; +const ics = () => new File(['BEGIN:VCALENDAR'], 'invite.ics', { type: 'text/calendar' }); + +function client(overrides: Record = {}) { + return { + getServerUrl: () => 'https://mail.example.org', + getCalendarsAccountId: () => 'acc', + uploadBlob: vi.fn(async () => ({ blobId: 'blob-1' })), + parseCalendarEvents: vi.fn(async () => [{ title: 'Standup', start: '2026-11-03T09:00:00' }]), + ...overrides, + } as never as { uploadBlob: ReturnType; parseCalendarEvents: ReturnType }; +} + +beforeEach(() => { + useOfflineStore.setState({ browserOffline: false, downServers: [] }); +}); + +afterEach(() => { + useOfflineStore.setState({ browserOffline: false, downServers: [] }); +}); + +describe('ICalImportModal with an initial file', () => { + it('reads the file at once and shows the preview', async () => { + const c = client(); + render(); + await screen.findByText('Standup'); + expect(c.uploadBlob).toHaveBeenCalledTimes(1); + expect(c.parseCalendarEvents).toHaveBeenCalledWith('acc', 'blob-1'); + }); + + it('reads nothing without one', async () => { + const c = client(); + render(); + await act(async () => {}); + expect(c.uploadBlob).not.toHaveBeenCalled(); + }); + + it('says the server is needed when offline, and starts by itself once back online', async () => { + useOfflineStore.setState({ browserOffline: true }); + const c = client(); + render(); + expect(await screen.findByText('ics_needs_server')).toBeInTheDocument(); + expect(c.uploadBlob).not.toHaveBeenCalled(); + + useOfflineStore.setState({ browserOffline: false }); + await act(async () => { window.dispatchEvent(new Event('online')); }); + await screen.findByText('Standup'); + expect(c.uploadBlob).toHaveBeenCalledTimes(1); + }); + + it('says the same when the connection fails while it reads', async () => { + const c = client({ + uploadBlob: vi.fn(async () => { + useOfflineStore.setState({ downServers: ['https://mail.example.org'] }); + throw new TypeError('Failed to fetch'); + }), + }); + render(); + expect(await screen.findByText('ics_needs_server')).toBeInTheDocument(); + }); + + it('still calls a bad file invalid while online', async () => { + const c = client({ parseCalendarEvents: vi.fn(async () => { throw new Error('parse'); }) }); + render(); + expect(await screen.findByText('invalid_format')).toBeInTheDocument(); + }); + + it('applies the size limit to it', async () => { + const big = new File([new Uint8Array(10 * 1024 * 1024 + 1)], 'big.ics'); + const c = client(); + render(); + expect(await screen.findByText('file_too_large')).toBeInTheDocument(); + expect(c.uploadBlob).not.toHaveBeenCalled(); + }); + + it('picks the default calendar when the calendars load after it opened', async () => { + const importEvents = vi.fn(); + const c = client(); + const view = render(); + await screen.findByText('Standup'); + view.rerender( + , + ); + await waitFor(() => expect((screen.getByRole('combobox') as HTMLSelectElement).value).toBe('b')); + expect(importEvents).not.toHaveBeenCalled(); + }); +}); diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index 1bccba921..00e4a0c1d 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -93,6 +93,7 @@ import { useCompanyCalendars } from "./use-company-calendars"; import { sharedCalendarColorKey, autoAssignSharedCalendarColors } from "@/lib/shared-calendar-colors"; import { debug } from "@/lib/debug"; import { consumePendingWebcal, hasPendingWebcal, subscribeToPendingWebcal } from "@/lib/protocol-handlers/session"; +import { useHandoffFiles } from "@/hooks/use-handoff-files"; import type { ParsedWebcal } from "@/lib/protocol-handlers/webcal"; import { appPath, buildCalendarPath, parseCalendarPath, type CalendarDeepLink } from "@/lib/deep-links"; import { consumePendingDeepLinkEntry, subscribePendingDeepLink } from "@/lib/deep-link-handoff"; @@ -178,6 +179,10 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps(); const { client, isAuthenticated, logout, checkAuth, switchAccount, activeAccountId, isLoading: authLoading } = useAuthStore(); const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client); + // A .ics opened from the OS (lib/launch-handoff.ts). The standard page that + // renders while the Pro shell takes over leaves it for the Pro one. A demo + // has no import. + const launchedFile = useHandoffFiles("calendar", isAuthenticated && !!client && !demoMode && (isEmbedded || !proInterfaceActive)); const { quota, isPushConnected } = useEmailStore(); const { calendars, events, selectedDate, viewMode, selectedCalendarIds, @@ -2574,6 +2579,16 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = /> )} + {launchedFile.file && client && ( + + )} + {showSubscriptionModal && client && ( void; initialUrl?: string; + /** A file opened from the OS: read at once, as if picked here. */ + initialFile?: File | null; } const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB @@ -30,8 +33,9 @@ const ACCEPTED_EXTENSIONS = [".ics", ".ical"]; type ImportStep = "select" | "preview" | "importing"; type ImportMode = "file" | "url"; -export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICalImportModalProps) { +export function ICalImportModal({ calendars, client, onClose, initialUrl, initialFile }: ICalImportModalProps) { const t = useTranslations("calendar.import"); + const tLaunch = useTranslations("pwaLaunch"); const tCal = useTranslations("calendar"); const locale = useLocale(); const tForm = useTranslations("calendar.form"); @@ -56,6 +60,15 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal const dropZoneRef = useRef(null); const urlInputRef = useRef(null); const targetId = useId(); + // The file that couldn't be read for lack of a connection, read again when + // it is back. + const waitingForServer = useRef(null); + + // Opened before the calendars had loaded, from a launch: the default once they have. + useEffect(() => { + if (calendarId || calendars.length === 0) return; + setCalendarId((calendars.find((c) => c.isDefault) ?? calendars[0]).id); + }, [calendars, calendarId]); const validateFile = useCallback((file: File): string | null => { if (file.size > MAX_FILE_SIZE) return t("file_too_large"); @@ -71,6 +84,16 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal return; } + // The server reads the file (uploadBlob, then CalendarEvent/parse). Say so + // rather than call a good file invalid. + const serverUrl = client.getServerUrl(); + if (isOfflineNow(serverUrl)) { + waitingForServer.current = file; + setError(tLaunch("ics_needs_server")); + return; + } + waitingForServer.current = null; + setError(null); setIsParsing(true); setStep("select"); @@ -91,11 +114,31 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal setSelectedIndices(new Set(events.map((_, i) => i))); setStep("preview"); } catch { - setError(t("invalid_format")); + if (isOfflineNow(serverUrl)) { + waitingForServer.current = file; + setError(tLaunch("ics_needs_server")); + } else { + setError(t("invalid_format")); + } } finally { setIsParsing(false); } - }, [client, validateFile, t]); + }, [client, validateFile, t, tLaunch]); + + const initialFileRead = useRef(false); + useEffect(() => { + if (!initialFile || initialFileRead.current) return; + initialFileRead.current = true; + void handleFile(initialFile); + }, [initialFile, handleFile]); + + useEffect(() => { + const retry = () => { + if (waitingForServer.current) void handleFile(waitingForServer.current); + }; + window.addEventListener("online", retry); + return () => window.removeEventListener("online", retry); + }, [handleFile]); const handleFileChange = useCallback((e: React.ChangeEvent) => { const file = e.target.files?.[0]; diff --git a/components/contacts/__tests__/contact-import-initial-file.test.tsx b/components/contacts/__tests__/contact-import-initial-file.test.tsx new file mode 100644 index 000000000..2a424dedc --- /dev/null +++ b/components/contacts/__tests__/contact-import-initial-file.test.tsx @@ -0,0 +1,62 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { useOfflineStore } from '@/lib/offline/state'; +import { ContactImportDialog } from '../contact-import-dialog'; + +// A .vcf opened from the OS: it parses in the browser, also without a connection. + +const VCARD = ['BEGIN:VCARD', 'VERSION:3.0', 'FN:Ada Lovelace', 'EMAIL:ada@example.org', 'END:VCARD', ''].join('\r\n'); +const vcf = (body = VCARD) => { + const file = new File([body], 'people.vcf', { type: 'text/vcard' }); + // jsdom's File has no text(). + if (typeof file.text !== 'function') Object.defineProperty(file, 'text', { value: async () => body }); + return file; +}; + +afterEach(() => { + useOfflineStore.setState({ browserOffline: false, downServers: [] }); +}); + +describe('ContactImportDialog with an initial file', () => { + it('parses it at once and offers the cards', async () => { + render(); + expect(await screen.findByText('Ada Lovelace')).toBeInTheDocument(); + }); + + it('parses it offline as well', async () => { + useOfflineStore.setState({ browserOffline: true }); + render(); + expect(await screen.findByText('Ada Lovelace')).toBeInTheDocument(); + }); + + it('applies the size limit to it', async () => { + const big = new File([new Uint8Array(5 * 1024 * 1024 + 1)], 'big.vcf'); + render(); + expect(await screen.findByText('import.file_too_large')).toBeInTheDocument(); + }); + + it('shows the file input without one', async () => { + const { container } = render(); + expect(container.ownerDocument.querySelector('input[type="file"]')).not.toBeNull(); + expect(screen.queryByText('Ada Lovelace')).toBeNull(); + }); + + it('says the server is needed when the import fails offline', async () => { + const onImport = vi.fn(async () => { + useOfflineStore.setState({ browserOffline: true }); + throw new TypeError('Failed to fetch'); + }); + render(); + await screen.findByText('Ada Lovelace'); + fireEvent.click(screen.getByRole('button', { name: 'import.import_button' })); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('contacts_need_server')); + }); + + it('calls a failed import failed while online', async () => { + const onImport = vi.fn(async () => { throw new Error('server said no'); }); + render(); + await screen.findByText('Ada Lovelace'); + fireEvent.click(screen.getByRole('button', { name: 'import.import_button' })); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('import.failed')); + }); +}); diff --git a/components/contacts/contact-import-dialog.tsx b/components/contacts/contact-import-dialog.tsx index 91c2dbc64..dca624055 100644 --- a/components/contacts/contact-import-dialog.tsx +++ b/components/contacts/contact-import-dialog.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useRef, useCallback } from "react"; +import { useState, useRef, useCallback, useEffect } from "react"; import { useTranslations } from "next-intl"; import { Upload, FileText, AlertTriangle, Check } from "@/components/icons"; import { Button } from "@/components/ui/button"; @@ -9,19 +9,24 @@ import { cn } from "@/lib/utils"; import { parseVCard, detectDuplicates } from "@/lib/vcard"; import type { ContactCard } from "@/lib/jmap/types"; import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store"; +import { isOfflineNow } from "@/lib/offline/state"; interface ContactImportDialogProps { existingContacts: ContactCard[]; onImport: (contacts: ContactCard[]) => Promise; onClose: () => void; + /** A file opened from the OS: read at once, as if picked here. */ + initialFile?: File | null; } export function ContactImportDialog({ existingContacts, onImport, onClose, + initialFile, }: ContactImportDialogProps) { const t = useTranslations("contacts"); + const tLaunch = useTranslations("pwaLaunch"); const fileRef = useRef(null); const [parsed, setParsed] = useState([]); const [selected, setSelected] = useState>(new Set()); @@ -30,10 +35,7 @@ export function ContactImportDialog({ const [result, setResult] = useState(null); const [error, setError] = useState(null); - const handleFileChange = useCallback(async (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - + const readFile = useCallback(async (file: File) => { setError(null); setResult(null); @@ -66,6 +68,18 @@ export function ContactImportDialog({ } }, [existingContacts, t]); + const handleFileChange = useCallback(async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) await readFile(file); + }, [readFile]); + + const initialFileRead = useRef(false); + useEffect(() => { + if (!initialFile || initialFileRead.current) return; + initialFileRead.current = true; + void readFile(initialFile); + }, [initialFile, readFile]); + const toggleSelect = (idx: number) => { const next = new Set(selected); if (next.has(idx)) { @@ -94,7 +108,7 @@ export function ContactImportDialog({ setResult(count); } catch (error) { console.error('Failed to import contacts:', error); - setError(t("import.failed")); + setError(isOfflineNow() ? tLaunch("contacts_need_server") : t("import.failed")); } finally { setIsImporting(false); } diff --git a/components/contacts/contacts-app.tsx b/components/contacts/contacts-app.tsx index 1b371b44a..0f9da0954 100644 --- a/components/contacts/contacts-app.tsx +++ b/components/contacts/contacts-app.tsx @@ -51,6 +51,7 @@ import type { ContactCard, AddressBook, AddressBookRights } from "@/lib/jmap/typ import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog"; import { appPath, buildContactsPath, parseContactsPath, type ContactDeepLink } from "@/lib/deep-links"; import { consumePendingDeepLinkEntry, subscribePendingDeepLink } from "@/lib/deep-link-handoff"; +import { useHandoffFiles } from "@/hooks/use-handoff-files"; import { useDeepLinkUrl } from "@/hooks/use-deep-link-url"; import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect"; import { useLiteLinkSegments } from "@/hooks/use-lite-link-segments"; @@ -356,6 +357,14 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = // focused tab writes the address bar; the standard instance that renders // while Pro takes over a route stays silent. const proInterfaceActive = useProInterfaceActive(); + // A .vcf opened from the OS (lib/launch-handoff.ts). It waits for the + // contacts, or everything would look new to the duplicate check. The + // standard page that renders while the Pro shell takes over leaves it for + // the Pro one. + const launchedFile = useHandoffFiles( + "contacts", + isAuthenticated && !!client && (contactsLoaded || !supportsSync) && (isEmbedded || !proInterfaceActive), + ); const isFocusedProTab = useIsFocusedProTab(); const contactsLinkPath = appPath(buildContactsPath({ contactId: view === 'detail' || view === 'edit' ? selectedContactId : null, @@ -1572,6 +1581,15 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = if (movingContactIds) void handleDropContacts(movingContactIds, book); }} /> + {launchedFile.file && ( + + )} {showImportDialog && ( { + afterEach(() => { + useAuthStore.setState({ client: null }); + vi.clearAllMocks(); + hoisted.beforeAttachmentUpload.mockImplementation(async () => true); + }); + + const uploading = () => filesClient({ uploadBlob: vi.fn(() => new Promise(() => {})), getMaxSizeUpload: () => 0 }); + + it('attaches them when it opens', async () => { + useAuthStore.setState({ client: uploading() as never }); + render(); + expect(await screen.findByText('shared.pdf')).toBeInTheDocument(); + }); + + it('waits for the client', async () => { + const files = [new File(['x'], 'shared.pdf')]; + const view = render(); + await act(async () => {}); + expect(hoisted.beforeAttachmentUpload).not.toHaveBeenCalled(); + + useAuthStore.setState({ client: uploading() as never }); + view.rerender(); + expect(await screen.findByText('shared.pdf')).toBeInTheDocument(); + }); + + it('attaches an array once, also when the composer remounts with it', async () => { + useAuthStore.setState({ client: uploading() as never }); + const files = [new File(['x'], 'shared.pdf')]; + const first = render(); + await screen.findByText('shared.pdf'); + first.unmount(); + + render(); + await act(async () => {}); + expect(hoisted.beforeAttachmentUpload).toHaveBeenCalledTimes(1); + expect(screen.queryByText('shared.pdf')).toBeNull(); + }); + + it('runs the same checks as a picked file: a plugin may refuse it', async () => { + hoisted.beforeAttachmentUpload.mockImplementation(async (info) => info.name !== 'setup.exe'); + useAuthStore.setState({ client: uploading() as never }); + render(); + expect(await screen.findByText('notes.pdf')).toBeInTheDocument(); + expect(screen.queryByText('setup.exe')).toBeNull(); + }); + + it('runs the same checks as a picked file: the size limit', async () => { + useAuthStore.setState({ client: filesClient({ getMaxSizeUpload: () => 1 }) as never }); + render(); + await act(async () => {}); + expect(hoisted.toastError).toHaveBeenCalledWith('attachment_too_large'); + expect(screen.queryByText('big.pdf')).toBeNull(); + }); +}); + describe('attach from Files', () => { afterEach(() => { useAuthStore.setState({ client: null }); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 77163d04b..93932e246 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -213,6 +213,9 @@ function createChipDragPreview(label: string): HTMLElement { // inserts as a single chip and expands into its members on send. type SuggestionItem = { name: string; email: string; group?: { id: string; memberCount: number } }; +// Which `initialFiles` arrays are attached: a remount gets the same array. +const attachedInitialFiles = new WeakSet(); + export interface ComposerDraftData { to: string; cc: string; @@ -360,6 +363,12 @@ interface EmailComposerProps { sessionKey?: string; /** Id of the message being replied to or forwarded, for onComposerOpen. */ sourceEmailId?: string; + /** + * Files to attach when the composer opens, from a share or a launch (see + * lib/launch-handoff.ts). They go through the same checks as a picked file. + * Attached once per array, also when the composer remounts with it. + */ + initialFiles?: File[] | null; mode?: 'compose' | 'reply' | 'replyAll' | 'forward'; /** * Email of the mailbox/account the user is viewing when they start a new @@ -492,6 +501,7 @@ export function EmailComposer({ className, initialDraftText: initialDraftTextProp, initialData: initialDataProp, + initialFiles, prefill, sessionKey, sourceEmailId, @@ -2461,6 +2471,12 @@ export function EmailComposer({ } }, [client, t]); + useEffect(() => { + if (!client || !initialFiles || initialFiles.length === 0 || attachedInitialFiles.has(initialFiles)) return; + attachedInitialFiles.add(initialFiles); + void addFiles(initialFiles); + }, [client, initialFiles, addFiles]); + const handleFileSelect = async (event: React.ChangeEvent) => { if (!event.target.files) return; await addFiles(Array.from(event.target.files)); diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index c69d7c2c7..52f90a2f6 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -142,6 +142,7 @@ import { usePluginStore } from "@/stores/plugin-store"; import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot"; import { canDownload } from "@/lib/device-policy"; import { consumePendingMailto, subscribeToPendingMailto } from "@/lib/protocol-handlers/session"; +import { subscribeHandoff, takeHandoff } from "@/lib/launch-handoff"; import { INTERNAL_MAILTO_EVENT, type ParsedMailto } from "@/lib/protocol-handlers/mailto"; import { PLUGIN_REPLY_EVENT, type PluginReplyRequest } from "@/lib/plugin-sandbox/host-api-compose"; import { onOpenEmail, type OpenEmailHandler, type OpenEmailOutcome } from "@/lib/plugin-sandbox/open-email"; @@ -230,6 +231,9 @@ const UNIFIED_LIST_PUSH = { }, }; +/** A draft from outside: a mailto:, or a share with files. ParsedMailto stays JSON, for sessionStorage. */ +type DraftRequest = ParsedMailto & { initialFiles?: File[] }; + export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // Static Lite build: the route params are empty, read the link from the URL. const linkSegments = useLiteLinkSegments('mail', routeSegments); @@ -314,7 +318,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // Office attachments open read-only in the configured WOPI editor (#1047). const [officeAttachment, setOfficeAttachment] = useState<{ blobId: string; name: string; type?: string; size?: number; accountId: string; slot: number | null } | null>(null); const wopiStatus = useWopiStatus(true); - const [pendingMailtoAccountChoice, setPendingMailtoAccountChoice] = useState(null); + const [pendingMailtoAccountChoice, setPendingMailtoAccountChoice] = useState(null); + // Files from a share or a launch, for the next composer. It attaches an + // array once, so a stale one does nothing. + const [composerInitialFiles, setComposerInitialFiles] = useState(null); const [isProtocolAccountSwitching, setIsProtocolAccountSwitching] = useState(false); const markAsReadTimeoutRef = useRef(null); const lastUndoToastSubmissionRef = useRef(null); @@ -1376,6 +1383,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { initialDraftText: composerDraftText, initialData: pendingDraft, prefill: composerPrefill, + initialFiles: composerInitialFiles, sourceEmailId: selectedEmail?.id ?? null, sourceEmailSource: selectedEmail ? emailWriteSource(selectedEmail, client) : null, title, @@ -1386,6 +1394,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { setComposerDraftText(""); setPendingDraft(null); setComposerPrefill(null); + setComposerInitialFiles(null); setComposerQuoteHeader(null); // We only react to the rising edge of `showComposer` here; the other // variables read above are captured-but-stale-safe because the next @@ -1465,18 +1474,19 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { return true; }, [identities, t]); - const applyMailtoDraft = useCallback((pending: ParsedMailto, suppressOutgoingStash: boolean) => { + const applyMailtoDraft = useCallback((pending: DraftRequest, suppressOutgoingStash: boolean) => { if (suppressOutgoingStash) { suppressComposerStateSaveSessionRef.current = composerSessionId; } setComposerSessionId((id) => id + 1); setPendingDraft(mailtoComposerDraft(pending, useSettingsStore.getState().plainTextMode)); + setComposerInitialFiles(pending.initialFiles ?? null); setComposerMode("compose"); setShowComposer(true); if (isMobile) setActiveView("viewer"); }, [composerSessionId, isMobile, setActiveView]); - const openMailtoDraft = useCallback((pending: ParsedMailto) => withComposeGate('compose', { + const openMailtoDraft = useCallback((pending: DraftRequest) => withComposeGate('compose', { to: pending.to, cc: pending.cc, subject: pending.subject, body: pending.body, }, () => { // A live composer owns text the user has not sent. Replacing it outright @@ -1495,7 +1505,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { applyMailtoDraft(pending, showComposer); }), [applyMailtoDraft, showComposer, withComposeGate]); - const openMailtoForAccount = useCallback(async (pending: ParsedMailto, accountId: string) => { + const openMailtoForAccount = useCallback(async (pending: DraftRequest, accountId: string) => { setIsProtocolAccountSwitching(true); try { if (useAuthStore.getState().activeAccountId !== accountId) { @@ -1519,13 +1529,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { }); }, []); - const handleMailtoProtocolRequest = useCallback(async (pending: ParsedMailto) => { - if (!(await mailtoAllowed(pending))) return; - // From another app of this webmail: the account in use, no question. - if (pending.inApp) { - openMailtoDraft(pending); - return; - } + // A draft that comes from outside: which account it goes out from. + const routeOutsideDraft = useCallback((pending: DraftRequest) => { const protocolAccounts = getMailtoProtocolAccounts(); if (protocolAccounts.length > 1) { setPendingMailtoAccountChoice(pending); @@ -1539,7 +1544,52 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { } openMailtoDraft(pending); - }, [activeAccountId, getMailtoProtocolAccounts, mailtoAllowed, openMailtoDraft, openMailtoForAccount]); + }, [activeAccountId, getMailtoProtocolAccounts, openMailtoDraft, openMailtoForAccount]); + + const handleMailtoProtocolRequest = useCallback(async (pending: ParsedMailto) => { + if (!(await mailtoAllowed(pending))) return; + // From another app of this webmail: the account in use, no question. + if (pending.inApp) { + openMailtoDraft(pending); + return; + } + routeOutsideDraft(pending); + }, [mailtoAllowed, openMailtoDraft, routeOutsideDraft]); + + // A new draft from a shortcut, a share or the desktop app + // (lib/launch-handoff.ts). Not a mailto:, so onMailtoIntercept stays out of + // it; onBeforeCompose still runs. The standard page that renders while the + // Pro shell takes over must leave it for the Pro one. + const proTakingOver = proInterfaceActive && !isEmbedded; + useEffect(() => { + if (!isAuthenticated || !client || proTakingOver) return; + + const openHandoff = () => { + for (let handoff = takeHandoff('compose'); handoff; handoff = takeHandoff('compose')) { + if (!handoff.subject && !handoff.body && handoff.files.length === 0) { + void withComposeGate('compose', {}, () => { + startFreshComposerSession(); + setComposerMode('compose'); + setShowComposer(true); + if (isMobile) setActiveView('viewer'); + }); + continue; + } + routeOutsideDraft({ + to: [], + cc: [], + bcc: [], + subject: handoff.subject ?? '', + body: handoff.body ?? '', + initialFiles: handoff.files, + }); + } + }; + + openHandoff(); + return subscribeHandoff('compose', openHandoff); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isAuthenticated, client, proTakingOver, routeOutsideDraft, withComposeGate, isMobile]); useEffect(() => { if (!isAuthenticated || !client) return; @@ -4848,6 +4898,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { initialDraftText={composerDraftText} initialData={pendingDraft} prefill={composerPrefill} + initialFiles={composerInitialFiles} sessionKey={`inline-${composerSessionId}`} sourceEmailId={composerSourceEmail?.id} onSaveState={(data) => { diff --git a/components/pro/pro-compose-tab-body.tsx b/components/pro/pro-compose-tab-body.tsx index a49e0f2df..9a3b51ae1 100644 --- a/components/pro/pro-compose-tab-body.tsx +++ b/components/pro/pro-compose-tab-body.tsx @@ -244,6 +244,7 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) { initialDraftText={data.initialDraftText} initialData={data.initialData} prefill={data.prefill} + initialFiles={data.initialFiles} sessionKey={`pro-${tabId}-${data.sessionId}`} sourceEmailId={data.sourceEmailId ?? undefined} composeFromAccountEmail={composeFromAccountEmail} diff --git a/components/protocol/__tests__/protocol-launch-handler-provider.test.tsx b/components/protocol/__tests__/protocol-launch-handler-provider.test.tsx new file mode 100644 index 000000000..49c451447 --- /dev/null +++ b/components/protocol/__tests__/protocol-launch-handler-provider.test.tsx @@ -0,0 +1,287 @@ +import { act, render, screen, waitFor } from '@testing-library/react'; +import React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { FakeCaches } from '@/lib/__tests__/sw-harness'; + +// The launchQueue consumer. It is the only one the app may have: a second +// setConsumer replaces the first. Files used to be dropped here. + +const hoisted = vi.hoisted(() => ({ + push: vi.fn(), + pathname: { current: '/' }, + toastError: vi.fn(), + savePendingMailto: vi.fn(), + protocolOpenMode: 'active-session', +})); + +vi.mock('@/i18n/navigation', () => ({ + useRouter: () => ({ push: hoisted.push, replace: vi.fn(), back: vi.fn() }), + usePathname: () => hoisted.pathname.current, +})); +vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Bulwark' }) })); +vi.mock('@/stores/toast-store', () => ({ toast: { error: hoisted.toastError, info: vi.fn(), success: vi.fn() } })); +vi.mock('@/stores/settings-store', () => ({ + useSettingsStore: Object.assign(() => undefined, { getState: () => ({ protocolOpenMode: hoisted.protocolOpenMode }) }), +})); +vi.mock('@/lib/protocol-handlers/session', () => ({ + listenForMailtoRequests: () => () => {}, + notifyPendingMailto: vi.fn(), + notifyPendingWebcal: vi.fn(), + requestOpenMailtoInExistingClient: vi.fn(async () => false), + savePendingMailto: hoisted.savePendingMailto, + savePendingWebcal: vi.fn(), +})); +// The real modal has its own tests; here: does the launch reach it, and is +// its download gated by the same function. +vi.mock('@/components/files/file-preview-modal', () => ({ + FilePreviewModal: function PreviewStub({ name, onClose, onDownload, getFileContent }: { + name: string; onClose: () => void; onDownload: () => void; + getFileContent: () => Promise<{ blob: Blob; contentType: string }>; + }) { + const [type, setType] = React.useState(''); + React.useEffect(() => { void getFileContent().then((c) => setType(c.contentType)); }, [getFileContent]); + return ( +
+ {name} + + +
+ ); + }, +})); + +const { clearHandoffs, pendingHandoffs, takeHandoff } = await import('@/lib/launch-handoff'); +const { setDownloadGuard } = await import('@/lib/download-guard'); +const { ProtocolLaunchHandlerProvider } = await import('../protocol-launch-handler-provider'); + +type Consumer = (params: { targetURL?: string; files?: Array<{ kind: string; getFile: () => Promise }> }) => void; + +let consumer: Consumer | null = null; +const setConsumer = vi.fn((fn: Consumer) => { consumer = fn; }); +const handle = (name: string, type = '') => ({ kind: 'file', getFile: async () => new File(['x'], name, { type }) }); +const flush = () => act(async () => { await new Promise((r) => setTimeout(r, 0)); }); + +function mount() { + return render(
app
); +} + +beforeEach(() => { + consumer = null; + setConsumer.mockClear(); + hoisted.push.mockClear(); + hoisted.toastError.mockClear(); + hoisted.savePendingMailto.mockClear(); + hoisted.pathname.current = '/'; + hoisted.protocolOpenMode = 'active-session'; + sessionStorage.clear(); + vi.stubGlobal('caches', new FakeCaches()); + window.launchQueue = { setConsumer } as never; +}); + +afterEach(() => { + clearHandoffs(); + delete window.launchQueue; + vi.unstubAllGlobals(); + window.history.replaceState(null, '', '/'); +}); + +describe('file launches', () => { + it('opens a .eml in the viewer without leaving the page', async () => { + mount(); + consumer!({ targetURL: `${window.location.origin}/mail`, files: [handle('note.eml')] }); + const viewer = await screen.findByTestId('eml-viewer'); + expect(viewer).toHaveTextContent('note.eml'); + await waitFor(() => expect(viewer).toHaveAttribute('data-type', 'message/rfc822')); + expect(hoisted.push).not.toHaveBeenCalled(); + }); + + it('shows several .eml files one after the other', async () => { + mount(); + consumer!({ files: [handle('one.eml'), handle('two.eml')] }); + expect(await screen.findByTestId('eml-viewer')).toHaveTextContent('one.eml'); + await act(async () => { screen.getByText('close').click(); }); + expect(await screen.findByTestId('eml-viewer')).toHaveTextContent('two.eml'); + await act(async () => { screen.getByText('close').click(); }); + expect(screen.queryByTestId('eml-viewer')).toBeNull(); + }); + + it("saves a .eml only if the device policy allows downloads", async () => { + const created = vi.fn(() => 'blob:x'); + vi.stubGlobal('URL', Object.assign(URL, { createObjectURL: created, revokeObjectURL: vi.fn() })); + setDownloadGuard(() => false); + mount(); + consumer!({ files: [handle('note.eml')] }); + await screen.findByTestId('eml-viewer'); + await act(async () => { screen.getByText('save').click(); }); + expect(created).not.toHaveBeenCalled(); + setDownloadGuard(null); + }); + + it('takes a .ics to the calendar with the file waiting', async () => { + mount(); + consumer!({ targetURL: `${window.location.origin}/calendar`, files: [handle('invite.ics')] }); + await flush(); + expect(hoisted.push).toHaveBeenCalledWith('/calendar'); + expect(takeHandoff('calendar')?.files[0].name).toBe('invite.ics'); + }); + + it('takes a .vcf to the contacts with the file waiting', async () => { + mount(); + consumer!({ files: [handle('people.vcf')] }); + await flush(); + expect(hoisted.push).toHaveBeenCalledWith('/contacts'); + expect(takeHandoff('contacts')?.files[0].name).toBe('people.vcf'); + }); + + it('stays when already on the right page', async () => { + hoisted.pathname.current = '/calendar'; + mount(); + consumer!({ files: [handle('invite.ics')] }); + await flush(); + expect(hoisted.push).not.toHaveBeenCalled(); + }); + + it('goes to the page in the Pro shell too, which has one route', async () => { + hoisted.pathname.current = '/pro'; + mount(); + consumer!({ files: [handle('invite.ics')] }); + await flush(); + expect(hoisted.push).toHaveBeenCalledWith('/calendar'); + }); + + it('says so for a file it cannot handle', async () => { + mount(); + consumer!({ files: [handle('photo.png', 'image/png')] }); + await flush(); + expect(hoisted.toastError).toHaveBeenCalledWith('unsupported_file'); + expect(pendingHandoffs()).toEqual([]); + }); + + it('says so for a file it cannot read', async () => { + mount(); + consumer!({ files: [{ kind: 'file', getFile: async () => { throw new Error('denied'); } }] }); + await flush(); + expect(hoisted.toastError).toHaveBeenCalledWith('open_failed'); + }); + + it('waits on the sign-in page, and goes on after it', async () => { + hoisted.pathname.current = '/login'; + const view = mount(); + consumer!({ files: [handle('invite.ics')] }); + await flush(); + expect(hoisted.push).not.toHaveBeenCalled(); + + hoisted.pathname.current = '/'; + view.rerender(
app
); + await flush(); + expect(hoisted.push).toHaveBeenCalledTimes(1); + expect(hoisted.push).toHaveBeenCalledWith('/calendar'); + }); + + it('goes to the page only once for a handoff, so it does not trap a later visit elsewhere', async () => { + hoisted.pathname.current = '/settings'; + const view = mount(); + consumer!({ files: [handle('invite.ics')] }); + await flush(); + expect(hoisted.push).toHaveBeenCalledTimes(1); + + hoisted.pathname.current = '/mail'; + view.rerender(
app
); + await flush(); + expect(hoisted.push).toHaveBeenCalledTimes(1); + }); +}); + +describe('launches that are not files', () => { + it('still handles mailto:', async () => { + mount(); + consumer!({ targetURL: `${window.location.origin}/protocol/mailto?url=${encodeURIComponent('mailto:a@example.org')}` }); + await waitFor(() => expect(hoisted.savePendingMailto).toHaveBeenCalled()); + expect(hoisted.savePendingMailto.mock.calls[0][0].to).toEqual(['a@example.org']); + }); + + it('opens a new message from the shortcut in an open window', async () => { + hoisted.pathname.current = '/settings'; + mount(); + consumer!({ targetURL: `${window.location.origin}/?compose=1` }); + await flush(); + expect(hoisted.push).toHaveBeenCalledWith('/'); + expect(takeHandoff('compose')).toMatchObject({ files: [] }); + }); + + it('goes to the calendar from its shortcut in an open window', async () => { + mount(); + consumer!({ targetURL: `${window.location.origin}/calendar` }); + expect(hoisted.push).toHaveBeenCalledWith('/calendar'); + }); + + it('leaves an open window where it is for the plain app icon', async () => { + hoisted.pathname.current = '/settings'; + mount(); + consumer!({ targetURL: `${window.location.origin}/` }); + await flush(); + expect(hoisted.push).not.toHaveBeenCalled(); + expect(pendingHandoffs()).toEqual([]); + }); + + it('ignores a launch of the share target: the service worker has that', async () => { + mount(); + consumer!({ targetURL: `${window.location.origin}/protocol/share` }); + await flush(); + expect(hoisted.push).not.toHaveBeenCalled(); + expect(hoisted.toastError).not.toHaveBeenCalled(); + }); + + it('asks launchQueue once per consumer: never a second one on one render', () => { + mount(); + expect(setConsumer).toHaveBeenCalledTimes(1); + }); +}); + +describe('the address a launch lands on', () => { + it('opens a new message once and removes the parameter', async () => { + window.history.replaceState(null, '', '/?compose=1&slot=2#x'); + mount(); + expect(window.location.search).toBe('?slot=2'); + expect(window.location.hash).toBe('#x'); + expect(takeHandoff('compose')).toMatchObject({ files: [] }); + expect(takeHandoff('compose')).toBeNull(); + }); + + it('counts the same launch once when launchQueue names it too', async () => { + window.history.replaceState(null, '', '/?compose=1'); + mount(); + consumer!({ targetURL: `${window.location.origin}/?compose=1` }); + await flush(); + expect(takeHandoff('compose')).not.toBeNull(); + expect(takeHandoff('compose')).toBeNull(); + }); + + it('turns text in the address into a draft and says the files were lost', async () => { + window.history.replaceState(null, '', '/?share=unavailable&share_title=T&share_text=B&share_files=1'); + mount(); + expect(window.location.search).toBe(''); + expect(takeHandoff('compose')).toMatchObject({ subject: 'T', body: 'B' }); + expect(hoisted.toastError).toHaveBeenCalledWith('share_files_lost'); + }); + + it('says so when what was shared is gone', async () => { + window.history.replaceState(null, '', '/?share=abc'); + mount(); + expect(window.location.search).toBe(''); + await flush(); + expect(hoisted.toastError).toHaveBeenCalledWith('share_gone'); + }); +}); + +describe('a browser without launchQueue', () => { + it('renders the app and does nothing else', async () => { + delete window.launchQueue; + mount(); + await flush(); + expect(screen.getByText('app')).toBeInTheDocument(); + expect(setConsumer).not.toHaveBeenCalled(); + expect(hoisted.push).not.toHaveBeenCalled(); + expect(pendingHandoffs()).toEqual([]); + }); +}); diff --git a/components/protocol/launch-file-host.tsx b/components/protocol/launch-file-host.tsx new file mode 100644 index 000000000..4c18d2fd5 --- /dev/null +++ b/components/protocol/launch-file-host.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { useCallback, useEffect, useState, type ComponentType, type ComponentProps } from "react"; +import { useTranslations } from "next-intl"; +import type { FilePreviewModal as FilePreviewModalType } from "@/components/files/file-preview-modal"; +import { useHandoffFiles } from "@/hooks/use-handoff-files"; +import { saveBlobAs } from "@/lib/download-guard"; +import { toast } from "@/stores/toast-store"; + +type PreviewComponent = ComponentType>; + +/** + * Shows a .eml opened from the OS. It parses in the browser, so it works + * offline and before sign-in. `policyAccount` stays out: the strictest device + * policy of the accounts signed in here decides about saving (FilePreviewModal + * and saveBlobAs check it). + * + * The preview is loaded when a file arrives, to keep it off the layout's + * bundle, and by hand rather than with next/dynamic: without a connection and + * without the offline shell its chunk can't load, which has to end in a + * message, not in the error page, and work again later. + */ +export function LaunchFileHost() { + const t = useTranslations("pwaLaunch"); + const { file, done } = useHandoffFiles("eml", true); + const [Preview, setPreview] = useState(null); + + useEffect(() => { + if (!file || Preview) return; + let stale = false; + import("@/components/files/file-preview-modal") + .then((module) => { + if (!stale) setPreview(() => module.FilePreviewModal); + }) + .catch(() => { + if (stale) return; + toast.error(t("open_failed")); + done(); + }); + return () => { stale = true; }; + }, [file, Preview, done, t]); + + const getFileContent = useCallback(async () => { + if (!file) throw new Error("no file"); + return { blob: file, contentType: "message/rfc822" }; + }, [file]); + + const onDownload = useCallback(() => { + if (file) saveBlobAs(file, file.name, { kind: "attachment" }); + }, [file]); + + if (!file || !Preview) return null; + return ( + + ); +} diff --git a/components/protocol/protocol-launch-handler-provider.tsx b/components/protocol/protocol-launch-handler-provider.tsx index 885203711..69dcf5eb3 100644 --- a/components/protocol/protocol-launch-handler-provider.tsx +++ b/components/protocol/protocol-launch-handler-provider.tsx @@ -1,11 +1,25 @@ "use client"; -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import type { ReactNode } from "react"; import { useTranslations } from "next-intl"; import { usePathname, useRouter } from "@/i18n/navigation"; import { useConfig } from "@/hooks/use-config"; import { getPathPrefix } from "@/lib/browser-navigation"; +import { matchSurface, type AppSurface } from "@/lib/deep-links"; +import { + adoptStoredHandoff, + handOffDraft, + handOffLaunchFiles, + handOffNewMessage, + parseLaunchLanding, + pendingHandoffs, + restoreStoredHandoffs, + shortcutFromTargetUrl, + stripLandingParams, + subscribeHandoff, + type HandoffKind, +} from "@/lib/launch-handoff"; import { productName } from "@/lib/product-name"; import { parseMailto } from "@/lib/protocol-handlers/mailto"; import { parseWebcal } from "@/lib/protocol-handlers/webcal"; @@ -18,8 +32,13 @@ import { savePendingWebcal, } from "@/lib/protocol-handlers/session"; import { useSettingsStore } from "@/stores/settings-store"; +import { toast } from "@/stores/toast-store"; +import { LaunchFileHost } from "./launch-file-host"; -type LaunchParams = { targetURL?: string }; +type LaunchParams = { + targetURL?: string; + files?: ReadonlyArray<{ kind?: string; getFile?: () => Promise }>; +}; type StandaloneNavigator = Navigator & { standalone?: boolean }; declare global { @@ -51,6 +70,13 @@ function getProtocolLaunch(targetURL: string): return null; } +/** The page a handoff waits for. A .eml has none: it opens over any page. */ +const HANDOFF_TARGET: Partial> = { + compose: { surface: "mail", path: "/" }, + calendar: { surface: "calendar", path: "/calendar" }, + contacts: { surface: "contacts", path: "/contacts" }, +}; + function isStandaloneDisplayMode() { return window.matchMedia?.("(display-mode: standalone)").matches || (navigator as StandaloneNavigator).standalone === true; @@ -73,6 +99,50 @@ export function ProtocolLaunchHandlerProvider({ children }: ProtocolLaunchHandle const appName = productName(useConfig().appName); const router = useRouter(); const pathname = usePathname(); + const tLaunch = useTranslations("pwaLaunch"); + const sentToTarget = useRef(new Set()); + + // What the page's own address asks for: a shortcut's new message, or what + // was shared. Once: it leaves the address before anything else happens. + useEffect(() => { + const landing = parseLaunchLanding(window.location.search); + if (landing) { + window.history.replaceState( + window.history.state, + "", + window.location.pathname + stripLandingParams(window.location.search) + window.location.hash, + ); + } + if (landing?.type === "new-message") { + handOffNewMessage(); + } else if (landing?.type === "inline") { + if (landing.subject || landing.body) handOffDraft({ subject: landing.subject, body: landing.body }); + if (landing.filesLost) toast.error(tLaunch("share_files_lost")); + } else if (landing?.type === "stored") { + void adoptStoredHandoff(landing.id).then((found) => { + if (!found) toast.error(tLaunch("share_gone")); + }); + } + void restoreStoredHandoffs(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Takes a handoff to the page that handles it, once each: on arrival, or + // when the sign-in it had to wait for is done. + useEffect(() => { + const goToTarget = () => { + if (pathname === "/login" || pathname.startsWith("/login/") || pathname.startsWith("/auth/")) return; + for (const handoff of pendingHandoffs()) { + const target = HANDOFF_TARGET[handoff.kind]; + if (!target || sentToTarget.current.has(handoff.id)) continue; + sentToTarget.current.add(handoff.id); + if (matchSurface(pathname)?.surface !== target.surface) router.push(target.path); + return; + } + }; + goToTarget(); + return subscribeHandoff("any", goToTarget); + }, [pathname, router]); useEffect(() => { if (pathname.startsWith("/protocol/")) return; @@ -93,10 +163,23 @@ export function ProtocolLaunchHandlerProvider({ children }: ProtocolLaunchHandle if (typeof window === "undefined" || !window.launchQueue) return; window.launchQueue.setConsumer((launchParams) => { + if (launchParams.files && launchParams.files.length > 0) { + void handOffLaunchFiles(launchParams.files).then(({ unsupported, failed }) => { + for (const name of unsupported) toast.error(tLaunch("unsupported_file", { name })); + if (failed > 0) toast.error(tLaunch("open_failed")); + }); + return; + } + if (!launchParams.targetURL) return; const launch = getProtocolLaunch(launchParams.targetURL); - if (!launch) return; + if (!launch) { + const shortcut = shortcutFromTargetUrl(launchParams.targetURL, window.location.origin); + if (shortcut === "new-message") handOffNewMessage(); + else if (shortcut && matchSurface(pathname)?.surface !== shortcut) router.push(`/${shortcut}`); + return; + } if (launch.kind === "mailto") { const parsed = parseMailto(launch.raw); @@ -130,7 +213,12 @@ export function ProtocolLaunchHandlerProvider({ children }: ProtocolLaunchHandle notifyPendingWebcal(); if (pathname !== "/calendar") router.push("/calendar"); }); - }, [pathname, router]); + }, [pathname, router, tLaunch]); - return children; + return ( + <> + {children} + + + ); } diff --git a/hooks/use-handoff-files.ts b/hooks/use-handoff-files.ts new file mode 100644 index 000000000..9ef0eea8e --- /dev/null +++ b/hooks/use-handoff-files.ts @@ -0,0 +1,28 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { subscribeHandoff, takeHandoff, type HandoffKind } from "@/lib/launch-handoff"; + +/** + * The files handed to this part of the app (lib/launch-handoff.ts), one at a + * time. Nothing is taken while `active` is false, for a surface that isn't + * ready for them yet. `done` moves on to the next file. + */ +export function useHandoffFiles(kind: HandoffKind, active: boolean): { file: File | null; done: () => void } { + const [files, setFiles] = useState([]); + + useEffect(() => { + if (!active) return; + const take = () => { + for (let handoff = takeHandoff(kind); handoff; handoff = takeHandoff(kind)) { + const received = handoff.files; + if (received.length > 0) setFiles((waiting) => [...waiting, ...received]); + } + }; + take(); + return subscribeHandoff(kind, take); + }, [kind, active]); + + const done = useCallback(() => setFiles((waiting) => waiting.slice(1)), []); + return { file: files[0] ?? null, done }; +} diff --git a/lib/__tests__/launch-handoff.test.ts b/lib/__tests__/launch-handoff.test.ts new file mode 100644 index 000000000..5b21f7abe --- /dev/null +++ b/lib/__tests__/launch-handoff.test.ts @@ -0,0 +1,346 @@ +// @vitest-environment node +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { FakeCaches, loadWorker } from './sw-harness'; +import { + HANDOFF_CACHE, + HANDOFF_TTL_MS, + adoptStoredHandoff, + clearHandoffs, + handOffDraft, + handOffFiles, + handOffLaunchFiles, + handOffNewMessage, + kindForFile, + parseLaunchLanding, + pendingHandoffs, + restoreStoredHandoffs, + shareDraft, + shortcutFromTargetUrl, + stripLandingParams, + subscribeHandoff, + takeHandoff, +} from '../launch-handoff'; + +// Files and links from outside the app on their way to what handles them. + +const file = (name: string, type = '', body = 'x') => new File([body], name, { type }); +const handle = (f: File) => ({ kind: 'file', getFile: async () => f }); +const settle = () => new Promise((r) => setTimeout(r, 0)); + +function memoryStorage() { + const backing = new Map(); + return { + getItem: (key: string) => backing.get(key) ?? null, + setItem: (key: string, value: string) => { backing.set(key, value); }, + removeItem: (key: string) => { backing.delete(key); }, + }; +} + +let caches: FakeCaches; + +beforeEach(() => { + caches = new FakeCaches(); + vi.stubGlobal('caches', caches); + vi.stubGlobal('sessionStorage', memoryStorage()); +}); + +afterEach(() => { + clearHandoffs(); + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +describe('where a launched file goes', () => { + it.each([ + ['mail.eml', '', 'eml'], + ['MAIL.EML', '', 'eml'], + ['invite.ics', '', 'calendar'], + ['invite.ical', '', 'calendar'], + ['people.vcf', '', 'contacts'], + ['people.vcard', '', 'contacts'], + ['noextension', 'message/rfc822', 'eml'], + ['noextension', 'text/calendar', 'calendar'], + ['noextension', 'text/x-vcard', 'contacts'], + ['photo.png', 'image/png', null], + ])('%s (%s) goes to %s', (name, type, kind) => { + expect(kindForFile(file(name, type))).toBe(kind); + }); + + it('splits one launch by type and names what nothing handles', async () => { + const eml = file('a.eml'); + const ics = file('b.ics'); + const vcf = file('c.vcf'); + const result = await handOffLaunchFiles([handle(eml), handle(ics), handle(vcf), handle(file('d.png'))]); + + expect(result).toEqual({ unsupported: ['d.png'], failed: 0 }); + expect(takeHandoff('eml')?.files).toEqual([eml]); + expect(takeHandoff('calendar')?.files).toEqual([ics]); + expect(takeHandoff('contacts')?.files).toEqual([vcf]); + }); + + it('counts a file it could not read', async () => { + const result = await handOffLaunchFiles([{ kind: 'file', getFile: async () => { throw new Error('gone'); } }]); + expect(result).toEqual({ unsupported: [], failed: 1 }); + expect(pendingHandoffs()).toEqual([]); + }); + + it('skips a directory handle', async () => { + const result = await handOffLaunchFiles([{ kind: 'directory' }]); + expect(result).toEqual({ unsupported: [], failed: 0 }); + expect(pendingHandoffs()).toEqual([]); + }); +}); + +describe('handing off', () => { + it('gives a handoff to one taker, once', () => { + const ics = file('a.ics'); + handOffFiles([ics], 'calendar'); + expect(takeHandoff('contacts')).toBeNull(); + expect(takeHandoff('calendar')?.files).toEqual([ics]); + expect(takeHandoff('calendar')).toBeNull(); + }); + + it('tells the app first, while the handoff is still pending, then the taker', () => { + const order: string[] = []; + const offKind = subscribeHandoff('contacts', () => { + order.push(`taker:${takeHandoff('contacts') ? 'got it' : 'nothing'}`); + }); + const offAny = subscribeHandoff('any', (kind) => { + order.push(`app:${kind}:${pendingHandoffs().length}`); + }); + handOffFiles([file('a.vcf')], 'contacts'); + expect(order).toEqual(['app:contacts:1', 'taker:got it']); + offKind(); + offAny(); + }); + + it('stops telling a listener that unsubscribed', () => { + const listener = vi.fn(); + subscribeHandoff('calendar', listener)(); + handOffFiles([file('a.ics')], 'calendar'); + expect(listener).not.toHaveBeenCalled(); + }); + + it('does nothing for no files', () => { + handOffFiles([], 'calendar'); + expect(pendingHandoffs()).toEqual([]); + }); + + it('drops what waited too long', () => { + vi.useFakeTimers({ toFake: ['Date'] }); + handOffFiles([file('a.ics')], 'calendar'); + vi.advanceTimersByTime(HANDOFF_TTL_MS + 1); + expect(takeHandoff('calendar')).toBeNull(); + expect(pendingHandoffs()).toEqual([]); + }); + + it('counts a new message that arrives twice in one launch once', () => { + handOffNewMessage(); + handOffNewMessage(); + expect(takeHandoff('compose')).toMatchObject({ files: [] }); + expect(takeHandoff('compose')).toBeNull(); + }); + + it('carries the text of a draft', () => { + handOffDraft({ subject: 'Hi', body: 'There', files: [file('a.pdf')] }); + const handoff = takeHandoff('compose'); + expect(handoff).toMatchObject({ subject: 'Hi', body: 'There' }); + expect(handoff?.files.map((f) => f.name)).toEqual(['a.pdf']); + }); +}); + +describe('across a page load', () => { + // A sign-in through SSO leaves the page. A new module state stands in for + // the new page: the queue is emptied, sessionStorage and the cache stay. + async function newPage() { + pendingHandoffs(); + while (takeHandoffQuietly()) { /* the old page's memory goes */ } + } + function takeHandoffQuietly() { + const list = pendingHandoffs() as unknown as unknown[]; + return list.length > 0 ? list.splice(0, list.length) : null; + } + + it('brings back a file that waited for a sign-in, with its name and type', async () => { + handOffFiles([file('a.ics', 'text/calendar', 'BEGIN:VCALENDAR')], 'calendar'); + await settle(); + await newPage(); + expect(takeHandoff('calendar')).toBeNull(); + + await restoreStoredHandoffs(); + const handoff = takeHandoff('calendar'); + expect(handoff?.files[0].name).toBe('a.ics'); + expect(handoff?.files[0].type).toBe('text/calendar'); + expect(await handoff?.files[0].text()).toBe('BEGIN:VCALENDAR'); + }); + + it('removes the stored copy once it is taken', async () => { + handOffFiles([file('a.ics')], 'calendar'); + await settle(); + const store = await caches.open(HANDOFF_CACHE); + expect((await store.keys()).length).toBe(2); + + takeHandoff('calendar'); + await settle(); + await settle(); + expect((await store.keys()).length).toBe(0); + expect(sessionStorage.getItem('bulwark:launch-handoffs')).toBeNull(); + }); + + it('never stores a .eml: it opens at once and needs no sign-in', async () => { + handOffFiles([file('a.eml')], 'eml'); + await settle(); + expect(await caches.has(HANDOFF_CACHE)).toBe(false); + }); + + it("leaves another window's handoff alone", async () => { + handOffFiles([file('a.ics')], 'calendar'); + await settle(); + await newPage(); + // Another window: its own sessionStorage, the same cache. + vi.stubGlobal('sessionStorage', memoryStorage()); + await restoreStoredHandoffs(); + expect(takeHandoff('calendar')).toBeNull(); + expect((await (await caches.open(HANDOFF_CACHE)).keys()).length).toBe(2); + }); + + it('sweeps what nobody took, and keeps what is recent', async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + handOffFiles([file('old.ics')], 'calendar'); + await settle(); + await newPage(); + vi.stubGlobal('sessionStorage', memoryStorage()); + vi.advanceTimersByTime(HANDOFF_TTL_MS + 1); + handOffFiles([file('new.vcf')], 'contacts'); + await settle(); + + await restoreStoredHandoffs(); + const store = await caches.open(HANDOFF_CACHE); + const urls = (await store.keys()).map((key) => key.url); + expect(urls).toHaveLength(2); + const meta = await store.match(urls.find((url) => url.endsWith('/meta'))!); + expect(((await meta!.json()) as { kind: string }).kind).toBe('contacts'); + }); + + it('keeps working in memory without the Cache API', async () => { + vi.stubGlobal('caches', undefined); + const ics = file('a.ics'); + handOffFiles([ics], 'calendar'); + await settle(); + expect(takeHandoff('calendar')?.files).toEqual([ics]); + expect(await adoptStoredHandoff('anything')).toBe(false); + }); + + it('keeps the file in memory when the cache is full', async () => { + const original = caches.open.bind(caches); + vi.spyOn(caches, 'open').mockImplementation(async (name: string) => ({ + ...(await original(name)), + put: async () => { throw new DOMException('full', 'QuotaExceededError'); }, + })); + const ics = file('a.ics'); + handOffFiles([ics], 'calendar'); + await settle(); + expect(sessionStorage.getItem('bulwark:launch-handoffs')).toBeNull(); + expect(takeHandoff('calendar')?.files).toEqual([ics]); + }); +}); + +describe('sign-out', () => { + it('forgets what is waiting, in memory and stored', async () => { + handOffFiles([file('a.ics')], 'calendar'); + await settle(); + clearHandoffs(); + await settle(); + expect(pendingHandoffs()).toEqual([]); + expect(await caches.has(HANDOFF_CACHE)).toBe(false); + expect(sessionStorage.getItem('bulwark:launch-handoffs')).toBeNull(); + }); +}); + +describe('what the service worker stored', () => { + it('becomes a draft with the files, under the id in the address', async () => { + // The real worker writes it, so this fails if the two layouts drift apart. + const worker = loadWorker({ buildId: 'a', caches, clients: [] }); + const form = new FormData(); + form.set('title', 'A link'); + form.set('text', 'Look at this'); + form.set('url', 'https://example.org/page'); + form.append('files', file('photo.png', 'image/png', 'bytes')); + const answer = await worker.request({ url: 'https://mail.example.com/protocol/share', method: 'POST', mode: 'navigate', formData: async () => form } as never); + const landing = parseLaunchLanding(new URL(answer!.headers.get('location')!).search); + expect(landing?.type).toBe('stored'); + const { id } = landing as { id: string }; + + expect(await adoptStoredHandoff(id)).toBe(true); + const handoff = takeHandoff('compose'); + expect(handoff).toMatchObject({ subject: 'A link', body: 'Look at this\n\nhttps://example.org/page' }); + expect(handoff?.files[0].name).toBe('photo.png'); + expect(handoff?.files[0].type).toBe('image/png'); + expect(await handoff?.files[0].text()).toBe('bytes'); + await settle(); + await settle(); + expect((await (await caches.open(HANDOFF_CACHE)).keys()).length).toBe(0); + }); + + it('is gone when the id names nothing', async () => { + expect(await adoptStoredHandoff('11111111-2222')).toBe(false); + }); + + it('is gone when it is too old', async () => { + const store = await caches.open(HANDOFF_CACHE); + await store.put('/__bulwark/handoff/old/meta', new Response(JSON.stringify({ kind: 'compose', createdAt: Date.now() - HANDOFF_TTL_MS - 1, files: [] }))); + expect(await adoptStoredHandoff('old')).toBe(false); + expect((await store.keys()).length).toBe(0); + }); +}); + +describe('text of a share', () => { + it('puts the link under the text', () => { + expect(shareDraft({ title: ' T ', text: 'Hello', url: 'https://a.example' })).toEqual({ subject: 'T', body: 'Hello\n\nhttps://a.example' }); + }); + + it('does not repeat a link that is in the text already', () => { + expect(shareDraft({ text: 'See https://a.example now', url: 'https://a.example' }).body).toBe('See https://a.example now'); + }); + + it('copes with only a link', () => { + expect(shareDraft({ url: 'https://a.example' })).toEqual({ subject: '', body: 'https://a.example' }); + }); +}); + +describe('the address a launch lands on', () => { + it.each([ + ['?compose=1', { type: 'new-message' }], + ['?share=abc-123', { type: 'stored', id: 'abc-123' }], + ['?share=unavailable&share_title=T&share_text=B&share_files=1', { type: 'inline', subject: 'T', body: 'B', filesLost: true }], + ['?share=unavailable&share_url=https%3A%2F%2Fa.example', { type: 'inline', subject: '', body: 'https://a.example', filesLost: false }], + ['?share=..%2F..%2Fetc', null], + ['?compose=0', null], + ['?openLatestUnread=1', null], + ['', null], + ])('%s', (search, expected) => { + expect(parseLaunchLanding(search)).toEqual(expected); + }); + + it('leaves only what is not about the launch', () => { + expect(stripLandingParams('?compose=1')).toBe(''); + expect(stripLandingParams('?share=unavailable&share_text=x&slot=2')).toBe('?slot=2'); + }); +}); + +describe('a shortcut reaching an open window', () => { + const origin = 'https://mail.example.com'; + it.each([ + [`${origin}/?compose=1`, 'new-message'], + [`${origin}/en/?compose=1`, 'new-message'], + [`${origin}/calendar`, 'calendar'], + [`${origin}/de/contacts`, 'contacts'], + // The plain app icon and links into the app don't move an open window. + [`${origin}/`, null], + [`${origin}/calendar/some-event`, null], + [`${origin}/settings`, null], + ['https://evil.example/calendar', null], + ])('%s', (target, expected) => { + expect(shortcutFromTargetUrl(target, origin)).toBe(expected); + }); +}); diff --git a/lib/__tests__/manifest-launch.test.ts b/lib/__tests__/manifest-launch.test.ts new file mode 100644 index 000000000..124f67b55 --- /dev/null +++ b/lib/__tests__/manifest-launch.test.ts @@ -0,0 +1,200 @@ +// @vitest-environment node +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Shortcuts, file handlers and the share target in the manifest, with what +// the route already did (per-domain branding, the deployment subpath). + +const hoisted = vi.hoisted(() => ({ + requestHeaders: {} as Record, + config: {} as Record, + sources: {} as Record, +})); + +vi.mock('next/headers', () => ({ + headers: async () => new Headers(hoisted.requestHeaders), +})); + +// The real strings, so a missing or misnamed key fails here. +vi.mock('next-intl/server', () => ({ + getTranslations: async ({ locale, namespace }: { locale: string; namespace: string }) => { + const messages = JSON.parse(readFileSync(join(process.cwd(), 'locales', locale, 'common.json'), 'utf8')); + return (key: string) => { + const value = messages[namespace]?.[key]; + if (typeof value !== 'string') throw new Error(`missing ${namespace}.${key} in ${locale}`); + return value; + }; + }, +})); + +vi.mock('@/lib/admin/config-manager', () => ({ + configManager: { + ensureLoaded: async () => {}, + get: (key: string, fallback: unknown) => hoisted.config[key] ?? fallback, + getAllWithSources: () => hoisted.sources, + }, +})); + +type Manifest = { + name: string; + shortcuts: Array<{ name: string; url: string; icons: Array<{ src: string }> }>; + file_handlers: Array<{ action: string; accept: Record }>; + share_target: { action: string; method: string; enctype: string; params: Record }; + protocol_handlers: Array<{ protocol: string; url: string }>; +}; + +async function load(): Promise { + vi.resetModules(); + const { default: manifest } = await import('@/app/manifest'); + return (await manifest()) as unknown as Manifest; +} + +beforeEach(() => { + hoisted.requestHeaders = {}; + hoisted.config = {}; + hoisted.sources = { + pwaIconUrl: { source: 'default' }, + faviconUrl: { source: 'default' }, + pwaScreenshotMobileUrl: { source: 'default' }, + pwaScreenshotDesktopUrl: { source: 'default' }, + }; +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe('manifest launch fields', () => { + it('offers the three shortcuts', async () => { + const m = await load(); + expect(m.shortcuts.map((s) => [s.name, s.url])).toEqual([ + ['New message', '/?compose=1'], + ['Calendar', '/calendar'], + ['Contacts', '/contacts'], + ]); + }); + + it('names the shortcuts in the language the browser asks in', async () => { + hoisted.requestHeaders = { 'accept-language': 'de-DE,de;q=0.9,en;q=0.8' }; + expect((await load()).shortcuts.map((s) => s.name)).toEqual(['Neue Nachricht', 'Kalender', 'Kontakte']); + }); + + it('falls back to English for a language it does not have', async () => { + hoisted.requestHeaders = { 'accept-language': 'xx' }; + expect((await load()).shortcuts[0].name).toBe('New message'); + }); + + it('handles .eml, .ics and .vcf', async () => { + const m = await load(); + const extensions = (action: string) => Object.values(m.file_handlers.find((h) => h.action === action)?.accept ?? {}).flat(); + expect(extensions('/mail')).toEqual(['.eml']); + expect(extensions('/calendar')).toEqual(expect.arrayContaining(['.ics'])); + expect(extensions('/contacts')).toEqual(expect.arrayContaining(['.vcf'])); + expect(m.file_handlers).toHaveLength(3); + }); + + it('takes shares as multipart POST outside /api/, with files and links', async () => { + const { share_target: share } = await load(); + expect(share.action).toBe('/protocol/share'); + expect(share.action.startsWith('/api/')).toBe(false); + expect(share.method).toBe('POST'); + expect(share.enctype).toBe('multipart/form-data'); + expect(share.params).toEqual({ title: 'title', text: 'text', url: 'url', files: [{ name: 'files', accept: ['*/*'] }] }); + }); + + it('prefixes every new address with the subpath', async () => { + vi.stubEnv('NEXT_PUBLIC_BASE_PATH', '/webmail'); + const m = await load(); + expect(m.shortcuts.map((s) => s.url)).toEqual(['/webmail/?compose=1', '/webmail/calendar', '/webmail/contacts']); + expect(m.shortcuts[0].icons[0].src).toBe('/webmail/icon-192x192.png'); + expect(m.file_handlers.map((h) => h.action)).toEqual(['/webmail/mail', '/webmail/calendar', '/webmail/contacts']); + expect(m.share_target.action).toBe('/webmail/protocol/share'); + }); + + it("keeps a domain's branding, its icon on the shortcuts too", async () => { + hoisted.requestHeaders = { host: 'mail.acme.example' }; + hoisted.config = { domainBranding: [{ host: 'mail.acme.example', appName: 'Acme Mail', pwaIconUrl: 'https://cdn.example/i.png' }] }; + const m = await load(); + expect(m.name).toBe('Acme Mail'); + expect(m.shortcuts[0].icons[0].src).toBe('/api/pwa-icon/192'); + expect(m.file_handlers).toHaveLength(3); + expect(m.share_target.action).toBe('/protocol/share'); + }); + + it('leaves the protocol handlers as they are', async () => { + expect((await load()).protocol_handlers.map((h) => h.url)).toEqual(['/protocol/mailto?url=%s', '/protocol/webcal?url=%s']); + }); +}); + +describe('Lite', () => { + it('has no share target or file handlers: no service worker, no launch consumer behind them', async () => { + const { buildManifest } = await import('@/scripts/lite/lib.mjs'); + const lite = buildManifest({ appName: 'Acme' }); + expect(lite).not.toHaveProperty('share_target'); + expect(lite).not.toHaveProperty('file_handlers'); + expect(lite).not.toHaveProperty('shortcuts'); + }); +}); + +describe('share route without a service worker', () => { + const route = () => import('@/app/(main)/protocol/share/route'); + + function post(form: FormData | null, contentLength?: string) { + const request = new Request('https://mail.example.com/protocol/share', { method: 'POST', ...(form ? { body: form } : {}) }); + if (contentLength !== undefined) { + const headers = new Headers(request.headers); + headers.set('content-length', contentLength); + return { headers, formData: () => request.formData() } as unknown as Request; + } + return request; + } + + it('sends text and links on to a new draft, and goes to the app with a 303', async () => { + const { POST } = await route(); + const form = new FormData(); + form.set('title', 'T'); + form.set('text', 'B'); + form.set('url', 'https://example.org/'); + const res = await POST(post(form, '500')); + expect(res.status).toBe(303); + const landing = new URL(res.headers.get('location')!, 'https://x.example'); + expect(landing.pathname).toBe('/'); + expect(Object.fromEntries(landing.searchParams)).toEqual({ share: 'unavailable', share_title: 'T', share_text: 'B', share_url: 'https://example.org/' }); + }); + + it('says files were lost', async () => { + const { POST } = await route(); + const form = new FormData(); + form.set('text', 'B'); + form.append('files', new File(['x'], 'a.png')); + const res = await POST(post(form, '500')); + expect(new URL(res.headers.get('location')!, 'https://x.example').searchParams.get('share_files')).toBe('1'); + }); + + it('does not read a large upload into memory', async () => { + const { POST } = await route(); + const form = new FormData(); + form.set('text', 'B'); + const spy = vi.fn(async () => form); + const request = { headers: new Headers({ 'content-length': String(50 * 1024 * 1024) }), formData: spy } as unknown as Request; + const res = await POST(request); + expect(spy).not.toHaveBeenCalled(); + expect(new URL(res.headers.get('location')!, 'https://x.example').searchParams.get('share_files')).toBe('1'); + }); + + it('keeps the address short', async () => { + const { POST } = await route(); + const form = new FormData(); + form.set('text', 'x'.repeat(100_000)); + const res = await POST(post(form, '100000')); + expect(res.headers.get('location')!.length).toBeLessThan(4200); + }); + + it('honours the subpath', async () => { + vi.stubEnv('NEXT_PUBLIC_BASE_PATH', '/webmail'); + vi.resetModules(); + const { GET } = await route(); + expect(GET().headers.get('location')).toBe('/webmail/'); + }); +}); diff --git a/lib/__tests__/sign-out-cleanup.test.ts b/lib/__tests__/sign-out-cleanup.test.ts index 270fc1606..355f623e6 100644 --- a/lib/__tests__/sign-out-cleanup.test.ts +++ b/lib/__tests__/sign-out-cleanup.test.ts @@ -6,6 +6,7 @@ vi.mock('@/lib/plugin-storage', () => ({ import { fileStorage } from '@/lib/plugin-storage'; import { broadcastSignOut, onSignedOutElsewhere, purgeSignedOutData } from '@/lib/sign-out-cleanup'; +import { handOffFiles, pendingHandoffs } from '@/lib/launch-handoff'; // A full sign-out left search history, open tabs (with subjects), Files // favourites and staged attachment bytes behind for the next person at the @@ -14,6 +15,13 @@ import { broadcastSignOut, onSignedOutElsewhere, purgeSignedOutData } from '@/li beforeEach(() => localStorage.clear()); describe('purgeSignedOutData', () => { + it('forgets files and links on their way into an account', () => { + handOffFiles([new File(['x'], 'a.ics')], 'calendar'); + expect(pendingHandoffs()).toHaveLength(1); + purgeSignedOutData(); + expect(pendingHandoffs()).toHaveLength(0); + }); + it("removes the accounts' leftovers and keeps device preferences", () => { for (const key of [ 'email-snapshot', 'pro-tabs', 'search-history-storage', 'calendar-storage', diff --git a/lib/__tests__/sw-harness.ts b/lib/__tests__/sw-harness.ts index d54631a62..3f3479d40 100644 --- a/lib/__tests__/sw-harness.ts +++ b/lib/__tests__/sw-harness.ts @@ -29,17 +29,17 @@ export class FakeCaches { } } -export function loadWorker(opts: { buildId: string; caches: FakeCaches; clients: Array<{ id: string }>; fetch?: (input: unknown) => Promise }) { +export function loadWorker(opts: { buildId: string; caches: FakeCaches; clients: Array<{ id: string }>; fetch?: (input: unknown) => Promise; location?: string }) { const listeners = new Map void>(); let skipped = 0; const self = { - location: { href: `https://mail.example.com/sw.js?v=${opts.buildId}`, origin: 'https://mail.example.com' }, + location: { href: opts.location ?? `https://mail.example.com/sw.js?v=${opts.buildId}`, origin: 'https://mail.example.com' }, addEventListener: (type: string, fn: (event: unknown) => void) => listeners.set(type, fn), skipWaiting: () => { skipped++; }, clients: { claim: async () => {}, matchAll: async () => opts.clients }, registration: { showNotification: async () => {}, getNotifications: async () => [] }, }; - const context = vm.createContext({ self, caches: opts.caches, fetch: opts.fetch ?? (async () => { throw new TypeError('offline'); }), URL, URLSearchParams, Request, Response, Headers, Date, console, AbortController, setTimeout, clearTimeout }); + const context = vm.createContext({ self, caches: opts.caches, fetch: opts.fetch ?? (async () => { throw new TypeError('offline'); }), URL, URLSearchParams, Request, Response, Headers, Date, console, AbortController, setTimeout, clearTimeout, crypto: globalThis.crypto }); vm.runInContext(SW_SOURCE, context); async function dispatch(type: string, event: Record = {}) { const waits: Promise[] = []; diff --git a/lib/__tests__/sw-share-target.test.ts b/lib/__tests__/sw-share-target.test.ts new file mode 100644 index 000000000..799517e37 --- /dev/null +++ b/lib/__tests__/sw-share-target.test.ts @@ -0,0 +1,152 @@ +// @vitest-environment node +import { describe, expect, it, vi } from 'vitest'; +import { FakeCaches, loadWorker } from './sw-harness'; + +// The manifest's share_target POST, in the service worker, and a mailto: +// launch while offline. Before: a POST went to the server when offline mode was off. + +const ORIGIN = 'https://mail.example.com'; +const settle = () => new Promise((r) => setTimeout(r, 0)); + +function shareRequest(form: FormData, overrides: Record = {}) { + return { url: `${ORIGIN}/protocol/share`, method: 'POST', mode: 'navigate', formData: async () => form, ...overrides } as never; +} + +function sharedForm(): FormData { + const form = new FormData(); + form.set('title', 'A link'); + form.set('text', 'Look'); + form.set('url', 'https://example.org/'); + form.append('files', new File(['bytes'], 'photo.png', { type: 'image/png' })); + return form; +} + +async function shellOn(caches: FakeCaches, on: boolean) { + const cache = await caches.open('bulwark-sw-state-v1'); + await cache.put('/__bulwark/offline-shell', new Response(JSON.stringify({ on }))); +} + +function htmlPage(body: string) { + const res = new Response(body, { status: 200, headers: { 'Content-Type': 'text/html' } }); + Object.defineProperty(res, 'type', { value: 'basic' }); + return res; +} + +describe('share target', () => { + it.each([[false], [true]])('takes the POST with offline mode on=%s, keeps the files and goes to the app', async (on) => { + const caches = new FakeCaches(); + await shellOn(caches, on); + const fetchMock = vi.fn(); + const worker = loadWorker({ buildId: 'a', caches, clients: [], fetch: fetchMock }); + await settle(); + + const answer = await worker.request(shareRequest(sharedForm())); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(answer?.status).toBe(303); + const landing = new URL(answer!.headers.get('location')!); + expect(`${landing.origin}${landing.pathname}`).toBe(`${ORIGIN}/`); + const id = landing.searchParams.get('share')!; + expect(id).toMatch(/^[\w-]+$/); + + const store = await caches.open('bulwark-launch-handoff-v1'); + const meta = (await (await store.match(`/__bulwark/handoff/${id}/meta`))!.json()) as Record; + expect(meta).toMatchObject({ kind: 'compose', title: 'A link', text: 'Look', url: 'https://example.org/', files: [{ name: 'photo.png', type: 'image/png' }] }); + expect(await (await store.match(`/__bulwark/handoff/${id}/0`))!.text()).toBe('bytes'); + }); + + it('answers while the worker has only just started and has not read its state', async () => { + const worker = loadWorker({ buildId: 'a', caches: new FakeCaches(), clients: [] }); + const answer = await worker.request(shareRequest(sharedForm())); + expect(answer?.status).toBe(303); + }); + + it('works under a subpath', async () => { + const worker = loadWorker({ buildId: 'a', caches: new FakeCaches(), clients: [], location: `${ORIGIN}/webmail/sw.js?v=a` }); + expect(await worker.request(shareRequest(sharedForm()))).toBeNull(); + const answer = await worker.request(shareRequest(sharedForm(), { url: `${ORIGIN}/webmail/protocol/share` })); + expect(answer!.headers.get('location')).toContain(`${ORIGIN}/webmail/?share=`); + }); + + it('skips empty file fields', async () => { + const caches = new FakeCaches(); + const worker = loadWorker({ buildId: 'a', caches, clients: [] }); + const form = new FormData(); + form.set('url', 'https://example.org/'); + form.append('files', new File([], '')); + const answer = await worker.request(shareRequest(form)); + const id = new URL(answer!.headers.get('location')!).searchParams.get('share')!; + const store = await caches.open('bulwark-launch-handoff-v1'); + const meta = (await (await store.match(`/__bulwark/handoff/${id}/meta`))!.json()) as { files: unknown[] }; + expect(meta.files).toEqual([]); + }); + + it('keeps the text and says the files are lost when there is no room', async () => { + const caches = new FakeCaches(); + const original = caches.open.bind(caches); + const store = await original('bulwark-launch-handoff-v1'); + vi.spyOn(caches, 'open').mockImplementation(async (name: string) => (name === 'bulwark-launch-handoff-v1' + ? { ...store, put: async () => { throw new Error('QuotaExceededError'); } } + : original(name))); + const worker = loadWorker({ buildId: 'a', caches, clients: [] }); + + const answer = await worker.request(shareRequest(sharedForm())); + + const landing = new URL(answer!.headers.get('location')!).searchParams; + expect(landing.get('share')).toBe('unavailable'); + expect(landing.get('share_files')).toBe('1'); + expect(landing.get('share_title')).toBe('A link'); + expect(landing.get('share_url')).toBe('https://example.org/'); + expect((await store.keys()).length).toBe(0); + }); + + it('says so when the form cannot be read', async () => { + const worker = loadWorker({ buildId: 'a', caches: new FakeCaches(), clients: [] }); + const answer = await worker.request(shareRequest(new FormData(), { formData: async () => { throw new TypeError('bad body'); } })); + const landing = new URL(answer!.headers.get('location')!).searchParams; + expect(landing.get('share')).toBe('unavailable'); + expect(landing.get('share_files')).toBe('1'); + }); + + it('leaves other requests alone', async () => { + const caches = new FakeCaches(); + await shellOn(caches, false); + const worker = loadWorker({ buildId: 'a', caches, clients: [] }); + await settle(); + // A fetch from a page, a POST elsewhere, another origin, and a plain GET. + expect(await worker.request(shareRequest(sharedForm(), { mode: 'cors' }))).toBeNull(); + expect(await worker.request(shareRequest(sharedForm(), { url: `${ORIGIN}/api/share` }))).toBeNull(); + expect(await worker.request(shareRequest(sharedForm(), { url: 'https://other.example/protocol/share' }))).toBeNull(); + expect(await worker.request({ url: `${ORIGIN}/protocol/share`, mode: 'navigate' })).toBeNull(); + expect(await caches.has('bulwark-launch-handoff-v1')).toBe(false); + }); +}); + +describe('a mailto: launch without a connection', () => { + it('gets the kept handler page instead of a network error', async () => { + const caches = new FakeCaches(); + await shellOn(caches, true); + const online = vi.fn(async () => htmlPage('mailto page')); + const worker = loadWorker({ buildId: 'a', caches, clients: [], fetch: online }); + await settle(); + + await worker.dispatch('message', { data: { type: 'offline-shell', on: true, urls: ['/protocol/mailto'], buildId: 'a' } }); + await settle(); + + online.mockImplementation(async () => { throw new TypeError('offline'); }); + const answer = await worker.request({ url: `${ORIGIN}/protocol/mailto?url=mailto%3Aa%40example.org`, mode: 'navigate' }); + expect(await answer!.text()).toBe('mailto page'); + }); + + it('falls back to the start page when that page was never kept', async () => { + const caches = new FakeCaches(); + await shellOn(caches, true); + const shell = await caches.open('bulwark-shell-a'); + await shell.put(ORIGIN, htmlPage('start')); + const worker = loadWorker({ buildId: 'a', caches, clients: [] }); + await settle(); + + const answer = await worker.request({ url: `${ORIGIN}/protocol/webcal?url=x`, mode: 'navigate' }); + expect(await answer!.text()).toBe('start'); + }); +}); diff --git a/lib/launch-handoff.ts b/lib/launch-handoff.ts new file mode 100644 index 000000000..a072cf0ac --- /dev/null +++ b/lib/launch-handoff.ts @@ -0,0 +1,375 @@ +/** + * Files and links that arrive from outside the app, on their way to the part + * of the app that handles them: a .eml to the viewer, a .ics to the calendar + * import, a .vcf to the contacts import, shared files and links to a new + * draft. + * + * Three things feed it: the launchQueue consumer + * (components/protocol/protocol-launch-handler-provider.tsx), the service + * worker's share target (public/sw.js) and, later, the desktop app's bridge, + * because launchQueue never fires in Electron. A bridge only has to call + * handOffFiles, handOffDraft or handOffNewMessage. + * + * The pending mailto next to it (lib/protocol-handlers/session.ts) is JSON in + * sessionStorage, which can't hold a File. So a handoff lives in this module's + * memory, and everything except a .eml also goes into the Cache API: signing + * in with SSO, or after a session ended, leaves the page, and the file has to + * still be there afterwards. sessionStorage only holds which stored handoffs + * belong to this tab, so a second window doesn't take them. What is stored + * goes when it is taken, when it is too old, or at sign-out. + */ + +import { matchSurface } from '@/lib/deep-links'; + +export type HandoffKind = 'compose' | 'eml' | 'calendar' | 'contacts'; + +export interface Handoff { + id: string; + kind: HandoffKind; + files: File[]; + /** New draft only. */ + subject?: string; + body?: string; + createdAt: number; +} + +/** The service worker writes this cache too, in the same layout. */ +export const HANDOFF_CACHE = 'bulwark-launch-handoff-v1'; +const STORE_PREFIX = '/__bulwark/handoff/'; +const TAB_IDS_KEY = 'bulwark:launch-handoffs'; +export const HANDOFF_TTL_MS = 15 * 60 * 1000; +/** A launch can arrive twice: as launchQueue's target and as the page's own address. */ +const NEW_MESSAGE_REPEAT_MS = 2000; + +type Listener = (kind: HandoffKind) => void; + +const queue: Handoff[] = []; +const stored = new Map>(); +const kindListeners = new Map>(); +const anyListeners = new Set(); +let lastNewMessageAt = 0; + +function newId(): string { + return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' + ? crypto.randomUUID() + : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; +} + +function cacheApi(): CacheStorage | null { + return typeof caches !== 'undefined' && caches && typeof caches.open === 'function' ? caches : null; +} + +function readTabIds(): string[] { + try { + const parsed: unknown = JSON.parse(sessionStorage.getItem(TAB_IDS_KEY) ?? '[]'); + return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : []; + } catch { + return []; + } +} + +function writeTabIds(ids: string[]): void { + try { + if (ids.length > 0) sessionStorage.setItem(TAB_IDS_KEY, JSON.stringify(ids)); + else sessionStorage.removeItem(TAB_IDS_KEY); + } catch { + // Hardened or private modes: the handoff stays in memory only. + } +} + +async function writeStored(handoff: Handoff): Promise { + const api = cacheApi(); + if (!api) return; + const prefix = STORE_PREFIX + handoff.id + '/'; + try { + const cache = await api.open(HANDOFF_CACHE); + // The list first: the sweep goes by it, so no file can stay behind unlisted. + await cache.put(prefix + 'meta', new Response(JSON.stringify({ + kind: handoff.kind, + createdAt: handoff.createdAt, + title: handoff.subject ?? '', + text: handoff.body ?? '', + url: '', + files: handoff.files.map((file) => ({ name: file.name, type: file.type })), + }))); + await Promise.all(handoff.files.map((file, index) => cache.put(prefix + index, new Response(file)))); + writeTabIds([...readTabIds().filter((id) => id !== handoff.id), handoff.id]); + } catch { + // Quota, or no Cache API here: the handoff stays in memory only. + await deleteStored(handoff.id); + } +} + +async function deleteStored(id: string): Promise { + writeTabIds(readTabIds().filter((other) => other !== id)); + const api = cacheApi(); + if (!api) return; + try { + if (!(await api.has(HANDOFF_CACHE))) return; + const cache = await api.open(HANDOFF_CACHE); + const prefix = STORE_PREFIX + id + '/'; + const keys = await cache.keys(); + const own = keys.filter((key) => new URL(key.url, 'https://x.invalid').pathname.startsWith(prefix)); + // The list last, for the sweep. + await Promise.all(own.filter((key) => !key.url.endsWith('/meta')).map((key) => cache.delete(key))); + await cache.delete(prefix + 'meta'); + } catch { + // The sweep gets it. + } +} + +function notify(kind: HandoffKind): void { + // The app first, so it can go to the right page while the handoff is still + // pending, then whoever takes it. + for (const listener of [...anyListeners]) listener(kind); + for (const listener of [...(kindListeners.get(kind) ?? [])]) listener(kind); +} + +function enqueue(handoff: Handoff, persist: boolean): void { + queue.push(handoff); + if (persist) stored.set(handoff.id, writeStored(handoff)); + notify(handoff.kind); +} + +/** The kind a file from the OS goes to; null when nothing here handles it. */ +export function kindForFile(file: { name: string; type?: string }): HandoffKind | null { + const dot = file.name.lastIndexOf('.'); + const extension = dot >= 0 ? file.name.slice(dot).toLowerCase() : ''; + if (extension === '.eml') return 'eml'; + if (extension === '.ics' || extension === '.ical') return 'calendar'; + if (extension === '.vcf' || extension === '.vcard') return 'contacts'; + const type = (file.type ?? '').toLowerCase(); + if (type === 'message/rfc822') return 'eml'; + if (type === 'text/calendar') return 'calendar'; + if (type === 'text/vcard' || type === 'text/x-vcard') return 'contacts'; + return null; +} + +/** Hands files to a part of the app, which opens when they arrive. */ +export function handOffFiles(files: File[], kind: HandoffKind): void { + if (files.length === 0) return; + // A .eml opens at once and needs no sign-in, so it never waits for one. + enqueue({ id: newId(), kind, files, createdAt: Date.now() }, kind !== 'eml'); +} + +/** Opens a new draft with this text and these files. */ +export function handOffDraft(draft: { subject?: string; body?: string; files?: File[] }): void { + enqueue({ + id: newId(), + kind: 'compose', + files: draft.files ?? [], + ...(draft.subject ? { subject: draft.subject } : {}), + ...(draft.body ? { body: draft.body } : {}), + createdAt: Date.now(), + }, true); +} + +/** Opens an empty new message. */ +export function handOffNewMessage(): void { + const now = Date.now(); + if (now - lastNewMessageAt < NEW_MESSAGE_REPEAT_MS) return; + lastNewMessageAt = now; + enqueue({ id: newId(), kind: 'compose', files: [], createdAt: now }, false); +} + +/** + * Routes files from a launch by their type. Returns the names nothing handles + * and how many couldn't be read. + */ +export async function handOffLaunchFiles( + handles: ReadonlyArray<{ kind?: string; getFile?: () => Promise }>, +): Promise<{ unsupported: string[]; failed: number }> { + const byKind = new Map(); + const unsupported: string[] = []; + let failed = 0; + for (const handle of handles) { + if (typeof handle.getFile !== 'function') continue; + try { + const file = await handle.getFile(); + const kind = kindForFile(file); + if (!kind) { + unsupported.push(file.name); + continue; + } + byKind.set(kind, [...(byKind.get(kind) ?? []), file]); + } catch { + failed += 1; + } + } + for (const [kind, files] of byKind) handOffFiles(files, kind); + return { unsupported, failed }; +} + +/** The oldest handoff of this kind; it is gone from here after this. */ +export function takeHandoff(kind: HandoffKind): Handoff | null { + const now = Date.now(); + for (let index = 0; index < queue.length; index += 1) { + if (queue[index].kind !== kind) continue; + const [handoff] = queue.splice(index, 1); + index -= 1; + void (stored.get(handoff.id) ?? Promise.resolve()).then(() => deleteStored(handoff.id)); + stored.delete(handoff.id); + if (now - handoff.createdAt <= HANDOFF_TTL_MS) return handoff; + } + return null; +} + +/** What is waiting, oldest first. Nothing is taken. */ +export function pendingHandoffs(): readonly Handoff[] { + return queue; +} + +/** `kind` "any" hears every handoff, before those listening for that kind. */ +export function subscribeHandoff(kind: HandoffKind | 'any', listener: Listener): () => void { + if (kind === 'any') { + anyListeners.add(listener); + return () => { anyListeners.delete(listener); }; + } + const set = kindListeners.get(kind) ?? new Set(); + kindListeners.set(kind, set); + set.add(listener); + return () => { set.delete(listener); }; +} + +/** Subject and body of a draft from what the OS shared. */ +export function shareDraft(shared: { title?: string; text?: string; url?: string }): { subject: string; body: string } { + const text = (shared.text ?? '').trim(); + const url = (shared.url ?? '').trim(); + // Android often puts the link in the text as well. + const body = url && !text.includes(url) ? [text, url].filter(Boolean).join('\n\n') : text; + return { subject: (shared.title ?? '').trim(), body }; +} + +/** Brings a stored handoff into this tab. False when it is gone. */ +export async function adoptStoredHandoff(id: string): Promise { + if (queue.some((handoff) => handoff.id === id)) return true; + const api = cacheApi(); + if (!api) return false; + const prefix = STORE_PREFIX + id + '/'; + try { + if (!(await api.has(HANDOFF_CACHE))) return false; + const cache = await api.open(HANDOFF_CACHE); + const metaResponse = await cache.match(prefix + 'meta'); + if (!metaResponse) return false; + const meta = (await metaResponse.json()) as { + kind: HandoffKind; createdAt: number; title?: string; text?: string; url?: string; + files?: Array<{ name: string; type?: string }>; + }; + if (Date.now() - meta.createdAt > HANDOFF_TTL_MS) { + await deleteStored(id); + return false; + } + const files: File[] = []; + for (const [index, entry] of (meta.files ?? []).entries()) { + const response = await cache.match(prefix + index); + if (!response) continue; + files.push(new File([await response.blob()], entry.name, { type: entry.type ?? '' })); + } + const { subject, body } = shareDraft(meta); + // A second await above: another caller may have got here first. + if (queue.some((handoff) => handoff.id === id)) return true; + if (!readTabIds().includes(id)) writeTabIds([...readTabIds(), id]); + enqueue({ + id, + kind: meta.kind, + files, + ...(subject ? { subject } : {}), + ...(body ? { body } : {}), + createdAt: meta.createdAt, + }, false); + return true; + } catch { + return false; + } +} + +/** After a page load: this tab's stored handoffs come back, what is too old goes. */ +export async function restoreStoredHandoffs(): Promise { + await Promise.all(readTabIds().map(async (id) => { + if (!(await adoptStoredHandoff(id))) writeTabIds(readTabIds().filter((other) => other !== id)); + })); + const api = cacheApi(); + if (!api) return; + try { + if (!(await api.has(HANDOFF_CACHE))) return; + const cache = await api.open(HANDOFF_CACHE); + for (const key of await cache.keys()) { + const match = /\/__bulwark\/handoff\/([^/]+)\/meta$/.exec(key.url); + if (!match) continue; + const response = await cache.match(key); + const meta = response ? ((await response.json()) as { createdAt?: number }) : null; + if (!meta || typeof meta.createdAt !== 'number' || Date.now() - meta.createdAt > HANDOFF_TTL_MS) { + await deleteStored(match[1]); + } + } + } catch { + // Tried again at the next page load. + } +} + +/** Sign-out: nothing that was on its way into an account stays. */ +export function clearHandoffs(): void { + queue.length = 0; + stored.clear(); + writeTabIds([]); + lastNewMessageAt = 0; + const api = cacheApi(); + if (api) void api.delete(HANDOFF_CACHE).catch(() => {}); +} + +export type LaunchLanding = + | { type: 'new-message' } + /** The service worker stored what was shared. */ + | { type: 'stored'; id: string } + /** It couldn't be stored, or no worker ran: the text came along in the address. */ + | { type: 'inline'; subject: string; body: string; filesLost: boolean }; + +const LANDING_PARAMS = ['compose', 'share', 'share_title', 'share_text', 'share_url', 'share_files']; + +/** What the page's own address asks for, once. See stripLandingParams. */ +export function parseLaunchLanding(search: string): LaunchLanding | null { + const params = new URLSearchParams(search); + if (params.get('compose') === '1') return { type: 'new-message' }; + const share = params.get('share'); + if (!share) return null; + if (share === 'unavailable') { + return { + type: 'inline', + ...shareDraft({ + title: params.get('share_title') ?? '', + text: params.get('share_text') ?? '', + url: params.get('share_url') ?? '', + }), + filesLost: params.get('share_files') === '1', + }; + } + return /^[\w-]{1,64}$/.test(share) ? { type: 'stored', id: share } : null; +} + +/** The query without what parseLaunchLanding read, so a reload doesn't do it again. */ +export function stripLandingParams(search: string): string { + const params = new URLSearchParams(search); + for (const name of LANDING_PARAMS) params.delete(name); + const rest = params.toString(); + return rest ? `?${rest}` : ''; +} + +/** + * What a launch's address asks of a window that is already open. A shortcut + * with launch_handler focus-existing only focuses that window and passes the + * address here. Anything else, the start page included, stays as it is. + */ +export function shortcutFromTargetUrl(targetURL: string, origin: string): 'new-message' | 'calendar' | 'contacts' | null { + let url: URL; + try { + url = new URL(targetURL, origin); + } catch { + return null; + } + if (url.origin !== origin) return null; + const match = matchSurface(url.pathname); + if (!match || match.segments.length > 0) return null; + if (match.surface === 'mail') return url.searchParams.get('compose') === '1' ? 'new-message' : null; + if (match.surface === 'calendar' || match.surface === 'contacts') return match.surface; + return null; +} diff --git a/lib/offline/__tests__/shell-pages.test.ts b/lib/offline/__tests__/shell-pages.test.ts new file mode 100644 index 000000000..20a8e3716 --- /dev/null +++ b/lib/offline/__tests__/shell-pages.test.ts @@ -0,0 +1,26 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +// A mailto: or webcal: launch without a connection needs its handler page in +// the app shell. Its address is what opens the draft; the start page alone +// doesn't read it outside an installed app. + +vi.mock('@/lib/browser-navigation', () => ({ + apiFetch: async () => new Response(JSON.stringify({ buildId: 'build-1', files: ['/_next/static/a.js'] })), + withBasePath: (path: string) => path, +})); +vi.mock('@/lib/app-update', () => ({ currentBuildId: () => 'build-1', SW_SCOPE: '/' })); + +const { tellServiceWorker } = await import('../shell'); + +afterEach(() => vi.unstubAllGlobals()); + +describe('app shell pages', () => { + it('includes the protocol handler pages', async () => { + const postMessage = vi.fn(); + vi.stubGlobal('navigator', { serviceWorker: { getRegistration: async () => ({ active: { postMessage } }) } }); + + await tellServiceWorker(true); + + expect(postMessage.mock.calls[0][0].urls).toEqual(expect.arrayContaining(['/', '/protocol/mailto', '/protocol/webcal'])); + }); +}); diff --git a/lib/offline/shell.ts b/lib/offline/shell.ts index 88aa07b4e..295bdc325 100644 --- a/lib/offline/shell.ts +++ b/lib/offline/shell.ts @@ -8,7 +8,7 @@ import { apiFetch, withBasePath } from '@/lib/browser-navigation'; import { currentBuildId, SW_SCOPE } from '@/lib/app-update'; /** The app's pages kept for a start without the network. */ -const SHELL_PAGES = ['/', '/mail', '/settings']; +const SHELL_PAGES = ['/', '/mail', '/settings', '/protocol/mailto', '/protocol/webcal']; /** * The files of this tab's build. After a deploy the server lists the new diff --git a/lib/sign-out-cleanup.ts b/lib/sign-out-cleanup.ts index 2b00aaf51..fbdd44507 100644 --- a/lib/sign-out-cleanup.ts +++ b/lib/sign-out-cleanup.ts @@ -1,4 +1,5 @@ import { fileStorage } from '@/lib/plugin-storage'; +import { clearHandoffs } from '@/lib/launch-handoff'; import { clearAllCheckpoints } from '@/lib/mail-import/checkpoints'; import { sweepPrivateExports } from '@/lib/mail-export/sinks'; @@ -114,6 +115,8 @@ export function purgeSignedOutData(): void { /* storage unavailable */ } forgetOfflineShell(); + // Files and links on their way into a draft or an import. + clearHandoffs(); for (const cleanup of signedOutCleanups) { try { cleanup(); diff --git a/locales/ar/common.json b/locales/ar/common.json index cc8420d14..8bc955352 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -249,6 +249,17 @@ "active_account": "نشط", "switching_account": "جارٍ تبديل الحساب..." }, + "pwaLaunch": { + "shortcut_new_message": "رسالة جديدة", + "shortcut_calendar": "التقويم", + "shortcut_contacts": "جهات الاتصال", + "unsupported_file": "لا يمكن فتح {name} هنا.", + "open_failed": "تعذر فتح الملف. حاول فتحه مرة أخرى.", + "share_gone": "ما شاركته لم يعد موجودًا. يرجى مشاركته مرة أخرى.", + "share_files_lost": "تعذر الاحتفاظ بالملفات المشتركة. أضفها إلى الرسالة مرة أخرى.", + "ics_needs_server": "يحتاج استيراد ملف تقويم إلى اتصال بالخادم. يبدأ الاستيراد عند عودتك إلى الاتصال بالإنترنت.", + "contacts_need_server": "يحتاج استيراد جهات الاتصال إلى اتصال بالخادم. حاول مرة أخرى عند عودتك إلى الاتصال بالإنترنت." + }, "sidebar_apps": { "modal_title": "تطبيقات الشريط الجانبي", "add_new": "إضافة تطبيق", diff --git a/locales/ca/common.json b/locales/ca/common.json index 6e15f53fc..57dfafc11 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -249,6 +249,17 @@ "active_account": "Actiu", "switching_account": "Canviant de compte..." }, + "pwaLaunch": { + "shortcut_new_message": "Missatge nou", + "shortcut_calendar": "Calendari", + "shortcut_contacts": "Contactes", + "unsupported_file": "{name} no es pot obrir aquí.", + "open_failed": "No s'ha pogut obrir el fitxer. Torneu a obrir-lo.", + "share_gone": "El que heu compartit ja no hi és. Torneu a compartir-ho.", + "share_files_lost": "No s'han pogut conservar els fitxers compartits. Torneu-los a afegir al missatge.", + "ics_needs_server": "Per importar un fitxer de calendari cal connexió amb el servidor. La importació començarà quan torneu a estar en línia.", + "contacts_need_server": "Per importar contactes cal connexió amb el servidor. Torneu-ho a provar quan torneu a estar en línia." + }, "sidebar_apps": { "modal_title": "Aplicacions de la barra lateral", "add_new": "Afegeix una aplicació", diff --git a/locales/cs/common.json b/locales/cs/common.json index d8426dce2..b564e4a67 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -249,6 +249,17 @@ "active_account": "Aktivní", "switching_account": "Přepínání účtu..." }, + "pwaLaunch": { + "shortcut_new_message": "Nová zpráva", + "shortcut_calendar": "Kalendář", + "shortcut_contacts": "Kontakty", + "unsupported_file": "{name} zde nelze otevřít.", + "open_failed": "Soubor se nepodařilo otevřít. Zkuste ho otevřít znovu.", + "share_gone": "To, co jste sdíleli, už tu není. Sdílejte to prosím znovu.", + "share_files_lost": "Sdílené soubory se nepodařilo uchovat. Přidejte je do zprávy znovu.", + "ics_needs_server": "Import souboru kalendáře vyžaduje připojení k serveru. Import začne, až budete znovu online.", + "contacts_need_server": "Import kontaktů vyžaduje připojení k serveru. Zkuste to znovu, až budete online." + }, "sidebar_apps": { "modal_title": "Aplikace postranního panelu", "add_new": "Přidat aplikaci", diff --git a/locales/da/common.json b/locales/da/common.json index cf66a7294..9935f289d 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -249,6 +249,17 @@ "active_account": "Aktiv", "switching_account": "Skifter konto..." }, + "pwaLaunch": { + "shortcut_new_message": "Ny besked", + "shortcut_calendar": "Kalender", + "shortcut_contacts": "Kontakter", + "unsupported_file": "{name} kan ikke åbnes her.", + "open_failed": "Filen kunne ikke åbnes. Prøv at åbne den igen.", + "share_gone": "Det, du delte, er her ikke længere. Del det venligst igen.", + "share_files_lost": "De delte filer kunne ikke gemmes. Tilføj dem til beskeden igen.", + "ics_needs_server": "Import af en kalenderfil kræver forbindelse til serveren. Importen starter, når du er online igen.", + "contacts_need_server": "Import af kontakter kræver forbindelse til serveren. Prøv igen, når du er online igen." + }, "sidebar_apps": { "modal_title": "Sidepanel-apps", "add_new": "Tilføj app", diff --git a/locales/de/common.json b/locales/de/common.json index 3772dbab8..0466fccf5 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -249,6 +249,17 @@ "active_account": "Aktiv", "switching_account": "Account wird gewechselt..." }, + "pwaLaunch": { + "shortcut_new_message": "Neue Nachricht", + "shortcut_calendar": "Kalender", + "shortcut_contacts": "Kontakte", + "unsupported_file": "{name} kann hier nicht geöffnet werden.", + "open_failed": "Die Datei konnte nicht geöffnet werden. Öffnen Sie sie bitte noch einmal.", + "share_gone": "Das Geteilte ist nicht mehr vorhanden. Bitte teilen Sie es noch einmal.", + "share_files_lost": "Die geteilten Dateien konnten nicht aufbewahrt werden. Fügen Sie sie der Nachricht bitte noch einmal hinzu.", + "ics_needs_server": "Zum Importieren einer Kalenderdatei wird eine Verbindung zum Server gebraucht. Der Import beginnt, sobald Sie wieder online sind.", + "contacts_need_server": "Zum Importieren von Kontakten wird eine Verbindung zum Server gebraucht. Versuchen Sie es noch einmal, wenn Sie wieder online sind." + }, "sidebar_apps": { "modal_title": "Sidebar-Apps", "add_new": "App hinzufügen", diff --git a/locales/en/common.json b/locales/en/common.json index 57a58dab4..b87b0ec93 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -249,6 +249,17 @@ "active_account": "Active", "switching_account": "Switching account..." }, + "pwaLaunch": { + "shortcut_new_message": "New message", + "shortcut_calendar": "Calendar", + "shortcut_contacts": "Contacts", + "unsupported_file": "{name} can't be opened here.", + "open_failed": "The file couldn't be opened. Try opening it again.", + "share_gone": "What you shared is no longer here. Please share it again.", + "share_files_lost": "The shared files couldn't be kept. Add them to the message again.", + "ics_needs_server": "Importing a calendar file needs a connection to the server. The import starts when you're back online.", + "contacts_need_server": "Importing contacts needs a connection to the server. Try again when you're back online." + }, "sidebar_apps": { "modal_title": "Sidebar apps", "add_new": "Add app", diff --git a/locales/es/common.json b/locales/es/common.json index cc786a268..6a5f1f06f 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -249,6 +249,17 @@ "active_account": "Activa", "switching_account": "Cambiando de cuenta..." }, + "pwaLaunch": { + "shortcut_new_message": "Mensaje nuevo", + "shortcut_calendar": "Calendario", + "shortcut_contacts": "Contactos", + "unsupported_file": "{name} no se puede abrir aquí.", + "open_failed": "No se pudo abrir el archivo. Intenta abrirlo de nuevo.", + "share_gone": "Lo que compartiste ya no está aquí. Compártelo de nuevo.", + "share_files_lost": "No se pudieron conservar los archivos compartidos. Añádelos de nuevo al mensaje.", + "ics_needs_server": "Importar un archivo de calendario requiere conexión con el servidor. La importación empezará cuando vuelvas a estar en línea.", + "contacts_need_server": "Importar contactos requiere conexión con el servidor. Inténtalo de nuevo cuando vuelvas a estar en línea." + }, "sidebar_apps": { "modal_title": "Aplicaciones de la barra lateral", "add_new": "Añadir aplicación", diff --git a/locales/et/common.json b/locales/et/common.json index 2743f7f83..4353289be 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -249,6 +249,17 @@ "active_account": "Aktiivne", "switching_account": "Konto vahetamine..." }, + "pwaLaunch": { + "shortcut_new_message": "Uus kiri", + "shortcut_calendar": "Kalender", + "shortcut_contacts": "Kontaktid", + "unsupported_file": "Faili {name} ei saa siin avada.", + "open_failed": "Faili ei saanud avada. Proovi see uuesti avada.", + "share_gone": "See, mida jagasid, pole enam siin. Jaga seda palun uuesti.", + "share_files_lost": "Jagatud faile ei saanud alles hoida. Lisa need kirjale uuesti.", + "ics_needs_server": "Kalendrifaili importimiseks on vaja ühendust serveriga. Import algab, kui oled jälle võrgus.", + "contacts_need_server": "Kontaktide importimiseks on vaja ühendust serveriga. Proovi uuesti, kui oled jälle võrgus." + }, "sidebar_apps": { "modal_title": "Külgriba rakendused", "add_new": "Lisa rakendus", diff --git a/locales/fa/common.json b/locales/fa/common.json index 539f81c1a..9b939d48b 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -249,6 +249,17 @@ "active_account": "فعال", "switching_account": "در حال تعویض حساب..." }, + "pwaLaunch": { + "shortcut_new_message": "پیام جدید", + "shortcut_calendar": "تقویم", + "shortcut_contacts": "مخاطبان", + "unsupported_file": "{name} اینجا باز نمی‌شود.", + "open_failed": "فایل باز نشد. دوباره آن را باز کنید.", + "share_gone": "چیزی که هم‌رسانی کردید دیگر اینجا نیست. لطفاً دوباره آن را هم‌رسانی کنید.", + "share_files_lost": "فایل‌های هم‌رسانی‌شده نگه داشته نشدند. دوباره آن‌ها را به پیام اضافه کنید.", + "ics_needs_server": "وارد کردن فایل تقویم به اتصال به سرور نیاز دارد. وقتی دوباره آنلاین شوید، وارد کردن شروع می‌شود.", + "contacts_need_server": "وارد کردن مخاطبان به اتصال به سرور نیاز دارد. وقتی دوباره آنلاین شدید، دوباره امتحان کنید." + }, "sidebar_apps": { "modal_title": "برنامه‌های نوار کناری", "add_new": "افزودن برنامه", diff --git a/locales/fr/common.json b/locales/fr/common.json index fb7d148ec..22b8087bf 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -249,6 +249,17 @@ "active_account": "Actif", "switching_account": "Changement de compte..." }, + "pwaLaunch": { + "shortcut_new_message": "Nouveau message", + "shortcut_calendar": "Calendrier", + "shortcut_contacts": "Contacts", + "unsupported_file": "{name} ne peut pas être ouvert ici.", + "open_failed": "Le fichier n'a pas pu être ouvert. Essayez de l'ouvrir à nouveau.", + "share_gone": "Ce que vous avez partagé n'est plus disponible. Partagez-le à nouveau.", + "share_files_lost": "Les fichiers partagés n'ont pas pu être conservés. Ajoutez-les de nouveau au message.", + "ics_needs_server": "L'importation d'un fichier de calendrier nécessite une connexion au serveur. Elle démarrera dès que vous serez de nouveau en ligne.", + "contacts_need_server": "L'importation de contacts nécessite une connexion au serveur. Réessayez quand vous serez de nouveau en ligne." + }, "sidebar_apps": { "modal_title": "Applications de la barre latérale", "add_new": "Ajouter une application", diff --git a/locales/he/common.json b/locales/he/common.json index 73d75426a..95af29824 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -5272,6 +5272,17 @@ "active_account": "פעיל", "switching_account": "החלפת חשבון…" }, + "pwaLaunch": { + "shortcut_new_message": "הודעה חדשה", + "shortcut_calendar": "לוח שנה", + "shortcut_contacts": "אנשי קשר", + "unsupported_file": "לא ניתן לפתוח כאן את {name}.", + "open_failed": "לא ניתן היה לפתוח את הקובץ. נסו לפתוח אותו שוב.", + "share_gone": "מה ששיתפתם כבר לא נמצא כאן. שתפו אותו שוב.", + "share_files_lost": "לא ניתן היה לשמור את הקבצים ששותפו. הוסיפו אותם שוב להודעה.", + "ics_needs_server": "ייבוא קובץ לוח שנה דורש חיבור לשרת. הייבוא יתחיל כשתחזרו להיות מחוברים.", + "contacts_need_server": "ייבוא אנשי קשר דורש חיבור לשרת. נסו שוב כשתחזרו להיות מחוברים." + }, "mailbox_context_menu": { "mark_folder_read": "סמן תיקייה כקרויה", "mark_folder_tree_read": "סמן תיקייה ותת־תיקיות כקרויות", diff --git a/locales/hu/common.json b/locales/hu/common.json index b45fe495e..0564585b8 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -249,6 +249,17 @@ "active_account": "Aktív", "switching_account": "Fiók váltása..." }, + "pwaLaunch": { + "shortcut_new_message": "Új üzenet", + "shortcut_calendar": "Naptár", + "shortcut_contacts": "Névjegyek", + "unsupported_file": "A(z) {name} itt nem nyitható meg.", + "open_failed": "A fájlt nem sikerült megnyitni. Próbálja meg újra megnyitni.", + "share_gone": "Amit megosztott, már nincs itt. Kérjük, ossza meg újra.", + "share_files_lost": "A megosztott fájlokat nem sikerült megőrizni. Adja hozzá őket újra az üzenethez.", + "ics_needs_server": "A naptárfájl importálásához kapcsolat kell a szerverrel. Az importálás akkor indul el, amikor újra online lesz.", + "contacts_need_server": "A névjegyek importálásához kapcsolat kell a szerverrel. Próbálja újra, amikor ismét online lesz." + }, "sidebar_apps": { "modal_title": "Oldalsáv alkalmazások", "add_new": "Alkalmazás hozzáadása", diff --git a/locales/it/common.json b/locales/it/common.json index a021b7397..a82800289 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -249,6 +249,17 @@ "active_account": "Attivo", "switching_account": "Cambio account..." }, + "pwaLaunch": { + "shortcut_new_message": "Nuovo messaggio", + "shortcut_calendar": "Calendario", + "shortcut_contacts": "Contatti", + "unsupported_file": "{name} non può essere aperto qui.", + "open_failed": "Non è stato possibile aprire il file. Prova ad aprirlo di nuovo.", + "share_gone": "Ciò che hai condiviso non è più disponibile. Condividilo di nuovo.", + "share_files_lost": "Non è stato possibile conservare i file condivisi. Aggiungili di nuovo al messaggio.", + "ics_needs_server": "Per importare un file di calendario serve una connessione al server. L'importazione inizierà quando tornerai online.", + "contacts_need_server": "Per importare i contatti serve una connessione al server. Riprova quando tornerai online." + }, "sidebar_apps": { "modal_title": "App della barra laterale", "add_new": "Aggiungi app", diff --git a/locales/ja/common.json b/locales/ja/common.json index f2ea41909..0e567d3ca 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -249,6 +249,17 @@ "active_account": "アクティブ", "switching_account": "アカウントを切り替えています..." }, + "pwaLaunch": { + "shortcut_new_message": "新規メッセージ", + "shortcut_calendar": "カレンダー", + "shortcut_contacts": "連絡先", + "unsupported_file": "{name} はここでは開けません。", + "open_failed": "ファイルを開けませんでした。もう一度開いてください。", + "share_gone": "共有した内容はもうありません。もう一度共有してください。", + "share_files_lost": "共有したファイルを保持できませんでした。メッセージにもう一度追加してください。", + "ics_needs_server": "カレンダーファイルのインポートにはサーバーへの接続が必要です。オンラインに戻るとインポートが始まります。", + "contacts_need_server": "連絡先のインポートにはサーバーへの接続が必要です。オンラインに戻ってからもう一度お試しください。" + }, "sidebar_apps": { "modal_title": "サイドバーアプリ", "add_new": "アプリを追加", diff --git a/locales/ko/common.json b/locales/ko/common.json index cbde91d32..51cfc0fed 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -249,6 +249,17 @@ "active_account": "활성", "switching_account": "계정 전환 중..." }, + "pwaLaunch": { + "shortcut_new_message": "새 메시지", + "shortcut_calendar": "캘린더", + "shortcut_contacts": "연락처", + "unsupported_file": "{name}은(는) 여기에서 열 수 없습니다.", + "open_failed": "파일을 열 수 없습니다. 다시 열어 보세요.", + "share_gone": "공유한 내용이 더 이상 없습니다. 다시 공유해 주세요.", + "share_files_lost": "공유한 파일을 보관할 수 없습니다. 메시지에 다시 추가해 주세요.", + "ics_needs_server": "캘린더 파일을 가져오려면 서버에 연결되어 있어야 합니다. 다시 온라인 상태가 되면 가져오기가 시작됩니다.", + "contacts_need_server": "연락처를 가져오려면 서버에 연결되어 있어야 합니다. 다시 온라인 상태가 되면 다시 시도해 주세요." + }, "sidebar_apps": { "modal_title": "사이드바 앱", "add_new": "앱 추가", diff --git a/locales/lv/common.json b/locales/lv/common.json index 02d30c2f9..60fbbb89b 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -249,6 +249,17 @@ "active_account": "Aktīvs", "switching_account": "Notiek konta pārslēgšana..." }, + "pwaLaunch": { + "shortcut_new_message": "Jauna ziņa", + "shortcut_calendar": "Kalendārs", + "shortcut_contacts": "Kontakti", + "unsupported_file": "Failu {name} šeit nevar atvērt.", + "open_failed": "Failu neizdevās atvērt. Mēģiniet to atvērt vēlreiz.", + "share_gone": "Tas, ko kopīgojāt, šeit vairs nav. Lūdzu, kopīgojiet to vēlreiz.", + "share_files_lost": "Kopīgotos failus neizdevās saglabāt. Pievienojiet tos ziņai vēlreiz.", + "ics_needs_server": "Kalendāra faila importēšanai vajadzīgs savienojums ar serveri. Importēšana sāksies, kad atkal būsiet tiešsaistē.", + "contacts_need_server": "Kontaktu importēšanai vajadzīgs savienojums ar serveri. Mēģiniet vēlreiz, kad atkal būsiet tiešsaistē." + }, "sidebar_apps": { "modal_title": "Sānu joslas lietotnes", "add_new": "Pievienot lietotni", diff --git a/locales/mn/common.json b/locales/mn/common.json index 4dd86dea2..649c9e7ac 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -249,6 +249,17 @@ "active_account": "Идэвхтэй", "switching_account": "Бүртгэл сэлгэж байна..." }, + "pwaLaunch": { + "shortcut_new_message": "Шинэ зурвас", + "shortcut_calendar": "Хуанли", + "shortcut_contacts": "Харилцагчид", + "unsupported_file": "{name}-г энд нээх боломжгүй.", + "open_failed": "Файлыг нээж чадсангүй. Дахин нээж үзнэ үү.", + "share_gone": "Таны хуваалцсан зүйл энд байхгүй болсон. Дахин хуваалцана уу.", + "share_files_lost": "Хуваалцсан файлуудыг хадгалж чадсангүй. Зурваст дахин нэмнэ үү.", + "ics_needs_server": "Хуанлийн файл импортлоход сервертэй холболт хэрэгтэй. Та дахин онлайн болоход импорт эхэлнэ.", + "contacts_need_server": "Харилцагчдыг импортлоход сервертэй холболт хэрэгтэй. Дахин онлайн болсон үедээ оролдоно уу." + }, "sidebar_apps": { "modal_title": "Sidebar програмууд", "add_new": "Апп нэмэх", diff --git a/locales/nb/common.json b/locales/nb/common.json index d98bd1756..0ddf770f4 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -249,6 +249,17 @@ "active_account": "Aktiv", "switching_account": "Bytter konto..." }, + "pwaLaunch": { + "shortcut_new_message": "Ny melding", + "shortcut_calendar": "Kalender", + "shortcut_contacts": "Kontakter", + "unsupported_file": "{name} kan ikke åpnes her.", + "open_failed": "Filen kunne ikke åpnes. Prøv å åpne den på nytt.", + "share_gone": "Det du delte, er ikke her lenger. Del det på nytt.", + "share_files_lost": "De delte filene kunne ikke tas vare på. Legg dem til i meldingen på nytt.", + "ics_needs_server": "Import av en kalenderfil krever tilkobling til serveren. Importen starter når du er på nett igjen.", + "contacts_need_server": "Import av kontakter krever tilkobling til serveren. Prøv igjen når du er på nett igjen." + }, "sidebar_apps": { "modal_title": "Apper i sidefeltet", "add_new": "Legg til app", diff --git a/locales/nl/common.json b/locales/nl/common.json index 4d541f358..ab2c81582 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -249,6 +249,17 @@ "active_account": "Actief", "switching_account": "Account wisselen..." }, + "pwaLaunch": { + "shortcut_new_message": "Nieuw bericht", + "shortcut_calendar": "Agenda", + "shortcut_contacts": "Contacten", + "unsupported_file": "{name} kan hier niet worden geopend.", + "open_failed": "Het bestand kon niet worden geopend. Probeer het opnieuw te openen.", + "share_gone": "Wat je hebt gedeeld, is er niet meer. Deel het opnieuw.", + "share_files_lost": "De gedeelde bestanden konden niet worden bewaard. Voeg ze opnieuw aan het bericht toe.", + "ics_needs_server": "Voor het importeren van een agendabestand is een verbinding met de server nodig. Het importeren begint zodra je weer online bent.", + "contacts_need_server": "Voor het importeren van contacten is een verbinding met de server nodig. Probeer het opnieuw als je weer online bent." + }, "sidebar_apps": { "modal_title": "Zijbalk-apps", "add_new": "App toevoegen", diff --git a/locales/pl/common.json b/locales/pl/common.json index a6a7cd520..1fd914ac1 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -249,6 +249,17 @@ "active_account": "Aktywne", "switching_account": "Przełączanie konta..." }, + "pwaLaunch": { + "shortcut_new_message": "Nowa wiadomość", + "shortcut_calendar": "Kalendarz", + "shortcut_contacts": "Kontakty", + "unsupported_file": "Nie można tu otworzyć pliku {name}.", + "open_failed": "Nie udało się otworzyć pliku. Spróbuj otworzyć go ponownie.", + "share_gone": "To, co udostępniono, już tu nie istnieje. Udostępnij to ponownie.", + "share_files_lost": "Nie udało się zachować udostępnionych plików. Dodaj je ponownie do wiadomości.", + "ics_needs_server": "Import pliku kalendarza wymaga połączenia z serwerem. Import zacznie się, gdy znów będziesz online.", + "contacts_need_server": "Import kontaktów wymaga połączenia z serwerem. Spróbuj ponownie, gdy znów będziesz online." + }, "sidebar_apps": { "modal_title": "Aplikacje paska bocznego", "add_new": "Dodaj aplikację", diff --git a/locales/pt/common.json b/locales/pt/common.json index 685a98aea..aca70eda7 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -249,6 +249,17 @@ "active_account": "Ativa", "switching_account": "Alternando conta..." }, + "pwaLaunch": { + "shortcut_new_message": "Nova mensagem", + "shortcut_calendar": "Calendário", + "shortcut_contacts": "Contactos", + "unsupported_file": "Não é possível abrir {name} aqui.", + "open_failed": "Não foi possível abrir o ficheiro. Tente abri-lo novamente.", + "share_gone": "O que partilhou já não está aqui. Partilhe novamente.", + "share_files_lost": "Não foi possível guardar os ficheiros partilhados. Adicione-os novamente à mensagem.", + "ics_needs_server": "Importar um ficheiro de calendário precisa de uma ligação ao servidor. A importação começa quando voltar a estar online.", + "contacts_need_server": "Importar contactos precisa de uma ligação ao servidor. Tente novamente quando voltar a estar online." + }, "sidebar_apps": { "modal_title": "Apps da barra lateral", "add_new": "Adicionar app", diff --git a/locales/ro/common.json b/locales/ro/common.json index 45c3fefed..31a66a763 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -249,6 +249,17 @@ "active_account": "Activ", "switching_account": "Se schimbă contul..." }, + "pwaLaunch": { + "shortcut_new_message": "Mesaj nou", + "shortcut_calendar": "Calendar", + "shortcut_contacts": "Contacte", + "unsupported_file": "{name} nu poate fi deschis aici.", + "open_failed": "Fișierul nu a putut fi deschis. Încercați să îl deschideți din nou.", + "share_gone": "Ce ați partajat nu mai este aici. Partajați din nou.", + "share_files_lost": "Fișierele partajate nu au putut fi păstrate. Adăugați-le din nou la mesaj.", + "ics_needs_server": "Importul unui fișier de calendar are nevoie de conexiune la server. Importul începe când reveniți online.", + "contacts_need_server": "Importul contactelor are nevoie de conexiune la server. Încercați din nou când reveniți online." + }, "sidebar_apps": { "modal_title": "Aplicații din bara laterală", "add_new": "Adăugați aplicația", diff --git a/locales/ru/common.json b/locales/ru/common.json index b40069b61..8790a5cf5 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -249,6 +249,17 @@ "active_account": "Активен", "switching_account": "Переключение аккаунта..." }, + "pwaLaunch": { + "shortcut_new_message": "Новое письмо", + "shortcut_calendar": "Календарь", + "shortcut_contacts": "Контакты", + "unsupported_file": "{name} нельзя открыть здесь.", + "open_failed": "Не удалось открыть файл. Попробуйте открыть его ещё раз.", + "share_gone": "То, чем вы поделились, больше недоступно. Поделитесь ещё раз.", + "share_files_lost": "Не удалось сохранить полученные файлы. Добавьте их в письмо ещё раз.", + "ics_needs_server": "Для импорта файла календаря нужно подключение к серверу. Импорт начнётся, когда вы снова будете в сети.", + "contacts_need_server": "Для импорта контактов нужно подключение к серверу. Попробуйте ещё раз, когда снова будете в сети." + }, "sidebar_apps": { "modal_title": "Приложения боковой панели", "add_new": "Добавить приложение", diff --git a/locales/sk/common.json b/locales/sk/common.json index aa0304452..c186d6754 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -249,6 +249,17 @@ "active_account": "Aktívny", "switching_account": "Prepínanie účtu..." }, + "pwaLaunch": { + "shortcut_new_message": "Nová správa", + "shortcut_calendar": "Kalendár", + "shortcut_contacts": "Kontakty", + "unsupported_file": "{name} sa tu nedá otvoriť.", + "open_failed": "Súbor sa nepodarilo otvoriť. Skúste ho otvoriť znova.", + "share_gone": "To, čo ste zdieľali, tu už nie je. Zdieľajte to prosím znova.", + "share_files_lost": "Zdieľané súbory sa nepodarilo uchovať. Pridajte ich do správy znova.", + "ics_needs_server": "Import súboru kalendára vyžaduje pripojenie k serveru. Import sa začne, keď budete znova online.", + "contacts_need_server": "Import kontaktov vyžaduje pripojenie k serveru. Skúste to znova, keď budete online." + }, "sidebar_apps": { "modal_title": "Aplikácie postranného panela", "add_new": "Pridať aplikáciu", diff --git a/locales/th/common.json b/locales/th/common.json index 4d2c52754..fadbbffd1 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -2382,6 +2382,17 @@ "active_account": "ใช้งานอยู่", "switching_account": "กำลังสลับบัญชี..." }, + "pwaLaunch": { + "shortcut_new_message": "ข้อความใหม่", + "shortcut_calendar": "ปฏิทิน", + "shortcut_contacts": "รายชื่อติดต่อ", + "unsupported_file": "ไม่สามารถเปิด {name} ที่นี่ได้", + "open_failed": "เปิดไฟล์ไม่ได้ ลองเปิดอีกครั้ง", + "share_gone": "สิ่งที่คุณแชร์ไม่อยู่ที่นี่แล้ว โปรดแชร์อีกครั้ง", + "share_files_lost": "เก็บไฟล์ที่แชร์ไว้ไม่ได้ โปรดเพิ่มไฟล์ลงในข้อความอีกครั้ง", + "ics_needs_server": "การนำเข้าไฟล์ปฏิทินต้องเชื่อมต่อกับเซิร์ฟเวอร์ การนำเข้าจะเริ่มเมื่อคุณกลับมาออนไลน์", + "contacts_need_server": "การนำเข้ารายชื่อติดต่อต้องเชื่อมต่อกับเซิร์ฟเวอร์ โปรดลองอีกครั้งเมื่อคุณกลับมาออนไลน์" + }, "sidebar_apps": { "modal_title": "แอปพลิเคชันแถบด้านข้าง", "add_new": "เพิ่มแอป", diff --git a/locales/tr/common.json b/locales/tr/common.json index 4f25997fa..ff89c598e 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -249,6 +249,17 @@ "active_account": "Etkin", "switching_account": "Hesap değiştiriliyor..." }, + "pwaLaunch": { + "shortcut_new_message": "Yeni ileti", + "shortcut_calendar": "Takvim", + "shortcut_contacts": "Kişiler", + "unsupported_file": "{name} burada açılamıyor.", + "open_failed": "Dosya açılamadı. Yeniden açmayı deneyin.", + "share_gone": "Paylaştığınız içerik artık burada değil. Lütfen yeniden paylaşın.", + "share_files_lost": "Paylaşılan dosyalar saklanamadı. Onları iletiye yeniden ekleyin.", + "ics_needs_server": "Takvim dosyasını içe aktarmak için sunucu bağlantısı gerekir. İçe aktarma, yeniden çevrimiçi olduğunuzda başlar.", + "contacts_need_server": "Kişileri içe aktarmak için sunucu bağlantısı gerekir. Yeniden çevrimiçi olduğunuzda tekrar deneyin." + }, "sidebar_apps": { "modal_title": "Kenar çubuğu uygulamaları", "add_new": "Uygulama ekle", diff --git a/locales/uk/common.json b/locales/uk/common.json index 408e920da..8d6f02fc1 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -249,6 +249,17 @@ "active_account": "Активний", "switching_account": "Перемикання акаунта..." }, + "pwaLaunch": { + "shortcut_new_message": "Новий лист", + "shortcut_calendar": "Календар", + "shortcut_contacts": "Контакти", + "unsupported_file": "{name} не можна відкрити тут.", + "open_failed": "Не вдалося відкрити файл. Спробуйте відкрити його ще раз.", + "share_gone": "Те, чим ви поділилися, більше недоступне. Поділіться ще раз.", + "share_files_lost": "Не вдалося зберегти отримані файли. Додайте їх до листа ще раз.", + "ics_needs_server": "Для імпорту файлу календаря потрібне підключення до сервера. Імпорт почнеться, коли ви знову будете в мережі.", + "contacts_need_server": "Для імпорту контактів потрібне підключення до сервера. Спробуйте ще раз, коли знову будете в мережі." + }, "sidebar_apps": { "modal_title": "Програми бічної панелі", "add_new": "Додати додаток", diff --git a/locales/vi/common.json b/locales/vi/common.json index 6952aec2e..f8f43adae 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -249,6 +249,17 @@ "active_account": "Đang hoạt động", "switching_account": "Đang chuyển đổi tài khoản..." }, + "pwaLaunch": { + "shortcut_new_message": "Thư mới", + "shortcut_calendar": "Lịch", + "shortcut_contacts": "Danh bạ", + "unsupported_file": "Không thể mở {name} tại đây.", + "open_failed": "Không thể mở tệp. Hãy thử mở lại.", + "share_gone": "Nội dung bạn đã chia sẻ không còn ở đây. Vui lòng chia sẻ lại.", + "share_files_lost": "Không thể giữ các tệp đã chia sẻ. Hãy thêm lại chúng vào thư.", + "ics_needs_server": "Nhập tệp lịch cần kết nối với máy chủ. Quá trình nhập sẽ bắt đầu khi bạn trực tuyến trở lại.", + "contacts_need_server": "Nhập danh bạ cần kết nối với máy chủ. Hãy thử lại khi bạn trực tuyến trở lại." + }, "sidebar_apps": { "modal_title": "Ứng dụng thanh bên", "add_new": "Thêm ứng dụng", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 1236173cf..391355f2e 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -249,6 +249,17 @@ "active_account": "使用中", "switching_account": "正在切換帳號…" }, + "pwaLaunch": { + "shortcut_new_message": "新郵件", + "shortcut_calendar": "行事曆", + "shortcut_contacts": "聯絡人", + "unsupported_file": "無法在此處開啟 {name}。", + "open_failed": "無法開啟檔案。請再次嘗試開啟。", + "share_gone": "您分享的內容已不在此處。請重新分享。", + "share_files_lost": "無法保留分享的檔案。請將它們重新加入郵件。", + "ics_needs_server": "匯入行事曆檔案需要連線到伺服器。您恢復連線後將開始匯入。", + "contacts_need_server": "匯入聯絡人需要連線到伺服器。請在恢復連線後再試一次。" + }, "sidebar_apps": { "modal_title": "側邊欄應用程式", "add_new": "新增應用程式", diff --git a/locales/zh/common.json b/locales/zh/common.json index f51d10c28..2fabc2fe3 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -249,6 +249,17 @@ "active_account": "活动", "switching_account": "正在切换账户..." }, + "pwaLaunch": { + "shortcut_new_message": "新邮件", + "shortcut_calendar": "日历", + "shortcut_contacts": "联系人", + "unsupported_file": "无法在此处打开 {name}。", + "open_failed": "无法打开文件。请再次尝试打开。", + "share_gone": "您分享的内容已不在此处。请重新分享。", + "share_files_lost": "无法保留分享的文件。请将它们重新添加到邮件中。", + "ics_needs_server": "导入日历文件需要连接到服务器。您恢复联网后将开始导入。", + "contacts_need_server": "导入联系人需要连接到服务器。请在恢复联网后重试。" + }, "sidebar_apps": { "modal_title": "侧边栏应用", "add_new": "添加应用", diff --git a/public/sw.js b/public/sw.js index 3dc7ff7b8..6a313d86e 100644 --- a/public/sw.js +++ b/public/sw.js @@ -108,7 +108,7 @@ if (typeof caches !== "undefined" && caches && typeof caches.open === "function" readOfflineShell(); } -const APP_SECTIONS = new Set(["", "mail", "settings", "calendar", "contacts", "files", "pro", "apps"]); +const APP_SECTIONS = new Set(["", "mail", "settings", "calendar", "contacts", "files", "pro", "apps", "protocol"]); /** The page path inside the app ("/mail/inbox"), or null for anything that isn't a page of the app. */ function appPagePath(url) { @@ -311,7 +311,61 @@ async function deleteStaleShellCaches() { } } +// ---- Share target: what the OS shares goes into a new draft ---- +// +// The manifest's share_target POSTs here. Files can't travel through a +// redirect, so they wait in the Cache API until the page takes them (same +// layout as lib/launch-handoff.ts, which also removes them). Without a worker +// the POST reaches app/(main)/protocol/share/route.ts. +const HANDOFF_CACHE = "bulwark-launch-handoff-v1"; + +function isShareTarget(request) { + if (request.method !== "POST" || request.mode !== "navigate") return false; + const url = new URL(request.url); + return url.origin === self.location.origin && url.pathname.replace(/\/+$/, "") === BASE_PATH + "/protocol/share"; +} + +async function receiveShare(request) { + const land = (params) => Response.redirect(self.location.origin + BASE_PATH + "/?" + new URLSearchParams(params), 303); + let form; + try { + form = await request.formData(); + } catch (_) { + return land({ share: "unavailable", share_files: "1" }); + } + const field = (name) => { + const value = form.get(name); + return typeof value === "string" ? value : ""; + }; + const shared = { title: field("title"), text: field("text"), url: field("url") }; + const files = form.getAll("files").filter((file) => typeof file !== "string" && file.size > 0); + const id = crypto.randomUUID(); + const prefix = "/__bulwark/handoff/" + id + "/"; + let cache = null; + try { + cache = await caches.open(HANDOFF_CACHE); + const list = files.map((file) => ({ name: file.name, type: file.type })); + await cache.put(prefix + "meta", new Response(JSON.stringify({ kind: "compose", createdAt: Date.now(), ...shared, files: list }))); + await Promise.all(files.map((file, index) => cache.put(prefix + index, new Response(file)))); + return land({ share: id }); + } catch (_) { + // No room for the files. The text can still come along in the address. + if (cache) await Promise.all([...files.map((_file, index) => cache.delete(prefix + index)), cache.delete(prefix + "meta")]).catch(() => {}); + return land({ + share: "unavailable", + share_title: shared.title.slice(0, 300), + share_text: shared.text.slice(0, 4000), + share_url: shared.url.slice(0, 2000), + ...(files.length > 0 ? { share_files: "1" } : {}), + }); + } +} + self.addEventListener("fetch", (event) => { + if (isShareTarget(event.request)) { + event.respondWith(receiveShare(event.request)); + return; + } // Offline mode off (known): the network, untouched, as without a worker. if (offlineShellOn === false) return; const route = offlineRoute(event.request); diff --git a/stores/pro-tab-store.ts b/stores/pro-tab-store.ts index ecc393c05..36051d2a8 100644 --- a/stores/pro-tab-store.ts +++ b/stores/pro-tab-store.ts @@ -65,6 +65,8 @@ export interface ProComposeTabData { initialData?: ComposerDraftData | null; /** Fields a plugin filled in through onBeforeCompose; dropped on the first state save. */ prefill?: ComposePrefill | null; + /** Files from a share or a launch, attached when the composer opens. */ + initialFiles?: File[] | null; sourceEmailId?: string | null; /** The login and account of that message, for its $answered / $forwarded flag. */ sourceEmailSource?: EmailWriteSource | null; From c02103164206b67b85d1687203f3eff123345f8b Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 05:26:03 +0100 Subject: [PATCH 05/16] test: cover app launches and shares through the service worker on both servers --- integration/tests/23-do-pwa-launch.spec.ts | 444 +++++++++++++++++++++ 1 file changed, 444 insertions(+) create mode 100644 integration/tests/23-do-pwa-launch.spec.ts diff --git a/integration/tests/23-do-pwa-launch.spec.ts b/integration/tests/23-do-pwa-launch.spec.ts new file mode 100644 index 000000000..dd898c0e9 --- /dev/null +++ b/integration/tests/23-do-pwa-launch.spec.ts @@ -0,0 +1,444 @@ +import { test, expect, type Browser, type BrowserContext, type Page } from '@playwright/test'; +import { accountSwitcher, neutralizeDevOverlay } from './helpers/app'; +import { ENT, ENT_PASSWORD, adminContext, contextOptions, getPolicy, putPolicy, requireEntEnv, shot } from './helpers/ent'; + +/** + * The installed app's launches through the real UI: shortcuts, files opened + * from the OS, and shares. Chromium can't fire launchQueue from a test, so a + * stand-in catches the consumer the app registers, and the spec calls that + * consumer the way the browser would. Shares go through the real service + * worker as a real form POST. Needs a production build, the only one that + * registers the worker. + * + * BUILD=1 bash desktop-offline-rig/start-prod.sh 3230 pwa-launch-1.0 + * bash desktop-offline-rig/run-spec.sh 3230 1.0 integration/tests/23-do-pwa-launch.spec.ts + */ + +test.describe.configure({ mode: 'serial' }); +test.setTimeout(180_000); + +const RUN = Date.now().toString(36); +const composer = (page: Page) => page.locator('[data-testid="email-composer"]').first(); +const SHARE_CACHE = 'bulwark-launch-handoff-v1'; + +type LaunchFile = { name: string; type: string; body: string }; + +const EML: LaunchFile = { + name: 'note.eml', + type: 'message/rfc822', + body: [ + 'From: Bob ', + 'To: Alice ', + `Subject: Launched message ${RUN}`, + 'Date: Tue, 03 Nov 2026 10:00:00 +0000', + 'Content-Type: text/plain; charset=utf-8', + '', + 'Opened from the file system.', + '', + ].join('\r\n'), +}; + +const ICS: LaunchFile = { + name: 'invite.ics', + type: 'text/calendar', + body: [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//Bulwark//Test//EN', + 'BEGIN:VEVENT', + `UID:launch-${RUN}@example.org`, + 'DTSTAMP:20261103T100000Z', + 'DTSTART:20261110T100000Z', + 'DTEND:20261110T110000Z', + `SUMMARY:Launched event ${RUN}`, + 'END:VEVENT', + 'END:VCALENDAR', + '', + ].join('\r\n'), +}; + +const VCF: LaunchFile = { + name: 'people.vcf', + type: 'text/vcard', + body: ['BEGIN:VCARD', 'VERSION:3.0', `FN:Launched Person ${RUN}`, `EMAIL:launched-${RUN}@example.org`, 'END:VCARD', ''].join('\r\n'), +}; + +/** Stands in for the browser's launchQueue, keeping the consumer the app sets. */ +function catchLaunchConsumer() { + Object.defineProperty(window, 'launchQueue', { + configurable: true, + value: { + setConsumer(fn: unknown) { + (window as unknown as { __launch: unknown }).__launch = fn; + }, + }, + }); +} + +async function launch(page: Page, opts: { target?: string; files?: LaunchFile[] }): Promise { + await page.waitForFunction(() => typeof (window as unknown as { __launch?: unknown }).__launch === 'function'); + await page.evaluate(({ target, files }) => { + const consumer = (window as unknown as { __launch: (params: unknown) => void }).__launch; + consumer({ + targetURL: new URL(target, window.location.origin).href, + ...(files.length > 0 ? { + files: files.map((f) => ({ kind: 'file', getFile: async () => new File([f.body], f.name, { type: f.type }) })), + } : {}), + }); + }, { target: opts.target ?? '/', files: opts.files ?? [] }); +} + +async function signIn(page: Page, email: string): Promise { + await page.locator('#username').waitFor({ state: 'visible', timeout: 60_000 }); + await page.fill('#username', email); + await page.fill('#password', ENT_PASSWORD); + await page.click('button[type="submit"]'); + await accountSwitcher(page).waitFor({ state: 'visible', timeout: 60_000 }); +} + +/** A new window lands signed out or signed in, depending on what can be restored. */ +async function signInIfAsked(page: Page, email: string): Promise { + const login = page.locator('#username'); + await expect(login.or(accountSwitcher(page))).toBeVisible({ timeout: 60_000 }); + if (await login.isVisible()) await signIn(page, email); +} + +async function launchContext(browser: Browser, opts: Parameters[0] = {}): Promise { + const context = await browser.newContext({ ...contextOptions(), ...opts }); + await context.addInitScript(catchLaunchConsumer); + return context; +} + +async function signedIn(browser: Browser, opts: Parameters[0] = {}): Promise { + const page = await (await launchContext(browser, opts)).newPage(); + await neutralizeDevOverlay(page); + await page.goto('/'); + await signIn(page, ENT.alice); + return page; +} + +/** A real form POST to the share target, as the OS's share sheet sends it. */ +async function postShare(page: Page, fields: Record, files: LaunchFile[]): Promise { + await page.evaluate(({ fields: values, files: list }) => { + const form = document.createElement('form'); + form.method = 'POST'; + form.enctype = 'multipart/form-data'; + form.action = '/protocol/share'; + for (const [name, value] of Object.entries(values)) { + const input = document.createElement('input'); + input.type = 'hidden'; + input.name = name; + input.value = value; + form.append(input); + } + const input = document.createElement('input'); + input.type = 'file'; + input.name = 'files'; + input.multiple = true; + const data = new DataTransfer(); + for (const f of list) data.items.add(new File([f.body], f.name, { type: f.type })); + input.files = data.files; + form.append(input); + document.body.append(form); + form.submit(); + }, { fields, files }); +} + +async function workerReady(page: Page): Promise { + const active = await page.evaluate(() => navigator.serviceWorker.ready.then((r) => !!r.active)); + expect(active).toBe(true); +} + +const storedShareEntries = (page: Page) => page.evaluate(async (name) => { + if (!(await caches.has(name))) return 0; + return (await (await caches.open(name)).keys()).length; +}, SHARE_CACHE); + +test.beforeAll(() => requireEntEnv()); + +test.describe('manifest', () => { + test('offers shortcuts, file handlers and the share target, in the browser language', async ({ browser }) => { + const context = await browser.newContext(contextOptions()); + const res = await context.request.get('/manifest.webmanifest', { headers: { 'Accept-Language': 'de-DE,de;q=0.9' } }); + expect(res.ok()).toBe(true); + const manifest = await res.json(); + expect(manifest.shortcuts.map((s: { name: string; url: string }) => [s.name, s.url])).toEqual([ + ['Neue Nachricht', '/?compose=1'], + ['Kalender', '/calendar'], + ['Kontakte', '/contacts'], + ]); + expect(manifest.file_handlers.map((h: { action: string }) => h.action)).toEqual(['/mail', '/calendar', '/contacts']); + expect(manifest.share_target).toMatchObject({ action: '/protocol/share', method: 'POST', enctype: 'multipart/form-data' }); + expect(manifest.protocol_handlers).toHaveLength(2); + await context.close(); + }); + + test('every address in it answers', async ({ browser }) => { + const context = await browser.newContext(contextOptions()); + const manifest = await (await context.request.get('/manifest.webmanifest')).json(); + for (const shortcut of manifest.shortcuts) { + expect((await context.request.get(shortcut.url)).ok(), shortcut.url).toBe(true); + expect((await context.request.get(shortcut.icons[0].src)).ok(), shortcut.icons[0].src).toBe(true); + } + for (const handler of manifest.file_handlers) { + expect((await context.request.get(handler.action)).ok(), handler.action).toBe(true); + } + await context.close(); + }); +}); + +test.describe('share target', () => { + test('files and a link go through the service worker into a new draft', async ({ browser }) => { + const page = await signedIn(browser); + await workerReady(page); + + const subject = `Shared ${RUN}`; + await postShare(page, { title: subject, text: 'Have a look', url: 'https://example.org/page' }, [ + { name: `shared-${RUN}.txt`, type: 'text/plain', body: 'shared bytes' }, + ]); + await page.waitForURL((url) => url.pathname.endsWith('/') || !url.pathname.includes('protocol')); + await signInIfAsked(page, ENT.alice); + + await expect(composer(page)).toBeVisible({ timeout: 30_000 }); + await expect(composer(page).getByPlaceholder(/subject/i)).toHaveValue(subject); + await expect(composer(page).locator('[contenteditable="true"]').first()).toContainText('https://example.org/page'); + await expect(composer(page).getByText(`shared-${RUN}.txt`)).toBeVisible(); + // It reached the app through the worker's store, not the server's fallback. + await expect(page.getByText("The shared files couldn't be kept")).toHaveCount(0); + await expect.poll(() => new URL(page.url()).search).toBe(''); + await expect.poll(() => storedShareEntries(page)).toBe(0); + await shot(page, 'pwa-launch-share'); + await page.context().close(); + }); + + test('survives a sign-in that reloads the page', async ({ browser }) => { + const page = await (await launchContext(browser)).newPage(); + await page.goto('/'); + await page.locator('#username').waitFor({ state: 'visible', timeout: 60_000 }); + await workerReady(page); + + await postShare(page, { url: 'https://example.org/waited' }, [{ name: `waited-${RUN}.txt`, type: 'text/plain', body: 'x' }]); + await page.locator('#username').waitFor({ state: 'visible', timeout: 60_000 }); + await expect.poll(() => storedShareEntries(page)).toBe(2); + // What SSO does: away and back, in the same tab. + await page.reload(); + await signIn(page, ENT.alice); + + await expect(composer(page)).toBeVisible({ timeout: 30_000 }); + await expect(composer(page).getByText(`waited-${RUN}.txt`)).toBeVisible(); + await expect.poll(() => storedShareEntries(page)).toBe(0); + await page.context().close(); + }); + + test('without a service worker the link still arrives and the app says the files did not', async ({ browser }) => { + const page = await signedIn(browser, { serviceWorkers: 'block' }); + await postShare(page, { url: 'https://example.org/no-worker' }, [{ name: 'lost.txt', type: 'text/plain', body: 'x' }]); + await page.waitForLoadState('domcontentloaded'); + await signInIfAsked(page, ENT.alice); + + await expect(composer(page)).toBeVisible({ timeout: 30_000 }); + await expect(composer(page).locator('[contenteditable="true"]').first()).toContainText('https://example.org/no-worker'); + await expect(composer(page).getByText('lost.txt')).toHaveCount(0); + await expect(page.getByText("The shared files couldn't be kept").first()).toBeVisible(); + await page.context().close(); + }); + + test('the server takes the POST from outside the origin, which /api/ would refuse', async ({ browser }) => { + const context = await browser.newContext(contextOptions()); + const res = await context.request.post('/protocol/share', { + headers: { Origin: 'https://other.example', 'Sec-Fetch-Site': 'cross-site' }, + multipart: { url: 'https://example.org/direct' }, + maxRedirects: 0, + }); + expect(res.status()).toBe(303); + const location = new URL(res.headers().location, 'http://x.invalid'); + expect(location.searchParams.get('share')).toBe('unavailable'); + expect(location.searchParams.get('share_url')).toBe('https://example.org/direct'); + await context.close(); + }); +}); + +test.describe('file launches', () => { + test('.eml opens in the viewer, over the page', async ({ browser }) => { + const page = await signedIn(browser); + const before = new URL(page.url()).pathname; + await launch(page, { target: '/mail', files: [EML] }); + await expect(page.getByRole('heading', { name: `Launched message ${RUN}` })).toBeVisible({ timeout: 20_000 }); + expect(new URL(page.url()).pathname).toBe(before); + await expect(page.getByRole('button', { name: 'Download', exact: true }).first()).toBeVisible(); + await shot(page, 'pwa-launch-eml'); + await page.context().close(); + }); + + test('.eml opens before signing in', async ({ browser }) => { + const page = await (await launchContext(browser)).newPage(); + await page.goto('/'); + await page.locator('#username').waitFor({ state: 'visible', timeout: 60_000 }); + await launch(page, { target: '/mail', files: [EML] }); + await expect(page.getByRole('heading', { name: `Launched message ${RUN}` })).toBeVisible({ timeout: 20_000 }); + await page.context().close(); + }); + + test('.eml with downloads blocked: the viewer opens, with nothing to save', async ({ browser }) => { + const admin = await adminContext(browser); + const saved = await getPolicy(admin); + const organisation = (saved.organisation ?? {}) as Record; + await putPolicy(admin, { + ...saved, + organisation: { + ...organisation, + devices: { ...((organisation.devices ?? {}) as object), noDownloads: true }, + }, + }); + try { + const page = await signedIn(browser); + await launch(page, { target: '/mail', files: [EML] }); + await expect(page.getByRole('heading', { name: `Launched message ${RUN}` })).toBeVisible({ timeout: 20_000 }); + await expect(page.getByRole('button', { name: 'Download', exact: true })).toHaveCount(0); + await page.context().close(); + } finally { + await putPolicy(admin, saved); + await admin.close(); + } + }); + + test('.eml without a connection or the viewer chunk: a message, not the error page', async ({ browser }) => { + const page = await signedIn(browser); + await page.context().setOffline(true); + await launch(page, { target: '/mail', files: [EML] }); + await expect(page.getByText("The file couldn't be opened").first()).toBeVisible({ timeout: 20_000 }); + await expect(accountSwitcher(page)).toBeVisible(); + + await page.context().setOffline(false); + await launch(page, { target: '/mail', files: [EML] }); + await expect(page.getByRole('heading', { name: `Launched message ${RUN}` })).toBeVisible({ timeout: 20_000 }); + await page.context().close(); + }); + + test('.ics goes to the calendar import, which the server parses', async ({ browser }) => { + const page = await signedIn(browser); + await launch(page, { target: '/calendar', files: [ICS] }); + await page.waitForURL(/\/calendar/); + await expect(page.getByText(`Launched event ${RUN}`).first()).toBeVisible({ timeout: 30_000 }); + await shot(page, 'pwa-launch-ics'); + await page.context().close(); + }); + + test('.ics while offline says the server is needed, and carries on once back', async ({ browser }) => { + const page = await signedIn(browser); + // Into the calendar first: its code isn't on the device without offline mode. + await launch(page, { target: '/calendar' }); + await page.waitForURL(/\/calendar/); + await page.context().setOffline(true); + await launch(page, { target: '/calendar', files: [ICS] }); + await expect(page.getByText('Importing a calendar file needs a connection to the server.')).toBeVisible({ timeout: 20_000 }); + await shot(page, 'pwa-launch-ics-offline'); + + await page.context().setOffline(false); + await expect(page.getByText(`Launched event ${RUN}`).first()).toBeVisible({ timeout: 30_000 }); + await page.context().close(); + }); + + test('.vcf goes to the contacts import, parsed in the browser', async ({ browser }) => { + const page = await signedIn(browser); + await launch(page, { target: '/contacts', files: [VCF] }); + await page.waitForURL(/\/contacts/); + await expect(page.getByText(`Launched Person ${RUN}`).first()).toBeVisible({ timeout: 30_000 }); + await shot(page, 'pwa-launch-vcf'); + await page.context().close(); + }); + + test('a file launched before signing in waits for it', async ({ browser }) => { + const page = await (await launchContext(browser)).newPage(); + await page.goto('/'); + await page.locator('#username').waitFor({ state: 'visible', timeout: 60_000 }); + await launch(page, { target: '/contacts', files: [VCF] }); + await expect(page.locator('#username')).toBeVisible(); + await signIn(page, ENT.alice); + await page.waitForURL(/\/contacts/); + await expect(page.getByText(`Launched Person ${RUN}`).first()).toBeVisible({ timeout: 30_000 }); + await page.context().close(); + }); + + test('a file launched into a signed-in page with a reload on the way still arrives', async ({ browser }) => { + const page = await (await launchContext(browser)).newPage(); + await page.goto('/'); + await page.locator('#username').waitFor({ state: 'visible', timeout: 60_000 }); + await launch(page, { target: '/calendar', files: [ICS] }); + await expect.poll(() => storedShareEntries(page)).toBe(2); + await page.reload(); + await signIn(page, ENT.alice); + await page.waitForURL(/\/calendar/); + await expect(page.getByText(`Launched event ${RUN}`).first()).toBeVisible({ timeout: 30_000 }); + await expect.poll(() => storedShareEntries(page)).toBe(0); + await page.context().close(); + }); +}); + +test.describe('shortcuts', () => { + test('New message opens a composer from the address, once, and clears the address', async ({ browser }) => { + const page = await (await launchContext(browser)).newPage(); + await page.goto('/?compose=1'); + await signIn(page, ENT.alice); + await expect(composer(page)).toBeVisible({ timeout: 30_000 }); + expect(new URL(page.url()).search).toBe(''); + await page.context().close(); + }); + + test('New message in an open window', async ({ browser }) => { + const page = await signedIn(browser); + await expect(composer(page)).toHaveCount(0); + await launch(page, { target: '/?compose=1' }); + await expect(composer(page)).toBeVisible({ timeout: 30_000 }); + await page.context().close(); + }); + + test('Calendar and Contacts in an open window', async ({ browser }) => { + const page = await signedIn(browser); + await launch(page, { target: '/calendar' }); + await page.waitForURL(/\/calendar/); + await launch(page, { target: '/contacts' }); + await page.waitForURL(/\/contacts/); + await page.context().close(); + }); + + test('the plain app icon leaves an open window where it is', async ({ browser }) => { + const page = await signedIn(browser); + await launch(page, { target: '/calendar' }); + await page.waitForURL(/\/calendar/); + await launch(page, { target: '/' }); + await page.waitForTimeout(1000); + expect(new URL(page.url()).pathname).toMatch(/\/calendar(\/|$)/); + await expect(composer(page)).toHaveCount(0); + await page.context().close(); + }); +}); + +test.describe('in the Pro shell', () => { + const proOn = () => { + const key = 'settings-storage'; + const parsed = JSON.parse(localStorage.getItem(key) ?? '{"state":{},"version":0}'); + parsed.state = { ...parsed.state, proInterface: true }; + localStorage.setItem(key, JSON.stringify(parsed)); + }; + + test('a shared file reaches the composer tab, and a .ics the calendar tab', async ({ browser }) => { + const context = await launchContext(browser); + await context.addInitScript(proOn); + const page = await context.newPage(); + await page.goto('/'); + await signIn(page, ENT.alice); + await page.waitForURL(/\/pro/); + await workerReady(page); + + await postShare(page, { url: 'https://example.org/pro' }, [{ name: `pro-${RUN}.txt`, type: 'text/plain', body: 'x' }]); + await signInIfAsked(page, ENT.alice); + await page.waitForURL(/\/pro/); + await expect(page.getByText(`pro-${RUN}.txt`).first()).toBeVisible({ timeout: 30_000 }); + + await launch(page, { target: '/calendar', files: [ICS] }); + await expect(page.getByText(`Launched event ${RUN}`).first()).toBeVisible({ timeout: 30_000 }); + await shot(page, 'pwa-launch-pro'); + await context.close(); + }); +}); From 2bb07979962e008d05bc0cd004b8393585e9b772 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 05:26:43 +0100 Subject: [PATCH 06/16] feat: archive, mark read or reply right from a new mail notification A notification for one message gets buttons where the browser has room for them, and a click on its body works as before. The service worker has no login, so an open tab takes the action first, through the store actions. With no tab open a new same-origin route, /api/push/action, does Mark read and Archive with the context cookie. When the cookie is gone, the token has expired or the login can't be told apart, the message opens in the app instead. Reply opens the composer through a `?reply=1` link, with any text typed on the notification. - `public/sw.js`: buttons, asking open tabs, the route, `sync` handling - `app/api/push/action`: same-origin, checks the login and the account, honours the admin's Archive gate, files by the mirrored archive mode - `lib/pwa-notify`: the page's side, the mirror for the worker, and `requestOutboxSync`, which the offline queue calls - Background Sync, thin version: a `sync` event tells open tabs to flush Two push-preview-route assertions change: they match the whole answer for a named message, which now also carries `login`, what the action route checks so a stale notification can't act on whoever holds the cookie slot now. --- app/(main)/[locale]/layout.tsx | 2 + app/api/push/action/route.ts | 273 ++++++++++ app/api/push/preview/route.ts | 4 + components/mail/mail-app.tsx | 37 +- .../notification-actions-host.test.tsx | 124 +++++ .../providers/notification-actions-host.tsx | 56 ++ integration/tests/23-do-pwa-notify.spec.ts | 480 ++++++++++++++++++ lib/__tests__/deep-links.test.ts | 10 + lib/__tests__/push-action-route.test.ts | 313 ++++++++++++ lib/__tests__/push-preview-route.test.ts | 10 +- lib/__tests__/sw-harness.ts | 33 +- lib/__tests__/sw-notification-actions.test.ts | 363 +++++++++++++ lib/deep-links.ts | 7 +- lib/offline/__tests__/queue-sync.test.ts | 35 ++ lib/offline/queue.ts | 6 +- lib/pwa-notify/__tests__/outbox-sync.test.ts | 63 +++ lib/pwa-notify/__tests__/page-actions.test.ts | 175 +++++++ lib/pwa-notify/__tests__/worker-state.test.ts | 70 +++ lib/pwa-notify/login-fingerprint.ts | 13 + lib/pwa-notify/outbox-sync.ts | 24 + lib/pwa-notify/page-actions.ts | 121 +++++ lib/pwa-notify/worker-state.ts | 58 +++ locales/ar/common.json | 6 + locales/ca/common.json | 6 + locales/cs/common.json | 6 + locales/da/common.json | 6 + locales/de/common.json | 6 + locales/en/common.json | 6 + locales/es/common.json | 6 + locales/et/common.json | 6 + locales/fa/common.json | 6 + locales/fr/common.json | 6 + locales/he/common.json | 6 + locales/hu/common.json | 6 + locales/it/common.json | 6 + locales/ja/common.json | 6 + locales/ko/common.json | 6 + locales/lv/common.json | 6 + locales/mn/common.json | 6 + locales/nb/common.json | 6 + locales/nl/common.json | 6 + locales/pl/common.json | 6 + locales/pt/common.json | 6 + locales/ro/common.json | 6 + locales/ru/common.json | 6 + locales/sk/common.json | 6 + locales/th/common.json | 6 + locales/tr/common.json | 6 + locales/uk/common.json | 6 + locales/vi/common.json | 6 + locales/zh-TW/common.json | 6 + locales/zh/common.json | 6 + public/sw.js | 267 +++++++++- 53 files changed, 2707 insertions(+), 17 deletions(-) create mode 100644 app/api/push/action/route.ts create mode 100644 components/providers/__tests__/notification-actions-host.test.tsx create mode 100644 components/providers/notification-actions-host.tsx create mode 100644 integration/tests/23-do-pwa-notify.spec.ts create mode 100644 lib/__tests__/push-action-route.test.ts create mode 100644 lib/__tests__/sw-notification-actions.test.ts create mode 100644 lib/offline/__tests__/queue-sync.test.ts create mode 100644 lib/pwa-notify/__tests__/outbox-sync.test.ts create mode 100644 lib/pwa-notify/__tests__/page-actions.test.ts create mode 100644 lib/pwa-notify/__tests__/worker-state.test.ts create mode 100644 lib/pwa-notify/login-fingerprint.ts create mode 100644 lib/pwa-notify/outbox-sync.ts create mode 100644 lib/pwa-notify/page-actions.ts create mode 100644 lib/pwa-notify/worker-state.ts diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx index 9164a715f..764ae5547 100644 --- a/app/(main)/[locale]/layout.tsx +++ b/app/(main)/[locale]/layout.tsx @@ -27,6 +27,7 @@ import { PluginOAuthCallbackListener } from "@/components/providers/plugin-oauth import { PWAInstallPrompt } from "@/components/pwa-install-prompt"; import { AppUpdatePrompt } from "@/components/app-update-prompt"; import { OfflineHost } from "@/components/offline/offline-host"; +import { NotificationActionsHost } from "@/components/providers/notification-actions-host"; import { DevicePolicyHost } from "@/components/device/device-policy-host"; import { PushNotificationPrompt } from "@/components/push-notification-prompt"; import { AccountArchiveRestorePrompt } from "@/components/account-archive-prompt"; @@ -103,6 +104,7 @@ export default async function LocaleLayout({ {/* Offline mode isn't in Lite (no service worker there). */} {!IS_LITE && } + {!IS_LITE && } {/* Lite is a static bundle: no server keeps an archive there. */} diff --git a/app/api/push/action/route.ts b/app/api/push/action/route.ts new file mode 100644 index 000000000..169b7f7e8 --- /dev/null +++ b/app/api/push/action/route.ts @@ -0,0 +1,273 @@ +import { cookies } from 'next/headers'; +import { NextRequest, NextResponse } from 'next/server'; +import { logger } from '@/lib/logger'; +import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils'; +import { configManager } from '@/lib/admin/config-manager'; +import { resolvePolicy } from '@/lib/admin/policy-scopes'; +import { strictDeviceSubject } from '@/lib/admin/server-device-policy'; +import { forwardedClientHeaders, loginClientAddress } from '@/lib/auth/client-address'; +import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint'; +import { UPSTREAM_JSON_MAX_BYTES, UpstreamBodyTooLargeError, readBodyCapped, readJsonCapped } from '@/lib/security/capped-body'; +import { rejectCrossOriginRequest } from '@/lib/security/same-origin'; +import { DisallowedUrlError } from '@/lib/security/url-guard'; +import { readStalwartAuthContextFromStore, type StalwartAuthContext } from '@/lib/stalwart/auth-context'; +import { rebaseApiUrl } from '@/lib/stalwart/jmap-api'; +import { fetchJmapServer, isTrustedJmapServerUrl } from '@/lib/stalwart/server-fetch'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +const USING = ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail']; +const MAIL_CAPABILITY = 'urn:ietf:params:jmap:mail'; +const MAX_BODY_BYTES = 4096; +/** RFC 8620 ids, which is all an email or account id can be. */ +const JMAP_ID = /^[A-Za-z0-9_-]{1,255}$/; + +type ArchiveMode = 'single' | 'year' | 'month'; + +interface ActionRequest { + action: 'mark-read' | 'archive'; + accountId: string; + emailId: string; + login: string; + archiveMode: ArchiveMode; + timeZone?: string; +} + +interface JmapMailbox { + id: string; + name: string; + parentId?: string | null; + role?: string | null; + myRights?: { mayAddItems?: boolean }; +} + +type MethodResponse = [string, Record, string]; + +const fail = (status: number, error: string) => + NextResponse.json({ error }, { status, headers: { 'Cache-Control': 'no-store' } }); + +function validTimeZone(value: unknown): string | undefined { + if (typeof value !== 'string' || value.length > 64) return undefined; + try { + new Intl.DateTimeFormat('en-US', { timeZone: value }); + return value; + } catch { + return undefined; + } +} + +async function readActionRequest(request: Request): Promise { + const bytes = await readBodyCapped(request, MAX_BODY_BYTES); + if (!bytes) return null; + let body: Record; + try { + body = JSON.parse(new TextDecoder().decode(bytes)) as Record; + } catch { + return null; + } + if (!body || typeof body !== 'object') return null; + const { action, accountId, emailId, login, archiveMode } = body; + if (action !== 'mark-read' && action !== 'archive') return null; + if (typeof accountId !== 'string' || !JMAP_ID.test(accountId)) return null; + if (typeof emailId !== 'string' || !JMAP_ID.test(emailId)) return null; + if (typeof login !== 'string' || !login) return null; + return { + action, + accountId, + emailId, + login, + archiveMode: archiveMode === 'year' || archiveMode === 'month' ? archiveMode : 'single', + timeZone: validTimeZone(body.timeZone), + }; +} + +/** The signed-in logins this browser's cookies name as `login`: the notification's own, or none. */ +async function loginsNamed(login: string): Promise { + const cookieStore = await cookies(); + const found: StalwartAuthContext[] = []; + for (let slot = 0; slot < MAX_ACCOUNT_SLOTS; slot++) { + const ctx = readStalwartAuthContextFromStore(cookieStore, slot); + if (ctx && (await loginFingerprint(ctx.serverUrl, ctx.username)) === login) found.push(ctx); + } + return found; +} + +/** + * The Archive gate for this login's scopes (#1257). The worker's copy of the + * setting decides whether a button shows; this decides whether it works. When + * the login's scopes can't be worked out, the action is refused. + */ +async function archiveAllowed(ctx: StalwartAuthContext, trusted: boolean): Promise { + await configManager.ensureLoaded(); + const subject = await strictDeviceSubject({ ...ctx, trusted }); + if (!subject) return false; + return resolvePolicy(configManager.getPolicy(), subject).policy.features?.archiveActionEnabled !== false; +} + +/** The year and month a message falls in where the user is. */ +function dateParts(receivedAt: string, timeZone: string | undefined): { year: string; month: string } { + const parts = new Intl.DateTimeFormat('en-CA', { timeZone: timeZone ?? 'UTC', year: 'numeric', month: '2-digit' }) + .formatToParts(new Date(receivedAt)); + return { + year: parts.find((part) => part.type === 'year')?.value ?? '', + month: parts.find((part) => part.type === 'month')?.value ?? '', + }; +} + +/** + * Where Archive files a message, as the app's own Archive does + * (JMAPClient.archiveEmails, ensureArchiveMailbox): the Archive folder, or a + * year or year/month folder in it, creating what isn't there. Creation ids + * resolve in the request that carries the Mailbox/set. + */ +function archiveDestination( + mailboxes: JmapMailbox[], + mode: ArchiveMode, + receivedAt: string, + timeZone: string | undefined, +): { destination: string; create: Record> } { + const create: Record> = {}; + const archive = mailboxes.find((m) => m.role === 'archive') + ?? mailboxes.find((m) => m.name.toLowerCase() === 'archive'); + let destination = archive?.id ?? '#archive'; + if (!archive) create.archive = { name: 'Archive', parentId: null, role: 'archive' }; + + const descend = (name: string, cid: string) => { + const existing = destination.startsWith('#') + ? undefined + : mailboxes.find((m) => m.parentId === destination && m.name === name); + if (existing) { + destination = existing.id; + return; + } + create[cid] = { name, parentId: destination }; + destination = `#${cid}`; + }; + if (mode !== 'single') { + const { year, month } = dateParts(receivedAt, timeZone); + descend(year, 'year'); + if (mode === 'month') descend(month, 'month'); + } + return { destination, create }; +} + +/** + * POST /api/push/action + * + * A notification's Mark read or Archive when no tab is open to do it. The + * service worker has no login of its own, only the context cookie, whose + * token a page refreshes and which ends with the browser session. Every + * refusal here means the same to the worker: open the message in the app. + * + * Like /api/push/preview it never hands the worker a credential. The cookie + * is sent because the request is same-origin; the proxy and this handler both + * refuse any other. Plugins run in pages, so their move and read hooks don't + * see an action taken here. + */ +export async function POST(request: NextRequest) { + const rejected = rejectCrossOriginRequest(request); + if (rejected) return rejected; + try { + const body = await readActionRequest(request); + if (!body) return fail(400, 'Invalid request'); + + const logins = await loginsNamed(body.login); + if (logins.length === 0) return fail(401, 'Not authenticated'); + if (logins.length > 1) return fail(409, 'Login is in more than one cookie slot'); + const ctx = logins[0]; + + const serverUrl = ctx.serverUrl.replace(/\/+$/, ''); + const trusted = await isTrustedJmapServerUrl(serverUrl); + const forwarded = forwardedClientHeaders(loginClientAddress(request)); + const sessionRes = await fetchJmapServer(`${serverUrl}/.well-known/jmap`, { + headers: { Authorization: ctx.authHeader, ...forwarded }, + }, trusted); + // An expired token (only a page refreshes it), or a password changed since. + if (sessionRes.status === 401 || sessionRes.status === 403) return fail(401, 'Not authenticated'); + if (!sessionRes.ok) return fail(502, 'JMAP session failed'); + const session = (await readJsonCapped(sessionRes, UPSTREAM_JSON_MAX_BYTES)) as { + apiUrl?: string; + primaryAccounts?: Record; + accounts?: Record }>; + }; + const apiUrl = rebaseApiUrl(session, serverUrl); + if (!apiUrl) return fail(502, 'JMAP session failed'); + // The worker named this account; it has to be one this login can reach. + const { accountId, emailId } = body; + const account = session.accounts && Object.prototype.hasOwnProperty.call(session.accounts, accountId) + ? session.accounts[accountId] + : undefined; + if (!account && session.primaryAccounts?.[MAIL_CAPABILITY] !== accountId) { + return fail(403, 'Account is not in this login'); + } + + const jmap = async (methodCalls: unknown[]): Promise => { + const res = await fetchJmapServer(apiUrl, { + method: 'POST', + headers: { Authorization: ctx.authHeader, 'Content-Type': 'application/json', ...forwarded }, + body: JSON.stringify({ using: USING, methodCalls }), + }, trusted); + if (!res.ok) return null; + const data = (await readJsonCapped(res, UPSTREAM_JSON_MAX_BYTES)) as { methodResponses?: MethodResponse[] }; + return Array.isArray(data.methodResponses) ? data.methodResponses : null; + }; + const result = (responses: MethodResponse[], name: string, id: string) => + responses.find(([method, , callId]) => method === name && callId === id)?.[1]; + + let update: Record; + let create: Record> = {}; + if (body.action === 'mark-read') { + update = { 'keywords/$seen': true }; + } else { + if (!(await archiveAllowed(ctx, trusted))) return fail(403, 'Archive is turned off'); + const read = await jmap([ + ['Mailbox/get', { accountId, ids: null, properties: ['id', 'name', 'parentId', 'role', 'myRights'] }, 'mailboxes'], + ['Email/get', { accountId, ids: [emailId], properties: ['id', 'receivedAt'] }, 'email'], + ]); + if (!read) return fail(502, 'JMAP request failed'); + const mailboxes = result(read, 'Mailbox/get', 'mailboxes')?.list as JmapMailbox[] | undefined; + const emails = result(read, 'Email/get', 'email')?.list as { receivedAt?: string }[] | undefined; + if (!Array.isArray(mailboxes) || !Array.isArray(emails)) return fail(502, 'JMAP request failed'); + const receivedAt = emails[0]?.receivedAt; + if (!receivedAt) return fail(404, 'Message not found'); + const planned = archiveDestination(mailboxes, body.archiveMode, receivedAt, body.timeZone); + // Only an explicit refusal stops it, as in the app. + if (planned.create.archive && account?.accountCapabilities?.[MAIL_CAPABILITY]?.mayCreateTopLevelMailbox === false) { + return fail(403, 'Archive folder is missing'); + } + if (mailboxes.find((m) => m.id === planned.destination)?.myRights?.mayAddItems === false) { + return fail(403, 'Archive folder is read-only'); + } + create = planned.create; + update = { mailboxIds: { [planned.destination]: true } }; + } + + const written = await jmap([ + ...(Object.keys(create).length > 0 ? [['Mailbox/set', { accountId, create }, 'folders']] : []), + ['Email/set', { accountId, update: { [emailId]: update } }, 'write'], + ]); + if (!written) return fail(502, 'JMAP request failed'); + const set = result(written, 'Email/set', 'write') as { + updated?: Record | null; + notUpdated?: Record | null; + } | undefined; + if (set?.updated && Object.prototype.hasOwnProperty.call(set.updated, emailId)) { + return NextResponse.json({ ok: true }, { headers: { 'Cache-Control': 'no-store' } }); + } + if (set?.notUpdated?.[emailId]?.type === 'notFound') return fail(404, 'Message not found'); + return fail(502, 'JMAP server refused the change'); + } catch (error) { + if (error instanceof DisallowedUrlError) { + logger.warn('push action refused non-public server address', { error: error.message }); + return fail(502, 'JMAP server address is not allowed'); + } + if (error instanceof UpstreamBodyTooLargeError) { + logger.warn('push action: JMAP response too large', { maxBytes: error.maxBytes }); + return fail(502, 'JMAP server response is too large'); + } + const err = error as Error & { cause?: { code?: string; message?: string } }; + logger.error('push action failed', { error: err?.message ?? 'Unknown error', causeCode: err?.cause?.code }); + return fail(500, 'Internal error'); + } +} diff --git a/app/api/push/preview/route.ts b/app/api/push/preview/route.ts index 8e48073ba..c862eeb99 100644 --- a/app/api/push/preview/route.ts +++ b/app/api/push/preview/route.ts @@ -14,6 +14,7 @@ import { forwardedClientHeaders, loginClientAddress } from '@/lib/auth/client-ad import { DisallowedUrlError } from '@/lib/security/url-guard'; import { UPSTREAM_JSON_MAX_BYTES, UpstreamBodyTooLargeError, readJsonCapped } from '@/lib/security/capped-body'; import { serverDevicePolicy } from '@/lib/admin/server-device-policy'; +import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -359,6 +360,9 @@ export async function GET(request: NextRequest) { unreadTotal, ...(redacted ? { redacted: true } : {}), ...(target.slot !== undefined ? { slot: target.slot } : {}), + // For the notification's buttons, which must not land on whoever signs + // into this slot later (app/api/push/action). + ...(target.slot !== undefined && email ? { login: await loginFingerprint(target.serverUrl, target.username) } : {}), }, { headers: { // SW already gates on its own logic - don't let push events get diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index c69d7c2c7..3f6cf4e1d 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -142,6 +142,8 @@ import { usePluginStore } from "@/stores/plugin-store"; import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot"; import { canDownload } from "@/lib/device-policy"; import { consumePendingMailto, subscribeToPendingMailto } from "@/lib/protocol-handlers/session"; +import { setNotificationReplyOpener } from "@/lib/pwa-notify/page-actions"; +import { consumePendingReplyText } from "@/lib/pwa-notify/worker-state"; import { INTERNAL_MAILTO_EVENT, type ParsedMailto } from "@/lib/protocol-handlers/mailto"; import { PLUGIN_REPLY_EVENT, type PluginReplyRequest } from "@/lib/plugin-sandbox/host-api-compose"; import { onOpenEmail, type OpenEmailHandler, type OpenEmailOutcome } from "@/lib/plugin-sandbox/open-email"; @@ -257,6 +259,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { const [showComposer, setShowComposer] = useState(false); const [composerMode, setComposerMode] = useState<'compose' | 'reply' | 'replyAll' | 'forward'>('compose'); const [composerDraftText, setComposerDraftText] = useState(""); + // A notification's Reply, waiting for its message to be the open one. + const [pendingNotificationReply, setPendingNotificationReply] = useState<{ emailId: string; text: string } | null>(null); const [pendingDraft, setPendingDraft] = useState(null); const [composerSessionId, setComposerSessionId] = useState(0); // Fields a plugin filled in through onBeforeCompose for the composer about @@ -1768,7 +1772,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { } } }; - const applyMailDeepLink = async (link: MailDeepLink, opts?: { onLoad?: boolean }) => { + const applyMailDeepLink = async (link: MailDeepLink, opts?: { onLoad?: boolean; replyText?: string }) => { // The folder list before an account switch, if one happened: until the // new mailbox's folders arrive the store still holds these (or nothing). let switchedFrom: Mailbox[] | null = null; @@ -1867,6 +1871,12 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { } if (isMobile) setActiveView('viewer'); if (isTablet) setTabletListVisible(false); + if (link.reply) { + // A notification's Reply. The branch above may have skipped + // selecting, and the reply has to wait for the message to be open. + selectEmail(email); + setPendingNotificationReply({ emailId: email.id, text: opts?.replyText ?? await consumePendingReplyText(email.id) }); + } } finally { setLoadingEmail(false); } @@ -2764,6 +2774,31 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { return () => window.removeEventListener(PLUGIN_REPLY_EVENT, onPluginReply); }, []); + // A notification's Reply, from a `?reply=1` link or from the service worker + // when this tab is open. Both come through applyMailDeepLink and end here, + // once the message is the open one. + useEffect(() => { + if (!pendingNotificationReply || selectedEmail?.id !== pendingNotificationReply.emailId) return; + setPendingNotificationReply(null); + void pluginReplyRef.current.handleReply(pendingNotificationReply.text); + }, [pendingNotificationReply, selectedEmail?.id]); + // The Pro shell opens messages in tabs of its own, so it takes the link instead. + const composerOpenRef = useRef(showComposer); + composerOpenRef.current = showComposer; + useEffect(() => { + if (isEmbedded) return; + return setNotificationReplyOpener(({ emailId, accountId, slot, text }) => { + const open = () => void applyMailDeepLinkRef.current( + { kind: 'message', id: emailId, slot, ownerAccountId: accountId, reply: true }, + { replyText: text ?? '' }, + ); + // A live composer's text goes through its own "Save or discard?" first. + const requestClose = composerRequestCloseRef.current; + if (composerOpenRef.current && requestClose) requestClose(open); + else open(); + }); + }, [isEmbedded]); + // A plugin opening a message (api.ui.openEmail), as a click on a search // result entry naming one does: handleEmailSelect, set once it is defined. // Not inside the Pro shell, which keeps this tab mounted while hidden and diff --git a/components/providers/__tests__/notification-actions-host.test.tsx b/components/providers/__tests__/notification-actions-host.test.tsx new file mode 100644 index 000000000..99fa14b6f --- /dev/null +++ b/components/providers/__tests__/notification-actions-host.test.tsx @@ -0,0 +1,124 @@ +import { act, render } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { NotificationActionsHost } from '../notification-actions-host'; + +// The tab's end of the service worker: it keeps the worker's copy of the +// labels and archive settings, runs notification buttons and flushes the +// outbox when the worker says so. + +const writeNotificationActionPrefs = vi.fn(async (..._args: unknown[]) => {}); +const runNotificationAction = vi.fn(async (_request: unknown, answer: (status: string) => void) => answer('done')); +const scheduleFlushAll = vi.fn(); +let state = { isAuthenticated: true, archiveMode: 'month', archiveEnabled: true }; + +vi.mock('@/lib/pwa-notify/worker-state', () => ({ + writeNotificationActionPrefs: (...args: unknown[]) => writeNotificationActionPrefs(...args), +})); +vi.mock('@/lib/pwa-notify/page-actions', async () => { + const actual = await vi.importActual('@/lib/pwa-notify/page-actions'); + return { isNotificationActionRequest: actual.isNotificationActionRequest, runNotificationAction: (...args: Parameters) => runNotificationAction(...args) }; +}); +vi.mock('@/lib/offline/offline-mode', () => ({ scheduleFlushAll: (...args: unknown[]) => scheduleFlushAll(...args) })); +vi.mock('@/lib/archive-gate', () => ({ useArchiveActionEnabled: () => state.archiveEnabled })); +vi.mock('@/stores/auth-store', () => ({ + useAuthStore: (select: (s: { isAuthenticated: boolean }) => unknown) => select({ isAuthenticated: state.isAuthenticated }), +})); +vi.mock('@/stores/settings-store', () => ({ + useSettingsStore: (select: (s: { archiveMode: string }) => unknown) => select({ archiveMode: state.archiveMode }), +})); +vi.mock('@/stores/email-store', () => ({ useEmailStore: { getState: () => ({}) } })); +vi.mock('@/stores/account-store', () => ({ useAccountStore: { getState: () => ({ accounts: [] }) } })); + +const worker = new EventTarget(); +const fromWorker = (data: unknown, ports: unknown[] = []) => act(async () => { + const event = new Event('message'); + Object.assign(event, { data, ports }); + worker.dispatchEvent(event); +}); + +beforeEach(() => { + vi.clearAllMocks(); + state = { isAuthenticated: true, archiveMode: 'month', archiveEnabled: true }; + Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: worker }); +}); + +afterEach(() => { + Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: undefined }); +}); + +describe('the mirror for the worker', () => { + it('writes the labels and archive settings once signed in', () => { + render(); + expect(writeNotificationActionPrefs).toHaveBeenCalledWith({ + archiveEnabled: true, + archiveMode: 'month', + labels: { archive: 'action_archive', markRead: 'action_mark_read', reply: 'action_reply', replyPlaceholder: 'reply_placeholder' }, + }); + }); + + it('writes nothing while signed out', () => { + state.isAuthenticated = false; + render(); + expect(writeNotificationActionPrefs).not.toHaveBeenCalled(); + }); + + it('writes again on signing in, since signing out deleted the store', () => { + state.isAuthenticated = false; + const { rerender } = render(); + state.isAuthenticated = true; + rerender(); + expect(writeNotificationActionPrefs).toHaveBeenCalledTimes(1); + }); + + it('writes again when the archive mode or the gate changes, and not otherwise', () => { + const { rerender } = render(); + rerender(); + expect(writeNotificationActionPrefs).toHaveBeenCalledTimes(1); + state.archiveMode = 'year'; + rerender(); + expect(writeNotificationActionPrefs).toHaveBeenCalledTimes(2); + state.archiveEnabled = false; + rerender(); + expect(writeNotificationActionPrefs).toHaveBeenCalledTimes(3); + expect(writeNotificationActionPrefs.mock.calls[2][0]).toMatchObject({ archiveEnabled: false, archiveMode: 'year' }); + }); +}); + +describe('messages from the worker', () => { + const request = { kind: 'notification-action', action: 'mark-read', emailId: 'e1', accountId: 'a', slot: 0, login: 'fp' }; + + it('runs a notification action and answers on its port', async () => { + render(); + const port = { postMessage: vi.fn() }; + await fromWorker(request, [port]); + expect(runNotificationAction.mock.calls[0][0]).toMatchObject({ action: 'mark-read', emailId: 'e1' }); + expect(port.postMessage).toHaveBeenCalledWith({ status: 'done' }); + }); + + it('ignores anything else', async () => { + render(); + await fromWorker({ kind: 'notificationclick', tag: 't' }); + await fromWorker({ ...request, action: 'delete' }); + await fromWorker(null); + expect(runNotificationAction).not.toHaveBeenCalled(); + expect(scheduleFlushAll).not.toHaveBeenCalled(); + }); + + it('flushes the outbox on the word of a sync event', async () => { + render(); + await fromWorker({ kind: 'outbox-flush' }); + expect(scheduleFlushAll).toHaveBeenCalledWith(0); + }); + + it('stops listening when it goes', async () => { + const { unmount } = render(); + unmount(); + await fromWorker({ kind: 'outbox-flush' }); + expect(scheduleFlushAll).not.toHaveBeenCalled(); + }); + + it('does nothing in a browser without service workers', () => { + Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: undefined }); + expect(() => render()).not.toThrow(); + }); +}); diff --git a/components/providers/notification-actions-host.tsx b/components/providers/notification-actions-host.tsx new file mode 100644 index 000000000..6231869f9 --- /dev/null +++ b/components/providers/notification-actions-host.tsx @@ -0,0 +1,56 @@ +'use client'; + +import { useEffect } from 'react'; +import { useTranslations } from 'next-intl'; +import { useArchiveActionEnabled } from '@/lib/archive-gate'; +import { scheduleFlushAll } from '@/lib/offline/offline-mode'; +import { isNotificationActionRequest, runNotificationAction } from '@/lib/pwa-notify/page-actions'; +import { writeNotificationActionPrefs } from '@/lib/pwa-notify/worker-state'; +import { useAuthStore } from '@/stores/auth-store'; +import { useSettingsStore } from '@/stores/settings-store'; + +/** + * This tab's end of what the service worker can't do alone: it keeps the + * worker's copy of the labels and archive settings current, runs a + * notification's buttons and flushes the outbox on the worker's word. Not + * mounted in Lite (no service worker there). + */ +export function NotificationActionsHost() { + const t = useTranslations('pwaNotify'); + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + const archiveMode = useSettingsStore((s) => s.archiveMode); + const archiveEnabled = useArchiveActionEnabled(); + const archive = t('action_archive'); + const markRead = t('action_mark_read'); + const reply = t('action_reply'); + const replyPlaceholder = t('reply_placeholder'); + + // Signing out deletes the worker's store, so signing in writes it again. + useEffect(() => { + if (!isAuthenticated) return; + void writeNotificationActionPrefs({ + archiveEnabled, + archiveMode, + labels: { archive, markRead, reply, replyPlaceholder }, + }); + }, [isAuthenticated, archiveEnabled, archiveMode, archive, markRead, reply, replyPlaceholder]); + + useEffect(() => { + const worker = navigator.serviceWorker; + if (!worker) return; + const onMessage = (event: MessageEvent) => { + const message = event.data as { kind?: string } | null; + if (message?.kind === 'outbox-flush') { + scheduleFlushAll(0); + return; + } + if (message?.kind !== 'notification-action' || !isNotificationActionRequest(message)) return; + const port = event.ports[0]; + void runNotificationAction(message, (status) => port?.postMessage({ status })); + }; + worker.addEventListener('message', onMessage); + return () => worker.removeEventListener('message', onMessage); + }, []); + + return null; +} diff --git a/integration/tests/23-do-pwa-notify.spec.ts b/integration/tests/23-do-pwa-notify.spec.ts new file mode 100644 index 000000000..99298571c --- /dev/null +++ b/integration/tests/23-do-pwa-notify.spec.ts @@ -0,0 +1,480 @@ +import { test, expect, type BrowserContext, type Page, type Worker } from '@playwright/test'; +import { JMAP_URL } from './helpers/config'; +import { ENT, deliver, jmapAs, requireEntEnv, userPage } from './helpers/ent'; + +/** + * A notification's Archive, Mark read and Reply, and Background Sync, in + * Chromium with the real service worker of a production build, the real + * /api/push/preview and /api/push/action, and a VM 106 test Stalwart. + * + * Headless Chromium has no toasts to click, and a page can't make a browser + * send a trusted notificationclick. So the spec dispatches the event inside + * the real worker, on its real listener: an Event carrying `action`, the + * `notification` the browser really shows, and a waitUntil of its own + * (ExtendableEvent.waitUntil refuses a synthetic event). Everything after + * that is real: matchAll, postMessage to the page, the cookies on the route. + * What can't run this way is openWindow, focus and navigate, which want the + * browser's user activation, so those are recorded on the worker's side and + * the spec then opens the recorded URL itself. + * + * The account is ent-dave, whom the other specs leave alone: the one-message + * notification needs an Inbox with exactly one unread message. + */ + +test.describe.configure({ mode: 'serial' }); +test.setTimeout(180_000); +// The headless shell denies notifications whatever is granted; full Chromium +// in headless mode really shows them, buttons and all, to getNotifications. +test.use({ channel: 'chromium' }); + +const RUN = Date.now().toString(36); +let serial = 0; +const USER = ENT.dave; + +interface ShownNotification { + title: string; + data: Record; + actions: Array<{ action: string; title: string; type?: string }>; +} + +async function workerOf(context: BrowserContext, page: Page): Promise { + // A production build registers the worker; the dev server removes it. + await page.evaluate(() => navigator.serviceWorker.ready.then(() => undefined)); + await expect.poll(() => context.serviceWorkers().length, { timeout: 30_000 }).toBeGreaterThan(0); + const sw = context.serviceWorkers().at(-1)!; + // What needs the user activation a dispatched click doesn't have. + await sw.evaluate(() => { + const self_ = globalThis as unknown as { __opened: string[]; __posts: string[]; clients: Record; fetch: typeof fetch }; + if (self_.__opened) return; + self_.__opened = []; + self_.__posts = []; + self_.clients.openWindow = async (url: string) => { self_.__opened.push(url); return null; }; + const realFetch = self_.fetch.bind(globalThis); + self_.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + if (init?.method === 'POST') self_.__posts.push(String(input)); + return realFetch(input, init); + }) as typeof fetch; + }); + return sw; +} + +/** The real listener, with what the browser would pass. Resolves when its waitUntil settled. */ +async function click(sw: Worker, action: string, data: Record, reply?: string): Promise { + await sw.evaluate(async ({ action: a, data: d, reply: r }) => { + const waits: Promise[] = []; + const event = new Event('notificationclick') as Event & Record; + Object.assign(event, { + action: a, + reply: r ?? null, + notification: { data: d, tag: 'spec', close() {} }, + waitUntil: (p: Promise) => waits.push(p), + }); + self.dispatchEvent(event); + await Promise.all(waits); + }, { action, data, reply }); +} + +async function pushed(sw: Worker, accountId: string, emailId: string): Promise { + return sw.evaluate(async ({ accountId: account, emailId: id }) => { + const waits: Promise[] = []; + const event = new Event('push') as Event & Record; + Object.assign(event, { + data: { json: () => ({ kind: 'jmap-email-push', accountId: account, emailIds: [id] }) }, + waitUntil: (p: Promise) => waits.push(p), + }); + self.dispatchEvent(event); + await Promise.all(waits); + const registration = (self as unknown as { registration: ServiceWorkerRegistration }).registration; + const [shown] = await registration.getNotifications({ tag: `bulwark-mail:${account}` }); + return { title: shown.title, data: shown.data, actions: [...(shown as unknown as { actions: ShownNotification['actions'] }).actions] }; + }, { accountId, emailId }); +} + +async function opened(sw: Worker): Promise { + return sw.evaluate(() => (globalThis as unknown as { __opened: string[] }).__opened.splice(0)); +} + +async function routePosts(sw: Worker): Promise { + return sw.evaluate(() => (globalThis as unknown as { __posts: string[] }).__posts.splice(0)); +} + +/** Everything unread in the Inbox read, then one new message: what makes a notification name it. */ +async function oneUnread(): Promise<{ id: string; subject: string; accountId: string }> { + const client = await jmapAs(USER); + const inbox = await client.mailboxByRole('inbox'); + const query = await client.request([ + ['Email/query', { accountId: client.accountId, filter: { inMailbox: inbox!.id, notKeyword: '$seen' } }, 'q'], + ]); + const unread: string[] = query.methodResponses[0][1].ids; + if (unread.length > 0) { + await client.request([['Email/set', { + accountId: client.accountId, + update: Object.fromEntries(unread.map((id) => [id, { 'keywords/$seen': true }])), + }, 's']]); + } + const subject = `Notify ${RUN} ${++serial}`; + await deliver({ from: ENT.bob, to: [USER], subject, text: 'Please answer.' }); + const email = await client.waitForEmail(subject, { mailboxId: inbox!.id, timeoutMs: 30_000 }); + return { id: email.id, subject, accountId: client.accountId }; +} + +async function serverCopy(emailId: string): Promise<{ keywords: Record; mailboxIds: Record; receivedAt: string }> { + const client = await jmapAs(USER); + const res = await client.request([['Email/get', { accountId: client.accountId, ids: [emailId], properties: ['keywords', 'mailboxIds', 'receivedAt'] }, 'g']]); + return res.methodResponses[0][1].list[0]; +} + +async function archiveFolderId(): Promise { + const client = await jmapAs(USER); + return (await client.mailboxByRole('archive'))?.id; +} + +test.beforeAll(() => requireEntEnv()); + +test.describe('with the app open', () => { + let page: Page; + let context: BrowserContext; + let sw: Worker; + + test.beforeAll(async ({ browser }) => { + page = await userPage(browser, USER); + context = page.context(); + await context.grantPermissions(['notifications']); + sw = await workerOf(context, page); + // The mirror reaches the worker's store after the app has started. + await expect.poll(() => sw.evaluate(async () => { + const cache = await caches.open('bulwark-push-state-v1'); + const hit = await cache.match(`${self.location.origin}/__push-state/actions`); + return hit ? (await hit.json()).labels?.archive : null; + }), { timeout: 30_000 }).toBe('Archive'); + }); + + test.afterAll(async () => { + await context.close(); + }); + + test('a push for one message shows the buttons the browser has room for', async () => { + const mail = await oneUnread(); + const shown = await pushed(sw, mail.accountId, mail.id); + expect(shown.title).toContain(ENT.bob.split('@')[0]); + expect(shown.data).toMatchObject({ kind: 'email', emailId: mail.id, accountId: mail.accountId }); + expect(typeof shown.data.login).toBe('string'); + const max = await sw.evaluate(() => (Notification as unknown as { maxActions: number }).maxActions); + const names = shown.actions.map((a) => a.action); + expect(names.length).toBe(Math.min(3, max)); + expect(names).toContain('reply'); + expect(names).toContain('archive'); + expect(shown.actions.find((a) => a.action === 'archive')?.title).toBe('Archive'); + // Chromium has `reply` on the event: the text field. + expect(shown.actions.find((a) => a.action === 'reply')?.type).toBe('text'); + }); + + test('a group of messages gets no buttons', async () => { + const client = await jmapAs(USER); + const first = await oneUnread(); + const subject = `Notify ${RUN} second`; + await deliver({ from: ENT.bob, to: [USER], subject, text: 'Another.' }); + const second = await client.waitForEmail(subject, { timeoutMs: 30_000 }); + const shown = await pushed(sw, first.accountId, second.id); + expect(shown.data.kind).toBe('mail-list'); + expect(shown.actions).toEqual([]); + }); + + test('Mark read goes to the open tab, which marks it in the list too, not to the route', async () => { + const mail = await oneUnread(); + const shown = await pushed(sw, mail.accountId, mail.id); + await routePosts(sw); + await click(sw, 'mark-read', shown.data); + await expect.poll(async () => (await serverCopy(mail.id)).keywords.$seen === true, { timeout: 15_000 }).toBe(true); + expect(await routePosts(sw)).toEqual([]); + expect(await opened(sw)).toEqual([]); + }); + + test('Archive goes to the open tab and files the message by the store', async () => { + const mail = await oneUnread(); + const shown = await pushed(sw, mail.accountId, mail.id); + await routePosts(sw); + await click(sw, 'archive', shown.data); + const client = await jmapAs(USER); + const inbox = await client.mailboxByRole('inbox'); + await expect.poll(async () => !(await serverCopy(mail.id)).mailboxIds[inbox!.id], { timeout: 20_000 }).toBe(true); + const archive = await archiveFolderId(); + expect(archive).toBeTruthy(); + expect(Object.keys((await serverCopy(mail.id)).mailboxIds)).toEqual([archive]); + expect(await routePosts(sw)).toEqual([]); + }); + + test('a body click still opens the message and runs nothing', async () => { + const mail = await oneUnread(); + const shown = await pushed(sw, mail.accountId, mail.id); + await routePosts(sw); + // focus() needs the activation a dispatched click lacks, so this ends in openWindow. + await click(sw, '', shown.data); + const urls = await opened(sw); + expect(urls).toHaveLength(1); + expect(urls[0]).toContain(`/mail/message/${mail.id}`); + expect(urls[0]).not.toContain('reply=1'); + expect(await routePosts(sw)).toEqual([]); + expect((await serverCopy(mail.id)).keywords.$seen).toBeFalsy(); + }); + + test('Reply opens the composer in the open tab, with the text typed on the notification', async () => { + const mail = await oneUnread(); + const shown = await pushed(sw, mail.accountId, mail.id); + await routePosts(sw); + await click(sw, 'reply', shown.data, `Typed on the toast ${RUN}`); + await expect.poll(async () => page.evaluate((subjectText) => { + const inputs = [...document.querySelectorAll('input')] as HTMLInputElement[]; + return inputs.some((input) => input.value === `Re: ${subjectText}`); + }, mail.subject), { timeout: 20_000 }).toBe(true); + await expect(page.getByText(`Typed on the toast ${RUN}`).first()).toBeVisible({ timeout: 10_000 }); + expect(await routePosts(sw)).toEqual([]); + expect(await opened(sw)).toEqual([]); + }); +}); + +test.describe('Background Sync', () => { + test('a queued change registers the tag, and a sync event makes a tab that missed `online` flush it', async ({ browser }) => { + const page = await userPage(browser, USER); + const context = page.context(); + await context.grantPermissions(['notifications', 'background-sync']); + const sw = await workerOf(context, page); + await page.addInitScript(() => { + const w = window as unknown as { __syncTags: string[] }; + w.__syncTags = []; + const register = SyncManager.prototype.register; + SyncManager.prototype.register = function patched(tag: string) { + w.__syncTags.push(tag); + return register.call(this, tag); + }; + }); + await page.goto('/settings/backup/offline'); + const toggle = page.getByTestId('offline-switch'); + await expect(toggle).toBeVisible({ timeout: 30_000 }); + if ((await toggle.getAttribute('aria-checked')) !== 'true') await toggle.click(); + await expect(page.getByTestId('offline-status')).toContainText('updated', { timeout: 180_000 }); + await page.goto('/mail'); + await expect(page.locator('[data-testid="email-list-item"]').first()).toBeVisible({ timeout: 60_000 }); + + const mail = await oneUnread(); + const shown = await pushed(sw, mail.accountId, mail.id); + + // The mail server is gone, the browser still online: no `online` event will come. + const block = `${new URL(JMAP_URL).origin}/**`; + await context.route(block, (route) => route.abort('internetdisconnected')); + await page.keyboard.press('Shift+G'); + await expect(page.getByTestId('offline-banner')).toContainText("The mail server can't be reached", { timeout: 60_000 }); + + // The tab queues the change, as it does for its own Mark read button. + await click(sw, 'mark-read', shown.data); + await expect.poll(() => page.evaluate(() => (window as unknown as { __syncTags: string[] }).__syncTags)).toContain('bulwark-outbox'); + expect((await serverCopy(mail.id)).keywords.$seen).toBeFalsy(); + + await context.unroute(block); + await sw.evaluate(async () => { + const waits: Promise[] = []; + const event = new Event('sync') as Event & Record; + Object.assign(event, { tag: 'bulwark-outbox', waitUntil: (p: Promise) => waits.push(p) }); + self.dispatchEvent(event); + await Promise.all(waits); + }); + await expect.poll(async () => (await serverCopy(mail.id)).keywords.$seen === true, { timeout: 30_000 }).toBe(true); + + // Offline mode stays local to this context, which ends here. + await context.close(); + }); +}); + +test.describe('with no tab open', () => { + let page: Page; + let context: BrowserContext; + let sw: Worker; + + /** A blank tab keeps the context alive: it's outside the worker's scope. */ + async function closeTheApp(): Promise { + const blank = await context.newPage(); + await page.close(); + await expect.poll(() => sw.evaluate(async () => (await (self as unknown as { clients: Clients }).clients.matchAll({ type: 'window', includeUncontrolled: true })).length)).toBe(0); + return blank; + } + + test.beforeEach(async ({ browser }) => { + page = await userPage(browser, USER); + context = page.context(); + await context.grantPermissions(['notifications']); + sw = await workerOf(context, page); + await expect.poll(() => sw.evaluate(async () => { + const cache = await caches.open('bulwark-push-state-v1'); + return !!(await cache.match(`${self.location.origin}/__push-state/actions`)); + }), { timeout: 30_000 }).toBe(true); + }); + + test.afterEach(async () => { + await context.close(); + }); + + test('Mark read goes through the route on the context cookie', async () => { + const mail = await oneUnread(); + const shown = await pushed(sw, mail.accountId, mail.id); + await closeTheApp(); + await routePosts(sw); + await click(sw, 'mark-read', shown.data); + expect((await routePosts(sw)).at(-1)).toContain('/api/push/action'); + expect((await serverCopy(mail.id)).keywords.$seen).toBe(true); + expect(await opened(sw)).toEqual([]); + }); + + test('Archive goes through the route, to the folder the app uses', async () => { + const mail = await oneUnread(); + const shown = await pushed(sw, mail.accountId, mail.id); + await closeTheApp(); + await click(sw, 'archive', shown.data); + const archive = await archiveFolderId(); + expect(archive).toBeTruthy(); + expect(Object.keys((await serverCopy(mail.id)).mailboxIds)).toEqual([archive]); + expect(await opened(sw)).toEqual([]); + }); + + test('Archive follows the mirrored mode into year and month folders', async () => { + const mail = await oneUnread(); + const shown = await pushed(sw, mail.accountId, mail.id); + await sw.evaluate(async () => { + const cache = await caches.open('bulwark-push-state-v1'); + const key = `${self.location.origin}/__push-state/actions`; + const prefs = await (await cache.match(key))!.json(); + await cache.put(key, new Response(JSON.stringify({ ...prefs, archiveMode: 'month' }))); + }); + await closeTheApp(); + await click(sw, 'archive', shown.data); + const client = await jmapAs(USER); + const copy = await serverCopy(mail.id); + const [folderId] = Object.keys(copy.mailboxIds); + const folders = await client.mailboxes(); + const month = folders.find((f) => f.id === folderId)!; + const year = folders.find((f) => f.id === month.parentId)!; + const when = new Date(copy.receivedAt); + expect(month.name).toBe(String(when.getMonth() + 1).padStart(2, '0')); + expect(year.name).toBe(String(when.getFullYear())); + expect(year.parentId).toBe(await archiveFolderId()); + }); + + test('Archive makes the Archive, year and month folders on an account without them', async () => { + const client = await jmapAs(USER); + const mail = await oneUnread(); + const shown = await pushed(sw, mail.accountId, mail.id); + await sw.evaluate(async () => { + const cache = await caches.open('bulwark-push-state-v1'); + const key = `${self.location.origin}/__push-state/actions`; + const prefs = await (await cache.match(key))!.json(); + await cache.put(key, new Response(JSON.stringify({ ...prefs, archiveMode: 'month' }))); + }); + await closeTheApp(); + // Out of the way, so the account has no Archive for the moment. + const old = await client.mailboxByRole('archive'); + const oldName = `Old archive ${RUN}`; + if (old) { + const renamed = await client.request([['Mailbox/set', { accountId: client.accountId, update: { [old.id]: { name: oldName, role: null } } }, 'r']]); + expect(renamed.methodResponses[0][1].updated).toHaveProperty(old.id); + } + + await click(sw, 'archive', shown.data); + + expect(await opened(sw)).toEqual([]); + const created = await client.mailboxByRole('archive'); + expect(created?.name).toBe('Archive'); + expect(created?.id).not.toBe(old?.id); + const copy = await serverCopy(mail.id); + const folders = await client.mailboxes(); + const month = folders.find((f) => f.id === Object.keys(copy.mailboxIds)[0])!; + const year = folders.find((f) => f.id === month.parentId)!; + expect(year.parentId).toBe(created!.id); + + if (old) { + // Only this spec's messages live in ent-dave's Archive. + await client.request([['Mailbox/set', { accountId: client.accountId, destroy: [old.id], onDestroyRemoveEmails: true }, 'd']]); + } + }); + + test('with the context cookie gone the message opens instead, and stays as it was', async () => { + const mail = await oneUnread(); + const shown = await pushed(sw, mail.accountId, mail.id); + await closeTheApp(); + const jar = await context.cookies(); + await context.clearCookies({ name: /^jmap_stalwart_ctx/ }); + expect(jar.some((c) => c.name.startsWith('jmap_stalwart_ctx'))).toBe(true); + await routePosts(sw); + await click(sw, 'mark-read', shown.data); + expect((await routePosts(sw)).at(-1)).toContain('/api/push/action'); + const urls = await opened(sw); + expect(urls).toHaveLength(1); + expect(urls[0]).toContain(`/mail/message/${mail.id}`); + expect((await serverCopy(mail.id)).keywords.$seen).toBeFalsy(); + }); + + test('a notification from a login no longer signed in acts on nothing', async () => { + const mail = await oneUnread(); + const shown = await pushed(sw, mail.accountId, mail.id); + await closeTheApp(); + await click(sw, 'mark-read', { ...shown.data, login: 'someone-else' }); + expect(await opened(sw)).toHaveLength(1); + expect((await serverCopy(mail.id)).keywords.$seen).toBeFalsy(); + }); + + test('Reply opens the link, the composer opens once with the typed text, and a reload leaves it shut', async () => { + const mail = await oneUnread(); + const shown = await pushed(sw, mail.accountId, mail.id); + const blank = await closeTheApp(); + await click(sw, 'reply', shown.data, `Typed while closed ${RUN}`); + const [url] = await opened(sw); + expect(url).toContain(`/mail/message/${mail.id}`); + expect(url).toContain('reply=1'); + expect(url).not.toContain('Typed'); + + page = await context.newPage(); + await page.goto(url); + await blank.close(); + const replyInput = () => page.evaluate((subjectText) => { + const inputs = [...document.querySelectorAll('input')] as HTMLInputElement[]; + return inputs.some((input) => input.value === `Re: ${subjectText}`); + }, mail.subject); + await expect.poll(replyInput, { timeout: 45_000 }).toBe(true); + await expect(page.getByText(`Typed while closed ${RUN}`).first()).toBeVisible({ timeout: 10_000 }); + expect(page.url()).not.toContain('reply=1'); + + // Leave the draft and come back: the link's job is done. + await page.reload(); + await expect(page.getByText('Please answer.').first()).toBeVisible({ timeout: 45_000 }); + expect(await replyInput()).toBe(false); + }); +}); + +test.describe('the route, as a page or another site would call it', () => { + test('refuses a cross-site POST at the gate, with the cookies on it', async ({ browser }) => { + const page = await userPage(browser, USER); + const mail = await oneUnread(); + const res = await page.request.post('/api/push/action', { + headers: { 'Sec-Fetch-Site': 'cross-site' }, + data: { action: 'mark-read', accountId: mail.accountId, emailId: mail.id, login: 'x' }, + }); + expect(res.status()).toBe(403); + expect((await serverCopy(mail.id)).keywords.$seen).toBeFalsy(); + await page.context().close(); + }); + + test('refuses another account of the same login, and answers 401 without cookies', async ({ browser }) => { + const page = await userPage(browser, USER); + await page.context().grantPermissions(['notifications']); + const sw = await workerOf(page.context(), page); + const mail = await oneUnread(); + const shown = await pushed(sw, mail.accountId, mail.id); + const post = (over: Record) => page.request.post('/api/push/action', { + data: { action: 'mark-read', accountId: mail.accountId, emailId: mail.id, login: shown.data.login, ...over }, + }); + expect((await post({ accountId: 'zzzzzz' })).status()).toBe(403); + expect((await serverCopy(mail.id)).keywords.$seen).toBeFalsy(); + await page.context().clearCookies({ name: /^jmap_stalwart_ctx/ }); + expect((await post({})).status()).toBe(401); + expect((await serverCopy(mail.id)).keywords.$seen).toBeFalsy(); + await page.context().close(); + }); +}); diff --git a/lib/__tests__/deep-links.test.ts b/lib/__tests__/deep-links.test.ts index c9696f162..0dc59db84 100644 --- a/lib/__tests__/deep-links.test.ts +++ b/lib/__tests__/deep-links.test.ts @@ -185,6 +185,16 @@ describe('mail paths', () => { expect(parseMailPath(['thread', 't1'])).toEqual({ kind: 'thread', id: 't1', accountId: undefined }); }); + it("reads a notification's reply flag on a message link, and only there", () => { + expect(parseMailPath(['message', 'm1'], new URLSearchParams('slot=1&owner=a&reply=1'))).toMatchObject({ + kind: 'message', id: 'm1', slot: 1, ownerAccountId: 'a', reply: true, + }); + expect(parseMailPath(['message', 'm1'], new URLSearchParams('reply=0'))).not.toHaveProperty('reply'); + expect(parseMailPath(['message', 'm1'])).not.toHaveProperty('reply'); + expect(parseMailPath(['folder', 'inbox'], new URLSearchParams('reply=1'))).not.toHaveProperty('reply'); + expect(parseMailPath(['thread', 't1'], new URLSearchParams('reply=1'))).not.toHaveProperty('reply'); + }); + it('reads the fullscreen marker on message links', () => { expect(parseMailPath(['message', 'm1'], new URLSearchParams('view=fullscreen'))).toEqual({ kind: 'message', diff --git a/lib/__tests__/push-action-route.test.ts b/lib/__tests__/push-action-route.test.ts new file mode 100644 index 000000000..3658b03b2 --- /dev/null +++ b/lib/__tests__/push-action-route.test.ts @@ -0,0 +1,313 @@ +// @vitest-environment node +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { NextRequest } from 'next/server'; +import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint'; + +// POST /api/push/action: a notification's Mark read and Archive with no tab +// open. Every refusal must leave the mail alone: the worker opens the message +// in the app instead. + +vi.mock('next/headers', () => ({ cookies: async () => ({ get: () => undefined }) })); +vi.mock('@/lib/logger', () => ({ logger: { warn: vi.fn(), error: vi.fn(), debug: vi.fn(), info: vi.fn() } })); +vi.mock('@/lib/account-utils', () => ({ MAX_ACCOUNT_SLOTS: 3 })); + +type Login = { serverUrl: string; username: string; authHeader: string }; +let LOGINS: Array = []; +vi.mock('@/lib/stalwart/auth-context', () => ({ + readStalwartAuthContextFromStore: (_store: unknown, slot: number) => LOGINS[slot] ?? null, +})); + +const ALICE: Login = { serverUrl: 'https://one.example/', username: 'alice@example.org', authHeader: 'Bearer alice' }; +const BOB: Login = { serverUrl: 'https://one.example', username: 'bob@example.org', authHeader: 'Bearer bob' }; + +let policy: Record = {}; +vi.mock('@/lib/admin/config-manager', () => ({ + configManager: { ensureLoaded: async () => {}, getPolicy: () => policy }, +})); +let subject: { email: string; groups: string[] } | null = { email: 'alice@example.org', groups: [] }; +vi.mock('@/lib/admin/server-device-policy', () => ({ strictDeviceSubject: async () => subject })); + +type Calls = Array<[string, Record, string]>; +const writes: Array<{ auth: string; url: string; calls: Calls }> = []; +let session: Record; +let sessionStatus = 200; +let mailboxes: Array>; +let email: Record | null; +let emailSetResult: Record | null = null; +let sessionsAsked = 0; + +const json = (data: unknown, status = 200) => new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json' } }); +vi.mock('@/lib/stalwart/server-fetch', () => ({ + isTrustedJmapServerUrl: async () => true, + fetchJmapServer: async (url: string, init?: { headers?: Record; body?: string }) => { + if (url.endsWith('/.well-known/jmap')) { + sessionsAsked++; + return sessionStatus === 200 ? json(session) : new Response('', { status: sessionStatus }); + } + const calls = (JSON.parse(init?.body ?? '{}') as { methodCalls: Calls }).methodCalls; + if (calls.some(([name]) => name.endsWith('/set'))) { + writes.push({ auth: init?.headers?.Authorization ?? '', url, calls }); + } + return json({ + methodResponses: calls.map(([name, args, id]) => { + if (name === 'Mailbox/get') return [name, { list: mailboxes }, id]; + if (name === 'Email/get') return [name, { list: email ? [email] : [] }, id]; + if (name === 'Mailbox/set') return [name, { created: {} }, id]; + const emailId = Object.keys(args.update as Record)[0]; + return [name, emailSetResult ?? { updated: { [emailId]: null } }, id]; + }), + }); + }, +})); + +const INBOX = { id: 'inbox', name: 'Inbox', role: 'inbox' }; +const ARCHIVE = { id: 'arch', name: 'Archive', role: 'archive' }; + +async function call(body: Record, headers: Record = { 'sec-fetch-site': 'same-origin' }) { + const { POST } = await import('@/app/api/push/action/route'); + const res = await POST(new NextRequest('https://mail.example.com/api/push/action', { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...headers }, + body: JSON.stringify(body), + })); + return { status: res.status, body: await res.json() }; +} + +const fingerprint = (login: Login) => loginFingerprint(login.serverUrl, login.username); +const markRead = async (over: Record = {}) => ({ + action: 'mark-read', accountId: 'a', emailId: 'e1', login: await fingerprint(ALICE), ...over, +}); +const archive = async (over: Record = {}) => ({ ...(await markRead()), action: 'archive', ...over }); +const emailSet = () => { + const call = writes[0].calls.find(([name]) => name === 'Email/set'); + return call?.[1] as { accountId: string; update: Record> }; +}; +const mailboxSet = () => writes[0].calls.find(([name]) => name === 'Mailbox/set')?.[1] as { create: Record> } | undefined; + +beforeEach(() => { + LOGINS = [ALICE]; + policy = {}; + subject = { email: 'alice@example.org', groups: [] }; + writes.length = 0; + sessionsAsked = 0; + sessionStatus = 200; + session = { + apiUrl: 'https://one.example/jmap/', + primaryAccounts: { 'urn:ietf:params:jmap:mail': 'a' }, + accounts: { a: { name: 'alice@example.org', accountCapabilities: { 'urn:ietf:params:jmap:mail': {} } } }, + }; + mailboxes = [INBOX, ARCHIVE]; + email = { id: 'e1', receivedAt: '2026-03-31T23:30:00Z' }; + emailSetResult = null; +}); + +describe('who may ask', () => { + it('refuses a request from another site before reading anything', async () => { + const res = await call(await markRead(), { 'sec-fetch-site': 'same-site' }); + expect(res.status).toBe(403); + expect(sessionsAsked).toBe(0); + expect(writes).toHaveLength(0); + }); + + it('accepts what a service worker sends', async () => { + expect((await call(await markRead())).status).toBe(200); + expect(writes).toHaveLength(1); + }); + + it.each([ + ['an unknown action', { action: 'delete' }], + ['an id that is no JMAP id', { emailId: '../../x' }], + ['no account', { accountId: undefined }], + ['no login', { login: undefined }], + ])('refuses %s', async (_name, over) => { + const res = await call(await markRead(over)); + expect(res.status).toBe(400); + expect(sessionsAsked).toBe(0); + }); +}); + +describe('which login', () => { + it('answers 401 with no cookie, without asking the mail server', async () => { + LOGINS = []; + expect((await call(await markRead())).status).toBe(401); + expect(sessionsAsked).toBe(0); + expect(writes).toHaveLength(0); + }); + + it('answers 401 when the slot went to someone else since the notification', async () => { + // Account ids repeat, so bob's session would accept account `a` as well. + LOGINS = [BOB]; + expect((await call(await markRead())).status).toBe(401); + expect(sessionsAsked).toBe(0); + expect(writes).toHaveLength(0); + }); + + it('finds the login in whichever slot it holds now and uses its credential', async () => { + LOGINS = [BOB, null, ALICE]; + expect((await call(await markRead())).status).toBe(200); + expect(writes[0].auth).toBe('Bearer alice'); + }); + + it('answers 409 when the login is in two slots', async () => { + LOGINS = [ALICE, { ...ALICE, authHeader: 'Bearer stale' }]; + expect((await call(await markRead())).status).toBe(409); + expect(writes).toHaveLength(0); + }); + + it('answers 401 for an expired token: only a page refreshes it', async () => { + sessionStatus = 401; + expect((await call(await markRead())).status).toBe(401); + expect(writes).toHaveLength(0); + }); + + it('answers 502 when the mail server is down, which says nothing about the token', async () => { + sessionStatus = 503; + expect((await call(await markRead())).status).toBe(502); + expect(writes).toHaveLength(0); + }); + + it('refuses an account this login has no part in', async () => { + const res = await call(await markRead({ accountId: 'stranger' })); + expect(res.status).toBe(403); + expect(writes).toHaveLength(0); + }); + + it("doesn't take a name from Object.prototype for an account", async () => { + const res = await call(await markRead({ accountId: 'constructor' })); + expect(res.status).toBe(403); + expect(writes).toHaveLength(0); + }); +}); + +describe('Mark read', () => { + it('sets $seen on that message, at the rebased API address', async () => { + expect(await call(await markRead())).toEqual({ status: 200, body: { ok: true } }); + expect(writes[0].url).toBe('https://one.example/jmap/'); + expect(emailSet()).toEqual({ accountId: 'a', update: { e1: { 'keywords/$seen': true } } }); + expect(mailboxSet()).toBeUndefined(); + }); + + it('acts in a shared account the login reaches', async () => { + session.accounts = { ...(session.accounts as object), team: { name: 'team@example.org' } }; + expect((await call(await markRead({ accountId: 'team' }))).status).toBe(200); + expect(emailSet().accountId).toBe('team'); + }); + + it('answers 404 for a message that is gone', async () => { + emailSetResult = { notUpdated: { e1: { type: 'notFound' } } }; + expect((await call(await markRead())).status).toBe(404); + }); + + it('answers 502 when the server refuses', async () => { + emailSetResult = { notUpdated: { e1: { type: 'forbidden' } } }; + expect((await call(await markRead())).status).toBe(502); + }); +}); + +describe('Archive', () => { + it('moves the message into the Archive folder', async () => { + expect((await call(await archive())).status).toBe(200); + expect(emailSet().update.e1).toEqual({ mailboxIds: { arch: true } }); + expect(mailboxSet()).toBeUndefined(); + }); + + it('finds an Archive by name when no folder has the role, as the app does', async () => { + mailboxes = [INBOX, { id: 'named', name: 'archive' }]; + await call(await archive()); + expect(emailSet().update.e1).toEqual({ mailboxIds: { named: true } }); + }); + + it('creates the Archive folder when the account has none, in the same request', async () => { + mailboxes = [INBOX]; + expect((await call(await archive())).status).toBe(200); + expect(mailboxSet()?.create).toEqual({ archive: { name: 'Archive', parentId: null, role: 'archive' } }); + expect(emailSet().update.e1).toEqual({ mailboxIds: { '#archive': true } }); + }); + + it("doesn't try when the account may not create one", async () => { + mailboxes = [INBOX]; + session.accounts = { a: { accountCapabilities: { 'urn:ietf:params:jmap:mail': { mayCreateTopLevelMailbox: false } } } }; + expect((await call(await archive())).status).toBe(403); + expect(writes).toHaveLength(0); + }); + + it("doesn't try an Archive that takes no mail", async () => { + mailboxes = [INBOX, { ...ARCHIVE, myRights: { mayAddItems: false } }]; + expect((await call(await archive())).status).toBe(403); + expect(writes).toHaveLength(0); + }); + + it('files by year into an existing year folder', async () => { + mailboxes = [INBOX, ARCHIVE, { id: 'y26', name: '2026', parentId: 'arch' }]; + await call(await archive({ archiveMode: 'year', timeZone: 'UTC' })); + expect(emailSet().update.e1).toEqual({ mailboxIds: { y26: true } }); + expect(mailboxSet()).toBeUndefined(); + }); + + it('ignores a folder named like the year elsewhere', async () => { + mailboxes = [INBOX, ARCHIVE, { id: 'other', name: '2026', parentId: 'inbox' }]; + await call(await archive({ archiveMode: 'year', timeZone: 'UTC' })); + expect(mailboxSet()?.create).toEqual({ year: { name: '2026', parentId: 'arch' } }); + expect(emailSet().update.e1).toEqual({ mailboxIds: { '#year': true } }); + }); + + it("files by month in the user's time zone, not the server's", async () => { + mailboxes = [INBOX, ARCHIVE, { id: 'y26', name: '2026', parentId: 'arch' }, { id: 'm03', name: '03', parentId: 'y26' }]; + // 23:30 UTC on 31 March is 1 April in Berlin. + await call(await archive({ archiveMode: 'month', timeZone: 'Europe/Berlin' })); + expect(mailboxSet()?.create).toEqual({ month: { name: '04', parentId: 'y26' } }); + expect(emailSet().update.e1).toEqual({ mailboxIds: { '#month': true } }); + + writes.length = 0; + await call(await archive({ archiveMode: 'month', timeZone: 'UTC' })); + expect(emailSet().update.e1).toEqual({ mailboxIds: { m03: true } }); + }); + + it('creates the whole path in month mode on an account without an Archive', async () => { + mailboxes = [INBOX]; + await call(await archive({ archiveMode: 'month', timeZone: 'UTC' })); + expect(mailboxSet()?.create).toEqual({ + archive: { name: 'Archive', parentId: null, role: 'archive' }, + year: { name: '2026', parentId: '#archive' }, + month: { name: '03', parentId: '#year' }, + }); + expect(emailSet().update.e1).toEqual({ mailboxIds: { '#month': true } }); + }); + + it('survives a time zone that is no time zone', async () => { + mailboxes = [INBOX, ARCHIVE, { id: 'y26', name: '2026', parentId: 'arch' }]; + expect((await call(await archive({ archiveMode: 'year', timeZone: 'Mars/Olympus' }))).status).toBe(200); + expect(emailSet().update.e1).toEqual({ mailboxIds: { y26: true } }); + }); + + it('answers 404 for a message that is gone', async () => { + email = null; + expect((await call(await archive())).status).toBe(404); + expect(writes).toHaveLength(0); + }); + + describe('the admin gate', () => { + it('refuses when the admin turned Archive off, whatever the worker believes', async () => { + policy = { features: { archiveActionEnabled: false } }; + expect((await call(await archive())).status).toBe(403); + expect(writes).toHaveLength(0); + }); + + it('follows the scope this login is in', async () => { + policy = { scopes: [{ id: 'team', label: 'Team', match: { domains: ['example.org'] }, policy: { features: { archiveActionEnabled: false } } }] }; + expect((await call(await archive())).status).toBe(403); + expect(writes).toHaveLength(0); + }); + + it("refuses when the login's scopes can't be worked out", async () => { + subject = null; + expect((await call(await archive())).status).toBe(403); + expect(writes).toHaveLength(0); + }); + + it('leaves Mark read alone', async () => { + policy = { features: { archiveActionEnabled: false } }; + expect((await call(await markRead())).status).toBe(200); + }); + }); +}); diff --git a/lib/__tests__/push-preview-route.test.ts b/lib/__tests__/push-preview-route.test.ts index 3880595cb..a7abae5ca 100644 --- a/lib/__tests__/push-preview-route.test.ts +++ b/lib/__tests__/push-preview-route.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { endlessResponse } from './helpers/endless-body'; +import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint'; vi.mock('next/server', () => ({ NextResponse: { @@ -112,6 +113,11 @@ describe('push preview route account resolution', () => { expect(mailboxQuery?.methodCalls[0][1].accountId).toBe('g'); }); + it('names the login for a message, as the action route knows it', async () => { + const { body } = await callRoute('a'); + expect(body.login).toBe(await loginFingerprint('https://mail.example.com', undefined)); + }); + it('names the login (cookie slot) the account belongs to', async () => { // The worker only knows the JMAP account id, which can repeat across // servers; the click must open the message in this login. @@ -186,7 +192,7 @@ describe('push preview JMAP failures', () => { ['Email/get', { list: [{ id: 'd', threadId: 'td' }] }, 'delivered'], ] })); expect(await callRoute('a', 'd')).toEqual({ - status: 200, body: { email: { id: 'd', threadId: 'td' }, unreadTotal: 1, slot: 0 }, + status: 200, body: { email: { id: 'd', threadId: 'td' }, unreadTotal: 1, slot: 0, login: expect.any(String) }, }); }); }); @@ -202,7 +208,7 @@ it('previews a delivered message outside an empty Inbox', async () => { ['Email/get', { list: [{ id: 'filed', threadId: 't2' }] }, 'delivered'], ] })); expect(await callRoute('a', 'filed')).toEqual({ - status: 200, body: { email: { id: 'filed', threadId: 't2' }, unreadTotal: 1, slot: 0 }, + status: 200, body: { email: { id: 'filed', threadId: 't2' }, unreadTotal: 1, slot: 0, login: expect.any(String) }, }); }); diff --git a/lib/__tests__/sw-harness.ts b/lib/__tests__/sw-harness.ts index d54631a62..d945c3322 100644 --- a/lib/__tests__/sw-harness.ts +++ b/lib/__tests__/sw-harness.ts @@ -29,17 +29,40 @@ export class FakeCaches { } } -export function loadWorker(opts: { buildId: string; caches: FakeCaches; clients: Array<{ id: string }>; fetch?: (input: unknown) => Promise }) { +export interface ShownNotification { + title: string; + options: Record; +} + +export function loadWorker(opts: { + buildId: string; + caches: FakeCaches; + clients: Array<{ id: string }>; + fetch?: (input: unknown, init?: RequestInit) => Promise; + /** More globals of a real worker: Notification, NotificationEvent, MessageChannel. */ + globals?: Record; + openWindow?: (url: string) => Promise; + /** What showNotification does, after it was recorded in `shown`. */ + showNotification?: (title: string, options: Record) => Promise; + notifications?: Array>; +}) { const listeners = new Map void>(); + const shown: ShownNotification[] = []; let skipped = 0; const self = { location: { href: `https://mail.example.com/sw.js?v=${opts.buildId}`, origin: 'https://mail.example.com' }, addEventListener: (type: string, fn: (event: unknown) => void) => listeners.set(type, fn), skipWaiting: () => { skipped++; }, - clients: { claim: async () => {}, matchAll: async () => opts.clients }, - registration: { showNotification: async () => {}, getNotifications: async () => [] }, + clients: { claim: async () => {}, matchAll: async () => opts.clients, openWindow: opts.openWindow }, + registration: { + showNotification: async (title: string, options: Record) => { + shown.push({ title, options }); + await opts.showNotification?.(title, options); + }, + getNotifications: async () => opts.notifications ?? [], + }, }; - const context = vm.createContext({ self, caches: opts.caches, fetch: opts.fetch ?? (async () => { throw new TypeError('offline'); }), URL, URLSearchParams, Request, Response, Headers, Date, console, AbortController, setTimeout, clearTimeout }); + const context = vm.createContext({ self, caches: opts.caches, fetch: opts.fetch ?? (async () => { throw new TypeError('offline'); }), URL, URLSearchParams, Request, Response, Headers, Date, console, AbortController, setTimeout, clearTimeout, ...opts.globals }); vm.runInContext(SW_SOURCE, context); async function dispatch(type: string, event: Record = {}) { const waits: Promise[] = []; @@ -52,5 +75,5 @@ export function loadWorker(opts: { buildId: string; caches: FakeCaches; clients: listeners.get('fetch')?.({ request: { method: 'GET', mode: 'cors', ...req }, respondWith: (p: Promise) => { answer = p; } }); return answer ? await answer : null; } - return { dispatch, listeners, request, skipped: () => skipped }; + return { dispatch, listeners, request, shown, skipped: () => skipped }; } diff --git a/lib/__tests__/sw-notification-actions.test.ts b/lib/__tests__/sw-notification-actions.test.ts new file mode 100644 index 000000000..f452c79b3 --- /dev/null +++ b/lib/__tests__/sw-notification-actions.test.ts @@ -0,0 +1,363 @@ +import { describe, expect, it, vi } from 'vitest'; +import { FakeCaches, loadWorker } from './sw-harness'; + +// A notification's Archive, Mark read and Reply buttons (public/sw.js). The +// worker has no login: an open tab goes first, then the cookie route, then the +// message opens in the app. + +const ORIGIN = 'https://mail.example.com'; +const PUSH_CACHE = 'bulwark-push-state-v1'; +const PREFS = { + archiveEnabled: true, + archiveMode: 'month', + labels: { archive: 'Archivieren', markRead: 'Als gelesen markieren', reply: 'Antworten', replyPlaceholder: 'Antwort schreiben' }, +}; +const ONE_MESSAGE = { kind: 'email', emailId: 'e1', threadId: 't1', accountId: 'a', slot: 2, login: 'fp' }; +const MESSAGE_URL = '/mail/message/e1?slot=2&owner=a'; + +async function cachesWith(prefs?: unknown) { + const caches = new FakeCaches(); + if (prefs) { + const cache = await caches.open(PUSH_CACHE); + await cache.put(`${ORIGIN}/__push-state/actions`, new Response(JSON.stringify(prefs))); + } + return caches; +} + +/** A window client that answers a posted action with the given statuses. */ +function tab(id: string, statuses: string[], extra: Record = {}) { + const messages: Array> = []; + const client = { + id, + url: `${ORIGIN}/mail/folder/inbox`, + messages, + focus: vi.fn(async function focus(this: unknown) { return client; }), + navigate: vi.fn(async () => client), + postMessage(message: Record, ports?: MessagePort[]) { + messages.push(message); + if (message.kind !== 'notification-action') return; + const port = ports?.[0]; + for (const status of statuses) port?.postMessage({ status }); + }, + ...extra, + }; + return client; +} + +function worker(opts: { + caches: FakeCaches; + clients?: Array<{ id: string }>; + fetch?: (input: unknown, init?: RequestInit) => Promise; + max?: number | null; + inline?: boolean; + showNotification?: (title: string, options: Record) => Promise; + notifications?: Array>; +}) { + const openWindow = vi.fn(async () => null); + const fetch = vi.fn(opts.fetch ?? (async () => new Response('{}', { status: 200 }))); + const max = opts.max === undefined ? 3 : opts.max; + const loaded = loadWorker({ + buildId: 'b1', + caches: opts.caches, + clients: opts.clients ?? [], + fetch, + openWindow, + showNotification: opts.showNotification, + notifications: opts.notifications, + globals: { + MessageChannel, + ...(max === null ? {} : { Notification: { maxActions: max } }), + ...(opts.inline ? { NotificationEvent: class { get reply() { return null; } } } : {}), + }, + }); + const click = (action: string, extra: Record = {}, data: Record = ONE_MESSAGE) => + loaded.dispatch('notificationclick', { action, ...extra, notification: { data, tag: 'bulwark-mail:a', close() {} } }); + const posts = () => fetch.mock.calls.filter(([, init]) => init?.method === 'POST'); + return { ...loaded, click, openWindow, fetch, posts }; +} + +const pushFor = (preview: Record) => async (w: ReturnType) => { + await w.dispatch('push', { data: { json: () => ({ kind: 'jmap-email-push', accountId: 'a', emailIds: ['e1'] }) } }); + return preview; +}; + +const previewOf = (unreadTotal: number) => JSON.stringify({ + unreadTotal, + slot: 2, + login: 'fp', + email: { id: 'e1', threadId: 't1', subject: 'Hello', from: [{ name: 'Bob' }] }, +}); + +describe('the buttons a notification gets', () => { + const buttonsFor = async (opts: { prefs?: unknown; max?: number | null; inline?: boolean; unreadTotal?: number }) => { + const w = worker({ + caches: await cachesWith(opts.prefs), + max: opts.max, + inline: opts.inline, + fetch: async () => new Response(previewOf(opts.unreadTotal ?? 1), { status: 200 }), + }); + await pushFor({})(w); + expect(w.shown).toHaveLength(1); + return w.shown[0].options.actions as Array> | undefined; + }; + + it('offers the three in the labels the page left, on one message', async () => { + expect(await buttonsFor({ prefs: PREFS })).toEqual([ + { action: 'archive', title: 'Archivieren' }, + { action: 'mark-read', title: 'Als gelesen markieren' }, + { action: 'reply', title: 'Antworten' }, + ]); + }); + + it('keeps Archive and Reply where the browser has two buttons', async () => { + const buttons = await buttonsFor({ prefs: PREFS, max: 2 }); + expect(buttons?.map((b) => b.action)).toEqual(['archive', 'reply']); + }); + + it('leaves Archive out when the admin turned it off', async () => { + const buttons = await buttonsFor({ prefs: { ...PREFS, archiveEnabled: false }, max: 2 }); + expect(buttons?.map((b) => b.action)).toEqual(['mark-read', 'reply']); + }); + + it('offers no Archive, in English, before any page has spoken', async () => { + expect(await buttonsFor({})).toEqual([ + { action: 'mark-read', title: 'Mark as read' }, + { action: 'reply', title: 'Reply' }, + ]); + }); + + it('adds nothing in a browser without notification buttons', async () => { + expect(await buttonsFor({ prefs: PREFS, max: null })).toBeUndefined(); + expect(await buttonsFor({ prefs: PREFS, max: 0 })).toBeUndefined(); + }); + + it('gives the group notification none: it names no message', async () => { + expect(await buttonsFor({ prefs: PREFS, unreadTotal: 4 })).toBeUndefined(); + }); + + it('gives a message without its login none', async () => { + const w = worker({ + caches: await cachesWith(PREFS), + fetch: async () => new Response(JSON.stringify({ unreadTotal: 1, slot: 2, email: { id: 'e1', threadId: 't1' } }), { status: 200 }), + }); + await pushFor({})(w); + expect(w.shown[0].options.actions).toBeUndefined(); + }); + + it('makes Reply a text field only where the browser can hand the text back', async () => { + const buttons = await buttonsFor({ prefs: PREFS, inline: true }); + expect(buttons?.find((b) => b.action === 'reply')).toEqual({ + action: 'reply', title: 'Antworten', type: 'text', placeholder: 'Antwort schreiben', + }); + }); + + it('still shows the notification when the browser refuses the buttons', async () => { + const w = worker({ + caches: await cachesWith(PREFS), + fetch: async () => new Response(previewOf(1), { status: 200 }), + showNotification: async (_title, options) => { + if (options.actions) throw new TypeError('bad action'); + }, + }); + await pushFor({})(w); + expect(w.shown).toHaveLength(2); + expect(w.shown[1].options.actions).toBeUndefined(); + expect(w.shown[1].options.data).toMatchObject({ emailId: 'e1', login: 'fp' }); + }); + + it('keeps the buttons when a redelivered push shows the same notification again', async () => { + const existing = { title: 'Bob', body: 'Hello', data: ONE_MESSAGE }; + const caches = await cachesWith(PREFS); + const cache = await caches.open(PUSH_CACHE); + await cache.put(`${ORIGIN}/__push-state/notified/a`, new Response(JSON.stringify({ ids: ['e1'] }))); + const w = worker({ caches, notifications: [existing] }); + await pushFor({})(w); + expect(w.shown).toHaveLength(1); + expect((w.shown[0].options.actions as unknown[]).length).toBe(3); + }); +}); + +describe('a click', () => { + it('on the body works as before: it opens the message and runs nothing', async () => { + const client = tab('t', ['done']); + const w = worker({ caches: await cachesWith(PREFS), clients: [client] }); + await w.click(''); + expect(client.navigate).toHaveBeenCalledWith(MESSAGE_URL); + expect(client.messages.map((m) => m.kind)).toEqual(['notificationclick']); + expect(w.posts()).toHaveLength(0); + }); + + it('on an unknown button counts as a click on the body', async () => { + const client = tab('t', ['done']); + const w = worker({ caches: await cachesWith(PREFS), clients: [client] }); + await w.click('explode'); + expect(client.navigate).toHaveBeenCalledWith(MESSAGE_URL); + expect(w.posts()).toHaveLength(0); + }); + + it('on a button of a group notification opens the inbox and runs nothing', async () => { + const client = tab('t', ['done']); + const w = worker({ caches: await cachesWith(PREFS), clients: [client] }); + await w.click('archive', {}, { kind: 'mail-list', accountId: 'a', slot: 2 }); + expect(client.navigate).toHaveBeenCalledWith('/mail/folder/inbox?slot=2&owner=a'); + expect(w.posts()).toHaveLength(0); + }); +}); + +describe('Mark read and Archive', () => { + it('go to an open tab first, which leaves nothing else to do', async () => { + const client = tab('t', ['accepted', 'done']); + const w = worker({ caches: await cachesWith(PREFS), clients: [client] }); + await w.click('mark-read'); + expect(client.messages.find((m) => m.kind === 'notification-action')).toEqual({ + kind: 'notification-action', action: 'mark-read', emailId: 'e1', threadId: 't1', accountId: 'a', slot: 2, login: 'fp', + }); + expect(w.posts()).toHaveLength(0); + expect(client.navigate).not.toHaveBeenCalled(); + expect(client.focus).not.toHaveBeenCalled(); + expect(w.openWindow).not.toHaveBeenCalled(); + }); + + it('go to the tab in front first', async () => { + const hidden = tab('hidden', ['done'], { visibilityState: 'hidden' }); + const front = tab('front', ['done'], { focused: true, visibilityState: 'visible' }); + const w = worker({ caches: await cachesWith(PREFS), clients: [hidden, front] }); + await w.click('mark-read'); + expect(front.messages.some((m) => m.kind === 'notification-action')).toBe(true); + expect(hidden.messages.some((m) => m.kind === 'notification-action')).toBe(false); + }); + + it('try the next tab when one declines', async () => { + const other = tab('other', ['declined']); + const right = tab('right', ['done']); + const w = worker({ caches: await cachesWith(PREFS), clients: [other, right] }); + await w.click('mark-read'); + expect(right.messages.some((m) => m.kind === 'notification-action')).toBe(true); + expect(w.posts()).toHaveLength(0); + }); + + it('use the route with no tab open, and nothing opens when it works', async () => { + const w = worker({ caches: await cachesWith(PREFS) }); + await w.click('mark-read'); + expect(w.posts()).toHaveLength(1); + const [url, init] = w.posts()[0]; + expect(url).toBe('/api/push/action'); + expect(init?.credentials).toBe('include'); + expect(JSON.parse(String(init?.body))).toEqual({ action: 'mark-read', accountId: 'a', emailId: 'e1', login: 'fp' }); + expect(w.openWindow).not.toHaveBeenCalled(); + }); + + it('send the mirrored archive mode and the time zone with Archive', async () => { + const w = worker({ caches: await cachesWith(PREFS) }); + await w.click('archive'); + const body = JSON.parse(String(w.posts()[0][1]?.body)); + expect(body).toMatchObject({ action: 'archive', archiveMode: 'month', accountId: 'a', emailId: 'e1', login: 'fp' }); + expect(typeof body.timeZone).toBe('string'); + expect(body.timeZone).not.toBe(''); + }); + + it('use the route when a tab never answers, so a frozen tab costs a moment only', async () => { + const frozen = tab('frozen', []); + const w = worker({ caches: await cachesWith(PREFS), clients: [frozen] }); + await w.click('mark-read'); + expect(w.posts()).toHaveLength(1); + expect(frozen.navigate).not.toHaveBeenCalled(); + }); + + it.each([401, 403, 404, 409, 502])('open the message when the route answers %i', async (status) => { + const w = worker({ caches: await cachesWith(PREFS), fetch: async () => new Response('{}', { status }) }); + await w.click('archive'); + expect(w.posts()).toHaveLength(1); + expect(w.openWindow).toHaveBeenCalledWith(MESSAGE_URL); + }); + + it('open the message when the route is out of reach', async () => { + const w = worker({ caches: await cachesWith(PREFS), fetch: async () => { throw new TypeError('offline'); } }); + await w.click('mark-read'); + expect(w.openWindow).toHaveBeenCalledWith(MESSAGE_URL); + }); + + it('open the message when a tab with a live login tried and failed, without the route', async () => { + const client = tab('t', ['accepted', 'failed']); + const w = worker({ caches: await cachesWith(PREFS), clients: [client] }); + await w.click('archive'); + expect(w.posts()).toHaveLength(0); + expect(client.focus).toHaveBeenCalled(); + expect(client.navigate).toHaveBeenCalledWith(MESSAGE_URL); + }); + + it('never post an Archive the mirror says is off', async () => { + const w = worker({ caches: await cachesWith({ ...PREFS, archiveEnabled: false }) }); + await w.click('archive'); + expect(w.posts()).toHaveLength(0); + expect(w.openWindow).toHaveBeenCalledWith(MESSAGE_URL); + }); +}); + +describe('Reply', () => { + it('opens the composer in an open tab and brings it to the front', async () => { + const client = tab('t', ['done']); + const w = worker({ caches: await cachesWith(PREFS), clients: [client] }); + await w.click('reply', { reply: ' Thanks! ' }); + expect(client.messages.find((m) => m.kind === 'notification-action')).toMatchObject({ action: 'reply', emailId: 'e1', text: 'Thanks!' }); + expect(client.focus).toHaveBeenCalled(); + expect(client.navigate).not.toHaveBeenCalled(); + expect(w.posts()).toHaveLength(0); + }); + + it('navigates a tab that has no mail page to the reply link', async () => { + const client = tab('t', ['declined']); + const w = worker({ caches: await cachesWith(PREFS), clients: [client] }); + await w.click('reply'); + expect(client.focus).toHaveBeenCalled(); + expect(client.navigate).toHaveBeenCalledWith(`${MESSAGE_URL}&reply=1`); + }); + + it('opens a window on the reply link with no tab open, never through the route', async () => { + const w = worker({ caches: await cachesWith(PREFS) }); + await w.click('reply'); + expect(w.openWindow).toHaveBeenCalledWith(`${MESSAGE_URL}&reply=1`); + expect(w.posts()).toHaveLength(0); + }); + + it('keeps typed text in the store for the page, out of the URL', async () => { + const caches = await cachesWith(PREFS); + const w = worker({ caches }); + await w.click('reply', { reply: 'Private words' }); + expect(w.openWindow).toHaveBeenCalledWith(`${MESSAGE_URL}&reply=1`); + const cache = await caches.open(PUSH_CACHE); + const kept = await (await cache.match(`${ORIGIN}/__push-state/pending-reply`))!.json(); + expect(kept).toMatchObject({ emailId: 'e1', text: 'Private words' }); + }); + + it('leaves nothing in the store without typed text', async () => { + const caches = await cachesWith(PREFS); + const w = worker({ caches }); + await w.click('reply', { reply: ' ' }); + const cache = await caches.open(PUSH_CACHE); + expect(await cache.match(`${ORIGIN}/__push-state/pending-reply`)).toBeUndefined(); + }); +}); + +describe('Background Sync', () => { + it('tells every open tab to flush the outbox', async () => { + const one = tab('one', []); + const two = tab('two', []); + const w = worker({ caches: await cachesWith(), clients: [one, two] }); + await w.dispatch('sync', { tag: 'bulwark-outbox' }); + expect(one.messages).toEqual([{ kind: 'outbox-flush' }]); + expect(two.messages).toEqual([{ kind: 'outbox-flush' }]); + }); + + it('ignores other tags', async () => { + const one = tab('one', []); + const w = worker({ caches: await cachesWith(), clients: [one] }); + await w.dispatch('sync', { tag: 'something-else' }); + expect(one.messages).toEqual([]); + }); + + it('settles with no tab open', async () => { + const w = worker({ caches: await cachesWith(), clients: [] }); + await expect(w.dispatch('sync', { tag: 'bulwark-outbox' })).resolves.toBeUndefined(); + }); +}); diff --git a/lib/deep-links.ts b/lib/deep-links.ts index 483ad33a9..9e890e58e 100644 --- a/lib/deep-links.ts +++ b/lib/deep-links.ts @@ -137,7 +137,7 @@ const ALIASED_ROLES = new Set(['inbox', 'sent', 'drafts', 'trash', 'archive', 'j */ export type MailDeepLink = | { kind: 'folder'; ref: string; accountId?: string; slot?: number; ownerAccountId?: string } - | { kind: 'message'; id: string; accountId?: string; slot?: number; fullscreen?: boolean; ownerAccountId?: string } + | { kind: 'message'; id: string; accountId?: string; slot?: number; fullscreen?: boolean; ownerAccountId?: string; reply?: boolean } | { kind: 'thread'; id: string; accountId?: string; slot?: number; ownerAccountId?: string }; export interface MailLinkState { @@ -286,12 +286,15 @@ export function parseMailPath( // opens message links as fullscreen email tabs and ignores the flag. const fullscreen = search?.get('view') === 'fullscreen' || undefined; const ownerAccountId = search?.get('owner') || undefined; + // `?reply=1`, a notification's Reply: opens the reply composer once. Only + // opens it: nothing a link says can send or change mail. + const reply = search?.get('reply') === '1' || undefined; const [kind, value] = segments; if (kind && value) { const id = decodeSegment(value); if (id) { - if (kind === 'message') return { kind: 'message', id, accountId, slot, fullscreen, ownerAccountId }; + if (kind === 'message') return { kind: 'message', id, accountId, slot, fullscreen, ownerAccountId, ...(reply ? { reply } : {}) }; if (kind === 'thread') return { kind: 'thread', id, accountId, slot, ownerAccountId }; if (kind === 'folder') return { kind: 'folder', ref: id, accountId, slot, ownerAccountId }; } diff --git a/lib/offline/__tests__/queue-sync.test.ts b/lib/offline/__tests__/queue-sync.test.ts new file mode 100644 index 000000000..a31f5616e --- /dev/null +++ b/lib/offline/__tests__/queue-sync.test.ts @@ -0,0 +1,35 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { queuePatch, queueSend } from '../queue'; + +// Whatever goes into the queue asks for a Background Sync, once it's stored. + +const requestOutboxSync = vi.fn(); +const stored: unknown[] = []; +vi.mock('@/lib/pwa-notify/outbox-sync', () => ({ requestOutboxSync: () => requestOutboxSync(stored.length) })); +vi.mock('../db', () => ({ + idbAdd: vi.fn(async (_store: string, record: unknown) => { stored.push(record); return stored.length; }), + idbDeleteAccount: vi.fn(), idbGet: vi.fn(), idbGetAll: vi.fn(), idbGetAllByAccount: vi.fn(), idbUpdate: vi.fn(), idbWrite: vi.fn(), +})); +vi.mock('../crypto', () => ({ + sealJson: vi.fn(async () => ({ iv: 'i', data: 'd' })), + openJson: vi.fn(), openBytes: vi.fn(), sealBytes: vi.fn(), +})); + +beforeEach(() => { + stored.length = 0; + requestOutboxSync.mockClear(); +}); + +describe('the offline queue', () => { + it('asks for a sync after storing a change', async () => { + await queuePatch('acc', 'jmap', {} as never); + expect(requestOutboxSync).toHaveBeenCalledTimes(1); + expect(requestOutboxSync).toHaveBeenCalledWith(1); + }); + + it('asks for a sync after storing a message, and still returns its id', async () => { + const id = await queueSend('acc', {} as never, {} as never); + expect(id).toBe(1); + expect(requestOutboxSync).toHaveBeenCalledWith(1); + }); +}); diff --git a/lib/offline/queue.ts b/lib/offline/queue.ts index 61e53091a..4da2d5bf0 100644 --- a/lib/offline/queue.ts +++ b/lib/offline/queue.ts @@ -18,6 +18,7 @@ import { idbAdd, idbDeleteAccount, idbGet, idbGetAll, idbGetAllByAccount, idbUpdate, idbWrite } from './db'; import { openBytes, openJson, sealBytes, sealJson, type Sealed } from './crypto'; import { generateUUID } from '@/lib/utils'; +import { requestOutboxSync } from '@/lib/pwa-notify/outbox-sync'; export interface PatchItem { kind: 'patch'; @@ -104,11 +105,14 @@ interface SendPayload { export async function queuePatch(account: string, jmap: string, update: PatchItem['update']): Promise { const item: PatchItem = { kind: 'patch', jmap, update }; await idbAdd('queue', { account, kind: 'patch', createdAt: Date.now(), ...(await sealJson(item)) } satisfies QueueRecord); + requestOutboxSync(); } export async function queueSend(account: string, summary: OutboxSummary, item: SendItem, fingerprint?: PolicyFingerprint): Promise { const payload: SendPayload = { summary, item, fingerprint }; - return Number(await idbAdd('queue', { account, kind: 'send', createdAt: Date.now(), status: 'waiting', ...(await sealJson(payload)) } satisfies QueueRecord)); + const id = Number(await idbAdd('queue', { account, kind: 'send', createdAt: Date.now(), status: 'waiting', ...(await sealJson(payload)) } satisfies QueueRecord)); + requestOutboxSync(); + return id; } export interface QueuedPatch { id: number; account: string; item: PatchItem } diff --git a/lib/pwa-notify/__tests__/outbox-sync.test.ts b/lib/pwa-notify/__tests__/outbox-sync.test.ts new file mode 100644 index 000000000..0e2ae151d --- /dev/null +++ b/lib/pwa-notify/__tests__/outbox-sync.test.ts @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { requestOutboxSync } from '../outbox-sync'; + +// Background Sync where the browser has it. Elsewhere, and in Electron where +// register rejects, nothing changes and nothing throws. + +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); + +function browserWith(opts: { syncManager?: boolean; registration?: unknown; worker?: boolean }) { + if (opts.syncManager) vi.stubGlobal('SyncManager', class {}); + Object.defineProperty(navigator, 'serviceWorker', { + configurable: true, + value: opts.worker === false ? undefined : { getRegistration: async () => opts.registration }, + }); +} + +afterEach(() => { + vi.unstubAllGlobals(); + Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: undefined }); +}); + +describe('requestOutboxSync', () => { + it('registers the tag the worker listens for', async () => { + const register = vi.fn(async () => {}); + browserWith({ syncManager: true, registration: { sync: { register } } }); + requestOutboxSync(); + await settle(); + expect(register).toHaveBeenCalledWith('bulwark-outbox'); + }); + + it('does nothing in a browser without Background Sync', async () => { + const register = vi.fn(async () => {}); + browserWith({ syncManager: false, registration: { sync: { register } } }); + requestOutboxSync(); + await settle(); + expect(register).not.toHaveBeenCalled(); + }); + + it('does nothing with no worker registered', async () => { + browserWith({ syncManager: true, registration: undefined }); + expect(() => requestOutboxSync()).not.toThrow(); + await settle(); + }); + + it('does nothing without service workers', async () => { + browserWith({ syncManager: true, worker: false }); + expect(() => requestOutboxSync()).not.toThrow(); + await settle(); + }); + + it('swallows the rejection Electron answers with', async () => { + const rejected = vi.fn(); + process.on('unhandledRejection', rejected); + const register = vi.fn(async () => { throw new DOMException('Background Sync is disabled', 'UnknownError'); }); + browserWith({ syncManager: true, registration: { sync: { register } } }); + requestOutboxSync(); + await settle(); + await settle(); + process.off('unhandledRejection', rejected); + expect(register).toHaveBeenCalled(); + expect(rejected).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/pwa-notify/__tests__/page-actions.test.ts b/lib/pwa-notify/__tests__/page-actions.test.ts new file mode 100644 index 000000000..0351fcbfb --- /dev/null +++ b/lib/pwa-notify/__tests__/page-actions.test.ts @@ -0,0 +1,175 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { loginFingerprint } from '../login-fingerprint'; +import { isNotificationActionRequest, runNotificationAction, setNotificationReplyOpener, type NotificationActionRequest, type NotificationActionStatus } from '../page-actions'; + +// A notification's button in an open tab: only for the login it was shown for, +// through the store actions, with the tab's answer telling the worker what's next. + +const accounts = [ + { id: 'alice@one', cookieSlot: 0, serverUrl: 'https://one.example', username: 'alice@example.org' }, + { id: 'bob@two', cookieSlot: 1, serverUrl: 'https://two.example', username: 'bob@example.org' }, +]; +const makeClient = (accountId: string) => ({ + getAccountId: () => accountId, + getEmail: vi.fn(async (id: string) => (id === 'gone' ? null : { id, receivedAt: '2026-01-01T00:00:00Z' })), +}); +let clients: Record>; +let activeAccountId = 'alice@one'; +let archiveEnabled = true; +const batchMarkAsRead = vi.fn(async (..._args: unknown[]) => true); +const batchArchive = vi.fn(async (..._args: unknown[]) => true); + +vi.mock('@/stores/account-store', () => ({ useAccountStore: { getState: () => ({ accounts }) } })); +vi.mock('@/stores/auth-store', () => ({ + useAuthStore: { + getState: () => ({ + activeAccountId, + client: clients[activeAccountId] ?? null, + getClientForAccount: (id: string) => clients[id], + }), + }, +})); +vi.mock('@/stores/email-store', () => ({ useEmailStore: { getState: () => ({ batchMarkAsRead, batchArchive }) } })); +vi.mock('@/lib/archive-gate', () => ({ isArchiveActionEnabled: () => archiveEnabled })); + +async function run(over: Partial = {}) { + const request: NotificationActionRequest = { + action: 'mark-read', + emailId: 'e1', + accountId: 'a', + slot: 0, + login: await loginFingerprint('https://one.example', 'alice@example.org'), + ...over, + }; + const answers: NotificationActionStatus[] = []; + await runNotificationAction(request, (status) => answers.push(status)); + return answers; +} + +beforeEach(() => { + vi.clearAllMocks(); + clients = { 'alice@one': makeClient('a'), 'bob@two': makeClient('b') }; + activeAccountId = 'alice@one'; + archiveEnabled = true; + batchMarkAsRead.mockResolvedValue(true); + batchArchive.mockResolvedValue(true); +}); + +describe('which notifications a tab takes', () => { + it('declines when the slot holds someone else now', async () => { + const answers = await run({ login: await loginFingerprint('https://other.example', 'alice@example.org') }); + expect(answers).toEqual(['declined']); + expect(batchMarkAsRead).not.toHaveBeenCalled(); + }); + + it('declines a slot it has no login for', async () => { + expect(await run({ slot: 7 })).toEqual(['declined']); + }); + + it('declines without a login to compare', async () => { + expect(await run({ login: undefined })).toEqual(['declined']); + expect(batchMarkAsRead).not.toHaveBeenCalled(); + }); + + it('declines when that login is not connected in this tab', async () => { + delete clients['alice@one']; + activeAccountId = 'bob@two'; + expect(await run()).toEqual(['declined']); + }); + + it('checks a message from the worker for the shape it needs', () => { + expect(isNotificationActionRequest({ action: 'archive', emailId: 'e', accountId: 'a' })).toBe(true); + expect(isNotificationActionRequest({ action: 'delete', emailId: 'e', accountId: 'a' })).toBe(false); + expect(isNotificationActionRequest({ action: 'archive', emailId: '', accountId: 'a' })).toBe(false); + expect(isNotificationActionRequest({ action: 'archive', emailId: 'e' })).toBe(false); + expect(isNotificationActionRequest(null)).toBe(false); + }); +}); + +describe('Mark read', () => { + it('goes through the store, with the login of the notification', async () => { + expect(await run()).toEqual(['accepted', 'done']); + const [active, read, target] = batchMarkAsRead.mock.calls[0]; + expect(active).toBe(clients['alice@one']); + expect(read).toBe(true); + expect(target).toEqual({ emailIds: ['e1'], client: clients['alice@one'], accountId: undefined }); + }); + + it('reaches a login that is not the active one through its own client', async () => { + const answers = await run({ slot: 1, accountId: 'b', login: await loginFingerprint('https://two.example', 'bob@example.org') }); + expect(answers).toEqual(['accepted', 'done']); + const [active, , target] = batchMarkAsRead.mock.calls[0]; + expect(active).toBe(clients['alice@one']); + expect((target as { client: unknown }).client).toBe(clients['bob@two']); + }); + + it('names a shared account', async () => { + await run({ accountId: 'team' }); + expect((batchMarkAsRead.mock.calls[0][2] as { accountId?: string }).accountId).toBe('team'); + }); + + it('reports a failure, so the worker opens the message', async () => { + batchMarkAsRead.mockRejectedValue(new Error('forbidden')); + expect(await run()).toEqual(['accepted', 'failed']); + }); +}); + +describe('Archive', () => { + it('goes through the store with the message, which it files by date', async () => { + expect(await run({ action: 'archive' })).toEqual(['accepted', 'done']); + const [active, target] = batchArchive.mock.calls[0]; + expect(active).toBe(clients['alice@one']); + expect(target).toEqual({ + emailIds: ['e1'], + client: clients['alice@one'], + emails: [{ id: 'e1', receivedAt: '2026-01-01T00:00:00Z' }], + }); + }); + + it('declines when the admin turned it off', async () => { + archiveEnabled = false; + expect(await run({ action: 'archive' })).toEqual(['declined']); + expect(batchArchive).not.toHaveBeenCalled(); + }); + + it("leaves a shared account's Archive to the route: the store reads own folders only", async () => { + expect(await run({ action: 'archive', accountId: 'team' })).toEqual(['declined']); + expect(batchArchive).not.toHaveBeenCalled(); + }); + + it('reports a message that is gone', async () => { + expect(await run({ action: 'archive', emailId: 'gone' })).toEqual(['accepted', 'failed']); + expect(batchArchive).not.toHaveBeenCalled(); + }); + + it('reports a plugin or the store stopping it', async () => { + batchArchive.mockResolvedValue(false); + expect(await run({ action: 'archive' })).toEqual(['accepted', 'failed']); + }); +}); + +describe('Reply', () => { + it('declines on a page without the mail app', async () => { + expect(await run({ action: 'reply' })).toEqual(['declined']); + }); + + it('hands the request to the mail page, typed text included', async () => { + const opener = vi.fn(); + const remove = setNotificationReplyOpener(opener); + expect(await run({ action: 'reply', text: 'Thanks' })).toEqual(['done']); + expect(opener).toHaveBeenCalledWith({ emailId: 'e1', accountId: 'a', slot: 0, text: 'Thanks' }); + remove(); + expect(await run({ action: 'reply' })).toEqual(['declined']); + expect(opener).toHaveBeenCalledTimes(1); + }); + + it('keeps a newer opener when an older one is removed', async () => { + const remove = setNotificationReplyOpener(vi.fn()); + const current = vi.fn(); + const removeCurrent = setNotificationReplyOpener(current); + remove(); + await run({ action: 'reply' }); + expect(current).toHaveBeenCalled(); + removeCurrent(); + }); +}); diff --git a/lib/pwa-notify/__tests__/worker-state.test.ts b/lib/pwa-notify/__tests__/worker-state.test.ts new file mode 100644 index 000000000..0a175d5b7 --- /dev/null +++ b/lib/pwa-notify/__tests__/worker-state.test.ts @@ -0,0 +1,70 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { FakeCaches } from '@/lib/__tests__/sw-harness'; +import { PUSH_STATE_CACHE } from '@/lib/push-preferences'; +import { consumePendingReplyText, notificationActionPrefsKey, pendingReplyKey, writeNotificationActionPrefs } from '../worker-state'; + +let caches: FakeCaches; + +const keep = async (value: unknown) => { + const cache = await caches.open(PUSH_STATE_CACHE); + await cache.put(pendingReplyKey(window.location.origin), new Response(JSON.stringify(value))); +}; + +beforeEach(() => { + caches = new FakeCaches(); + vi.stubGlobal('caches', caches); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('the worker store', () => { + it('holds the prefs where sw.js reads them', async () => { + const prefs = { archiveEnabled: true, archiveMode: 'year' as const, labels: { archive: 'A', markRead: 'M', reply: 'R', replyPlaceholder: 'P' } }; + await writeNotificationActionPrefs(prefs); + const cache = await caches.open(PUSH_STATE_CACHE); + const stored = await cache.match(notificationActionPrefsKey(window.location.origin)); + expect(await stored!.json()).toEqual(prefs); + }); + + it('uses the same keys and cache as sw.js, which can import nothing', () => { + const worker = readFileSync(join(process.cwd(), 'public', 'sw.js'), 'utf8'); + expect(worker).toContain(`const PUSH_STATE_CACHE = "${PUSH_STATE_CACHE}"`); + expect(worker).toContain('/__push-state/actions`'); + expect(worker).toContain('/__push-state/pending-reply`'); + expect(notificationActionPrefsKey('https://x')).toBe('https://x/__push-state/actions'); + expect(pendingReplyKey('https://x')).toBe('https://x/__push-state/pending-reply'); + }); + + it('survives a browser without the Cache API', async () => { + vi.stubGlobal('caches', undefined); + await expect(writeNotificationActionPrefs({ archiveEnabled: false, archiveMode: 'single', labels: { archive: '', markRead: '', reply: '', replyPlaceholder: '' } })).resolves.toBeUndefined(); + await expect(consumePendingReplyText('e1')).resolves.toBe(''); + }); +}); + +describe('a reply typed on a notification', () => { + it('reaches the page once', async () => { + await keep({ emailId: 'e1', text: 'Thanks', at: Date.now() }); + expect(await consumePendingReplyText('e1')).toBe('Thanks'); + expect(await consumePendingReplyText('e1')).toBe(''); + }); + + it("doesn't go into a reply to another message, and is gone after", async () => { + await keep({ emailId: 'e1', text: 'Thanks', at: Date.now() }); + expect(await consumePendingReplyText('e2')).toBe(''); + expect(await consumePendingReplyText('e1')).toBe(''); + }); + + it("doesn't go into a reply much later", async () => { + await keep({ emailId: 'e1', text: 'Thanks', at: Date.now() - 6 * 60 * 1000 }); + expect(await consumePendingReplyText('e1')).toBe(''); + }); + + it('is empty when there is none', async () => { + expect(await consumePendingReplyText('e1')).toBe(''); + }); +}); diff --git a/lib/pwa-notify/login-fingerprint.ts b/lib/pwa-notify/login-fingerprint.ts new file mode 100644 index 000000000..7debefe5d --- /dev/null +++ b/lib/pwa-notify/login-fingerprint.ts @@ -0,0 +1,13 @@ +/** + * Names a login (server and user) in a notification's data, without naming + * either. A cookie slot goes to whoever signs in next, and JMAP account ids + * repeat across servers, so neither says that an action on a notification + * from yesterday still means the same mailbox. + * + * Runs in the page and on the server: both ends compare the result. + */ +export async function loginFingerprint(serverUrl: string, username: string | undefined): Promise { + const text = `${serverUrl.replace(/\/+$/, '')}\n${(username ?? '').trim().toLowerCase()}`; + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text)); + return Array.from(new Uint8Array(digest).slice(0, 16), (byte) => byte.toString(16).padStart(2, '0')).join(''); +} diff --git a/lib/pwa-notify/outbox-sync.ts b/lib/pwa-notify/outbox-sync.ts new file mode 100644 index 000000000..4e2fd8db4 --- /dev/null +++ b/lib/pwa-notify/outbox-sync.ts @@ -0,0 +1,24 @@ +/** Same tag as sw.js's OUTBOX_SYNC_TAG. */ +const OUTBOX_SYNC_TAG = 'bulwark-outbox'; + +/** + * Something went into the outbox: asks the browser to wake the service worker + * when the network is back, which tells the open tabs to flush. Only a tab + * can flush (the worker has no login), so this helps a tab that missed the + * `online` event. + * + * Chromium only. Without it, or without a worker (Lite, development), or in + * Electron, where register rejects, the outbox flushes as it always did. + */ +export function requestOutboxSync(): void { + void (async () => { + try { + if (typeof window === 'undefined' || !('SyncManager' in window) || !navigator.serviceWorker) return; + const registration = await navigator.serviceWorker.getRegistration(); + const sync = (registration as (ServiceWorkerRegistration & { sync?: { register(tag: string): Promise } }) | undefined)?.sync; + await sync?.register(OUTBOX_SYNC_TAG); + } catch { + // Nothing to do: the outbox doesn't depend on this. + } + })(); +} diff --git a/lib/pwa-notify/page-actions.ts b/lib/pwa-notify/page-actions.ts new file mode 100644 index 000000000..43cc96f95 --- /dev/null +++ b/lib/pwa-notify/page-actions.ts @@ -0,0 +1,121 @@ +import { isArchiveActionEnabled } from '@/lib/archive-gate'; +import { useAccountStore } from '@/stores/account-store'; +import { useAuthStore } from '@/stores/auth-store'; +import { useEmailStore } from '@/stores/email-store'; +import { loginFingerprint } from './login-fingerprint'; + +/** What the service worker posts when a notification's button is clicked (public/sw.js). */ +export interface NotificationActionRequest { + action: 'archive' | 'mark-read' | 'reply'; + emailId: string; + accountId: string; + threadId?: string; + slot?: number; + login?: string; + /** A reply typed on the notification, where the browser has a text field there. */ + text?: string; +} + +/** + * `declined`: not for this tab, the worker tries the next tab or the route. + * `accepted`: this tab is on it. `done` and `failed`: how it ended. + */ +export type NotificationActionStatus = 'declined' | 'accepted' | 'done' | 'failed'; + +export interface NotificationReplyRequest { + emailId: string; + accountId: string; + slot: number; + text?: string; +} + +type ReplyOpener = (request: NotificationReplyRequest) => void; + +let replyOpener: ReplyOpener | null = null; + +/** The mail page opens replies. On other pages the worker navigates to it instead. */ +export function setNotificationReplyOpener(opener: ReplyOpener): () => void { + replyOpener = opener; + return () => { + if (replyOpener === opener) replyOpener = null; + }; +} + +export function isNotificationActionRequest(value: unknown): value is NotificationActionRequest { + const request = value as Partial | null; + return !!request + && (request.action === 'archive' || request.action === 'mark-read' || request.action === 'reply') + && typeof request.emailId === 'string' && !!request.emailId + && typeof request.accountId === 'string' && !!request.accountId; +} + +/** The login the notification is for, if this tab has it signed in right now. */ +async function loginOf(request: NotificationActionRequest) { + const entry = useAccountStore.getState().accounts.find((account) => account.cookieSlot === request.slot); + if (!entry || !request.login) return null; + if ((await loginFingerprint(entry.serverUrl, entry.username)) !== request.login) return null; + const auth = useAuthStore.getState(); + const client = auth.getClientForAccount(entry.id) ?? (entry.id === auth.activeAccountId ? auth.client : null); + return client ? { client } : null; +} + +/** + * Runs a notification's button in this tab, through the same store actions as + * the app's own buttons: plugin hooks, archive mode and the Archive gate all + * apply, and the list on screen follows. + */ +export async function runNotificationAction( + request: NotificationActionRequest, + answer: (status: NotificationActionStatus) => void, +): Promise { + try { + const login = await loginOf(request); + const activeClient = useAuthStore.getState().client; + if (!login || !activeClient) { + answer('declined'); + return; + } + if (request.action === 'reply') { + if (!replyOpener || request.slot === undefined) { + answer('declined'); + return; + } + replyOpener({ emailId: request.emailId, accountId: request.accountId, slot: request.slot, text: request.text }); + answer('done'); + return; + } + + const own = request.accountId === login.client.getAccountId(); + if (request.action === 'mark-read') { + answer('accepted'); + await useEmailStore.getState().batchMarkAsRead(activeClient, true, { + emailIds: [request.emailId], + client: login.client, + accountId: own ? undefined : request.accountId, + }); + answer('done'); + return; + } + + // The store's targeted archive reads a login's own folders only. A shared + // account's Archive is the route's to find. + if (!own || !isArchiveActionEnabled()) { + answer('declined'); + return; + } + answer('accepted'); + const email = await login.client.getEmail(request.emailId); + if (!email) { + answer('failed'); + return; + } + const archived = await useEmailStore.getState().batchArchive(activeClient, { + emailIds: [request.emailId], + client: login.client, + emails: [email], + }); + answer(archived ? 'done' : 'failed'); + } catch { + answer('failed'); + } +} diff --git a/lib/pwa-notify/worker-state.ts b/lib/pwa-notify/worker-state.ts new file mode 100644 index 000000000..0fb48cdb6 --- /dev/null +++ b/lib/pwa-notify/worker-state.ts @@ -0,0 +1,58 @@ +import { PUSH_STATE_CACHE } from '@/lib/push-preferences'; + +/** + * What the service worker needs for a notification's buttons. It has no + * settings store and no translations, so the page mirrors these into the + * worker's Cache store, as it does the sound setting (lib/push-preferences.ts). + * Signing out deletes the store with the rest of the push state. + */ +export interface NotificationActionPrefs { + archiveEnabled: boolean; + archiveMode: 'single' | 'year' | 'month'; + labels: { archive: string; markRead: string; reply: string; replyPlaceholder: string }; +} + +const BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? '').replace(/\/+$/, ''); + +/** A reply typed on a notification waits this long for the page it opened. */ +const PENDING_REPLY_MAX_AGE_MS = 5 * 60 * 1000; + +/** The cache key sw.js reads the prefs from. */ +export function notificationActionPrefsKey(origin: string): string { + return `${origin}${BASE_PATH}/__push-state/actions`; +} + +/** The cache key sw.js keeps a reply typed on a notification under. */ +export function pendingReplyKey(origin: string): string { + return `${origin}${BASE_PATH}/__push-state/pending-reply`; +} + +export async function writeNotificationActionPrefs(prefs: NotificationActionPrefs): Promise { + try { + if (typeof caches === 'undefined') return; + const cache = await caches.open(PUSH_STATE_CACHE); + await cache.put( + notificationActionPrefsKey(window.location.origin), + new Response(JSON.stringify(prefs), { headers: { 'Content-Type': 'application/json' } }), + ); + } catch { + // Without the store the worker offers no Archive, in English. + } +} + +/** The text typed on this message's notification, once. */ +export async function consumePendingReplyText(emailId: string): Promise { + try { + if (typeof caches === 'undefined') return ''; + const cache = await caches.open(PUSH_STATE_CACHE); + const key = pendingReplyKey(window.location.origin); + const res = await cache.match(key); + if (!res) return ''; + const pending = (await res.json()) as { emailId?: unknown; text?: unknown; at?: unknown }; + await cache.delete(key); + const fresh = typeof pending.at === 'number' && Date.now() - pending.at < PENDING_REPLY_MAX_AGE_MS; + return fresh && pending.emailId === emailId && typeof pending.text === 'string' ? pending.text : ''; + } catch { + return ''; + } +} diff --git a/locales/ar/common.json b/locales/ar/common.json index cc8420d14..a1cb16ab2 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -6350,6 +6350,12 @@ "sign_language": "لغة الإشارة", "new_tab": "يُفتح في علامة تبويب جديدة" }, + "pwaNotify": { + "action_archive": "أرشفة", + "action_mark_read": "تمييز كمقروءة", + "action_reply": "رد", + "reply_placeholder": "اكتب ردًا" + }, "structured_mail_calendar": { "add_to_calendar": "إضافة إلى التقويم", "update_in_calendar": "تحديث في التقويم", diff --git a/locales/ca/common.json b/locales/ca/common.json index 6e15f53fc..9458ade33 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -6350,6 +6350,12 @@ "sign_language": "Llengua de signes", "new_tab": "s'obre en una pestanya nova" }, + "pwaNotify": { + "action_archive": "Arxiva", + "action_mark_read": "Marca com a llegit", + "action_reply": "Respon", + "reply_placeholder": "Escriu una resposta" + }, "structured_mail_calendar": { "add_to_calendar": "Afegeix al calendari", "update_in_calendar": "Actualitza al calendari", diff --git a/locales/cs/common.json b/locales/cs/common.json index d8426dce2..aaeebd622 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -6350,6 +6350,12 @@ "sign_language": "Znakový jazyk", "new_tab": "otevře se na nové kartě" }, + "pwaNotify": { + "action_archive": "Archivovat", + "action_mark_read": "Označit jako přečtené", + "action_reply": "Odpovědět", + "reply_placeholder": "Napište odpověď" + }, "structured_mail_calendar": { "add_to_calendar": "Přidat do kalendáře", "update_in_calendar": "Aktualizovat v kalendáři", diff --git a/locales/da/common.json b/locales/da/common.json index cf66a7294..8e5180c10 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -6350,6 +6350,12 @@ "sign_language": "Tegnsprog", "new_tab": "åbner i en ny fane" }, + "pwaNotify": { + "action_archive": "Arkivér", + "action_mark_read": "Markér som læst", + "action_reply": "Svar", + "reply_placeholder": "Skriv et svar" + }, "structured_mail_calendar": { "add_to_calendar": "Tilføj til kalender", "update_in_calendar": "Opdater i kalender", diff --git a/locales/de/common.json b/locales/de/common.json index 3772dbab8..d24c812bc 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -6350,6 +6350,12 @@ "sign_language": "Gebärdensprache", "new_tab": "öffnet in einem neuen Tab" }, + "pwaNotify": { + "action_archive": "Archivieren", + "action_mark_read": "Als gelesen markieren", + "action_reply": "Antworten", + "reply_placeholder": "Antwort schreiben" + }, "structured_mail_calendar": { "add_to_calendar": "Zum Kalender hinzufügen", "update_in_calendar": "Im Kalender aktualisieren", diff --git a/locales/en/common.json b/locales/en/common.json index 57a58dab4..e43553012 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -6350,6 +6350,12 @@ "sign_language": "Sign language", "new_tab": "opens in a new tab" }, + "pwaNotify": { + "action_archive": "Archive", + "action_mark_read": "Mark as read", + "action_reply": "Reply", + "reply_placeholder": "Write a reply" + }, "structured_mail_calendar": { "add_to_calendar": "Add to calendar", "update_in_calendar": "Update in calendar", diff --git a/locales/es/common.json b/locales/es/common.json index cc786a268..4dc8c717f 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -6350,6 +6350,12 @@ "sign_language": "Lengua de signos", "new_tab": "se abre en una pestaña nueva" }, + "pwaNotify": { + "action_archive": "Archivar", + "action_mark_read": "Marcar como leído", + "action_reply": "Responder", + "reply_placeholder": "Escribe una respuesta" + }, "structured_mail_calendar": { "add_to_calendar": "Añadir al calendario", "update_in_calendar": "Actualizar en el calendario", diff --git a/locales/et/common.json b/locales/et/common.json index 2743f7f83..df266cdb2 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -6350,6 +6350,12 @@ "sign_language": "Viipekeel", "new_tab": "avaneb uuel vahekaardil" }, + "pwaNotify": { + "action_archive": "Arhiveeri", + "action_mark_read": "Märgi loetuks", + "action_reply": "Vasta", + "reply_placeholder": "Kirjuta vastus" + }, "structured_mail_calendar": { "add_to_calendar": "Lisa kalendrisse", "update_in_calendar": "Uuenda kalendris", diff --git a/locales/fa/common.json b/locales/fa/common.json index 539f81c1a..20f3bd264 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -6350,6 +6350,12 @@ "sign_language": "زبان اشاره", "new_tab": "در زبانه‌ای تازه باز می‌شود" }, + "pwaNotify": { + "action_archive": "بایگانی", + "action_mark_read": "علامت‌گذاری خوانده شده", + "action_reply": "پاسخ", + "reply_placeholder": "پاسخ بنویسید" + }, "structured_mail_calendar": { "add_to_calendar": "افزودن به تقویم", "update_in_calendar": "به‌روزرسانی در تقویم", diff --git a/locales/fr/common.json b/locales/fr/common.json index fb7d148ec..019a6c00c 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -6350,6 +6350,12 @@ "sign_language": "Langue des signes", "new_tab": "s'ouvre dans un nouvel onglet" }, + "pwaNotify": { + "action_archive": "Archiver", + "action_mark_read": "Marquer comme lu", + "action_reply": "Répondre", + "reply_placeholder": "Écrire une réponse" + }, "structured_mail_calendar": { "add_to_calendar": "Ajouter au calendrier", "update_in_calendar": "Mettre à jour dans le calendrier", diff --git a/locales/he/common.json b/locales/he/common.json index 73d75426a..52650b64a 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -6351,6 +6351,12 @@ "sign_language": "שפת סימנים", "new_tab": "נפתח בכרטיסייה חדשה" }, + "pwaNotify": { + "action_archive": "ארכיון", + "action_mark_read": "סמן כנקרא", + "action_reply": "השב", + "reply_placeholder": "כתיבת תשובה" + }, "structured_mail_calendar": { "add_to_calendar": "הוסף ליומן", "update_in_calendar": "עדכן ביומן", diff --git a/locales/hu/common.json b/locales/hu/common.json index b45fe495e..22c05e7e8 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -6350,6 +6350,12 @@ "sign_language": "Jelnyelv", "new_tab": "új lapon nyílik meg" }, + "pwaNotify": { + "action_archive": "Archiválás", + "action_mark_read": "Megjelölés olvasottként", + "action_reply": "Válasz", + "reply_placeholder": "Válasz írása" + }, "structured_mail_calendar": { "add_to_calendar": "Hozzáadás a naptárhoz", "update_in_calendar": "Frissítés a naptárban", diff --git a/locales/it/common.json b/locales/it/common.json index a021b7397..3b87ba410 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -6350,6 +6350,12 @@ "sign_language": "Lingua dei segni", "new_tab": "si apre in una nuova scheda" }, + "pwaNotify": { + "action_archive": "Archivia", + "action_mark_read": "Segna come letto", + "action_reply": "Rispondi", + "reply_placeholder": "Scrivi una risposta" + }, "structured_mail_calendar": { "add_to_calendar": "Aggiungi al calendario", "update_in_calendar": "Aggiorna nel calendario", diff --git a/locales/ja/common.json b/locales/ja/common.json index f2ea41909..4833b9215 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -6350,6 +6350,12 @@ "sign_language": "手話", "new_tab": "新しいタブで開きます" }, + "pwaNotify": { + "action_archive": "アーカイブ", + "action_mark_read": "既読にする", + "action_reply": "返信", + "reply_placeholder": "返信を入力" + }, "structured_mail_calendar": { "add_to_calendar": "カレンダーに追加", "update_in_calendar": "カレンダーを更新", diff --git a/locales/ko/common.json b/locales/ko/common.json index cbde91d32..f403b389e 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -6350,6 +6350,12 @@ "sign_language": "수어", "new_tab": "새 탭에서 열림" }, + "pwaNotify": { + "action_archive": "보관", + "action_mark_read": "읽은 상태로 표시", + "action_reply": "답장", + "reply_placeholder": "답장 쓰기" + }, "structured_mail_calendar": { "add_to_calendar": "캘린더에 추가", "update_in_calendar": "캘린더에서 업데이트", diff --git a/locales/lv/common.json b/locales/lv/common.json index 02d30c2f9..5ee617996 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -6350,6 +6350,12 @@ "sign_language": "Zīmju valoda", "new_tab": "atveras jaunā cilnē" }, + "pwaNotify": { + "action_archive": "Arhivēt", + "action_mark_read": "Atzīmēt kā izlasītu", + "action_reply": "Atbildēt", + "reply_placeholder": "Rakstiet atbildi" + }, "structured_mail_calendar": { "add_to_calendar": "Pievienot kalendāram", "update_in_calendar": "Atjaunināt kalendārā", diff --git a/locales/mn/common.json b/locales/mn/common.json index 4dd86dea2..bd940ff26 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -6350,6 +6350,12 @@ "sign_language": "Дохионы хэл", "new_tab": "шинэ табад нээгдэнэ" }, + "pwaNotify": { + "action_archive": "Архив", + "action_mark_read": "Уншсан гэж тэмдэглэх", + "action_reply": "Хариулах", + "reply_placeholder": "Хариу бичих" + }, "structured_mail_calendar": { "add_to_calendar": "Хуанлид нэмэх", "update_in_calendar": "Хуанлид шинэчлэх", diff --git a/locales/nb/common.json b/locales/nb/common.json index d98bd1756..1362f1e01 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -6350,6 +6350,12 @@ "sign_language": "Tegnspråk", "new_tab": "åpnes i en ny fane" }, + "pwaNotify": { + "action_archive": "Arkiver", + "action_mark_read": "Merk som lest", + "action_reply": "Svar", + "reply_placeholder": "Skriv et svar" + }, "structured_mail_calendar": { "add_to_calendar": "Legg til i kalenderen", "update_in_calendar": "Oppdater i kalenderen", diff --git a/locales/nl/common.json b/locales/nl/common.json index 4d541f358..d6694f868 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -6350,6 +6350,12 @@ "sign_language": "Gebarentaal", "new_tab": "opent in een nieuw tabblad" }, + "pwaNotify": { + "action_archive": "Archiveren", + "action_mark_read": "Markeren als gelezen", + "action_reply": "Beantwoorden", + "reply_placeholder": "Schrijf een antwoord" + }, "structured_mail_calendar": { "add_to_calendar": "Toevoegen aan agenda", "update_in_calendar": "Bijwerken in agenda", diff --git a/locales/pl/common.json b/locales/pl/common.json index a6a7cd520..45e0db29b 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -6350,6 +6350,12 @@ "sign_language": "Język migowy", "new_tab": "otwiera się w nowej karcie" }, + "pwaNotify": { + "action_archive": "Archiwizuj", + "action_mark_read": "Oznacz jako przeczytane", + "action_reply": "Odpowiedz", + "reply_placeholder": "Napisz odpowiedź" + }, "structured_mail_calendar": { "add_to_calendar": "Dodaj do kalendarza", "update_in_calendar": "Zaktualizuj w kalendarzu", diff --git a/locales/pt/common.json b/locales/pt/common.json index 685a98aea..7c5723df2 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -6350,6 +6350,12 @@ "sign_language": "Língua de sinais", "new_tab": "abre em uma nova aba" }, + "pwaNotify": { + "action_archive": "Arquivar", + "action_mark_read": "Marcar como lido", + "action_reply": "Responder", + "reply_placeholder": "Escreva uma resposta" + }, "structured_mail_calendar": { "add_to_calendar": "Adicionar ao calendário", "update_in_calendar": "Atualizar no calendário", diff --git a/locales/ro/common.json b/locales/ro/common.json index 45c3fefed..e62685ff1 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -6350,6 +6350,12 @@ "sign_language": "Limbaj mimico-gestual", "new_tab": "se deschide într-o filă nouă" }, + "pwaNotify": { + "action_archive": "Arhivează", + "action_mark_read": "Marcați ca citit", + "action_reply": "Răspunde", + "reply_placeholder": "Scrieți un răspuns" + }, "structured_mail_calendar": { "add_to_calendar": "Adaugă în calendar", "update_in_calendar": "Actualizează în calendar", diff --git a/locales/ru/common.json b/locales/ru/common.json index b40069b61..ed4a27405 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -6350,6 +6350,12 @@ "sign_language": "Жестовый язык", "new_tab": "открывается в новой вкладке" }, + "pwaNotify": { + "action_archive": "Архивировать", + "action_mark_read": "Отметить прочитанным", + "action_reply": "Ответить", + "reply_placeholder": "Напишите ответ" + }, "structured_mail_calendar": { "add_to_calendar": "Добавить в календарь", "update_in_calendar": "Обновить в календаре", diff --git a/locales/sk/common.json b/locales/sk/common.json index aa0304452..5d154e4c3 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -6350,6 +6350,12 @@ "sign_language": "Posunkový jazyk", "new_tab": "otvorí sa na novej karte" }, + "pwaNotify": { + "action_archive": "Archivovať", + "action_mark_read": "Označiť ako prečítané", + "action_reply": "Odpovedať", + "reply_placeholder": "Napíšte odpoveď" + }, "structured_mail_calendar": { "add_to_calendar": "Pridať do kalendára", "update_in_calendar": "Aktualizovať v kalendári", diff --git a/locales/th/common.json b/locales/th/common.json index 4d2c52754..ec2e1774f 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -6358,6 +6358,12 @@ "sign_language": "ภาษามือ", "new_tab": "เปิดในแท็บใหม่" }, + "pwaNotify": { + "action_archive": "เก็บถาวร", + "action_mark_read": "ทำเครื่องหมายว่าอ่านแล้ว", + "action_reply": "ตอบกลับ", + "reply_placeholder": "เขียนคำตอบ" + }, "structured_mail_calendar": { "add_to_calendar": "เพิ่มลงในปฏิทิน", "update_in_calendar": "อัปเดตในปฏิทิน", diff --git a/locales/tr/common.json b/locales/tr/common.json index 4f25997fa..eeebfa2c1 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -6350,6 +6350,12 @@ "sign_language": "İşaret dili", "new_tab": "yeni sekmede açılır" }, + "pwaNotify": { + "action_archive": "Arşivle", + "action_mark_read": "Okundu olarak işaretle", + "action_reply": "Yanıtla", + "reply_placeholder": "Yanıt yazın" + }, "structured_mail_calendar": { "add_to_calendar": "Takvime ekle", "update_in_calendar": "Takvimde güncelle", diff --git a/locales/uk/common.json b/locales/uk/common.json index 408e920da..a74e6aef0 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -6350,6 +6350,12 @@ "sign_language": "Жестова мова", "new_tab": "відкривається в новій вкладці" }, + "pwaNotify": { + "action_archive": "Архів", + "action_mark_read": "Позначити як прочитане", + "action_reply": "Відповісти", + "reply_placeholder": "Напишіть відповідь" + }, "structured_mail_calendar": { "add_to_calendar": "Додати в календар", "update_in_calendar": "Оновити в календарі", diff --git a/locales/vi/common.json b/locales/vi/common.json index 6952aec2e..583d0db87 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -6362,6 +6362,12 @@ "sign_language": "Ngôn ngữ ký hiệu", "new_tab": "Mở trong một tab mới" }, + "pwaNotify": { + "action_archive": "Lưu trữ", + "action_mark_read": "Đánh dấu là đã đọc", + "action_reply": "Trả lời", + "reply_placeholder": "Viết trả lời" + }, "structured_mail_calendar": { "add_to_calendar": "Thêm vào lịch", "update_in_calendar": "Cập nhật trong lịch", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 1236173cf..5b52b6b63 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -6350,6 +6350,12 @@ "sign_language": "手語", "new_tab": "在新分頁中開啟" }, + "pwaNotify": { + "action_archive": "封存", + "action_mark_read": "標示為已讀", + "action_reply": "回覆", + "reply_placeholder": "撰寫回覆" + }, "structured_mail_calendar": { "add_to_calendar": "新增到行事曆", "update_in_calendar": "在行事曆中更新", diff --git a/locales/zh/common.json b/locales/zh/common.json index f51d10c28..c306c55cf 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -6350,6 +6350,12 @@ "sign_language": "手语", "new_tab": "在新标签页中打开" }, + "pwaNotify": { + "action_archive": "归档", + "action_mark_read": "标记为已读", + "action_reply": "回复", + "reply_placeholder": "撰写回复" + }, "structured_mail_calendar": { "add_to_calendar": "添加到日历", "update_in_calendar": "在日历中更新", diff --git a/public/sw.js b/public/sw.js index 3dc7ff7b8..8ce60bb8b 100644 --- a/public/sw.js +++ b/public/sw.js @@ -337,6 +337,14 @@ self.addEventListener("pushsubscriptionchange", (event) => { event.waitUntil(handlePushSubscriptionChange(event)); }); +// Background Sync, thin: this worker has no login and can't reach the outbox, +// so it tells the open tabs to flush theirs. With no tab open, the next one +// flushes when it starts. +self.addEventListener("sync", (event) => { + if (event.tag !== OUTBOX_SYNC_TAG) return; + event.waitUntil(tellClientsToFlushOutbox()); +}); + self.addEventListener("message", (event) => { const data = event.data || {}; if (data.type === "client-build") { @@ -486,6 +494,9 @@ async function handlePush(event) { // The login (cookie slot) that owns this account, so the click opens the // message there and not in whichever account happens to be active. const slot = preview && Number.isInteger(preview.slot) ? preview.slot : undefined; + // Which login that slot held when the message came: an action on this + // notification must not land on whoever signed into the slot since. + const login = preview && typeof preview.login === "string" ? preview.login : undefined; // Push subscription is scoped to EmailDelivery, but stragglers from the // older broader-types subscription, marking-as-read races and verification @@ -521,7 +532,7 @@ async function handlePush(event) { // (Organisation > Devices): the route sent neither. title = accountLabel ? `New mail (${accountLabel})` : "New mail"; body = unreadTotal > 1 ? `${unreadTotal} unread messages` : "You have new mail"; - if (unreadTotal <= 1) data = { kind: "email", emailId: email.id, threadId: email.threadId, accountId, slot }; + if (unreadTotal <= 1) data = { kind: "email", emailId: email.id, threadId: email.threadId, accountId, slot, login }; } else if (email) { const sender = email.from && email.from[0]; const senderName = (sender && sender.name) || (sender && sender.email) || "New mail"; @@ -534,7 +545,7 @@ async function handlePush(event) { body += "\n" + (more === 1 ? "+1 more message" : `+${more} more messages`); } else { // Exactly one unread: deep-link straight to that message on click. - data = { kind: "email", emailId: email.id, threadId: email.threadId, accountId, slot }; + data = { kind: "email", emailId: email.id, threadId: email.threadId, accountId, slot, login }; } } else { title = accountLabel ? `New mail (${accountLabel})` : "New mail"; @@ -543,7 +554,7 @@ async function handlePush(event) { // With the sound setting off, nothing rings and no quiet window starts. const audible = (await soundPreference()) && !(await withinQuietWindow(accountId)); - await self.registration.showNotification(title, { + await showWithActions(title, { body, // Shared per-account tag: each new push replaces the account's single // notification rather than adding another. @@ -587,7 +598,7 @@ async function showWithoutAlert(groupTag) { try { const existing = (await self.registration.getNotifications({ tag: groupTag }))[0]; if (existing) { - await self.registration.showNotification(existing.title, { + await showWithActions(existing.title, { ...options, body: existing.body, data: existing.data, @@ -732,7 +743,7 @@ async function handleNotificationClick(event) { return handleMailtoFocusNotificationClick(); } - const targetUrl = buildClickUrl(data); + let targetUrl = buildClickUrl(data); const allClients = await self.clients.matchAll({ type: "window", @@ -748,6 +759,14 @@ async function handleNotificationClick(event) { } } + // A button on the notification. When it can't be done from here, the click + // carries on below and opens the message, like a click on the body. + const action = notificationActionOf(event, data); + if (action) { + if (await runNotificationAction(action, data, event.reply, allClients)) return; + if (action === "reply") targetUrl = buildClickUrl(data, true); + } + for (const client of allClients) { // Reuse an existing tab whenever possible - users on desktop browsers // get annoyed when each notification opens a fresh window. @@ -920,11 +939,12 @@ function getReusableClientScore(state) { return 3; } -function buildClickUrl(data) { +function buildClickUrl(data, reply) { if (!data) return `${BASE_PATH}/`; const params = new URLSearchParams(); if (Number.isInteger(data.slot)) params.set("slot", String(data.slot)); if (typeof data.accountId === "string" && data.accountId) params.set("owner", data.accountId); + if (reply) params.set("reply", "1"); // Not params.size: Safari before 17 (iOS 16.4+ web push) lacks it. const qs = params.toString(); const targetQuery = qs ? `?${qs}` : ""; @@ -941,3 +961,238 @@ function buildClickUrl(data) { // app shell, so the click still feels purposeful. return `${BASE_PATH}/?openLatestUnread=1`; } + +// ---- Notification actions: Archive, Mark read, Reply ---- +// +// A worker has no login. An open tab has one, so it goes first. With none, a +// same-origin route acts with the login's context cookie (app/api/push/action). +// That cookie goes with the browser session and holds a token only a page +// refreshes, so the route can fail; the message then opens in the app. + +const NOTIFICATION_ACTIONS = ["archive", "mark-read", "reply"]; +// How long a tab has to take an action. A frozen tab doesn't answer. +const CLIENT_TAKE_MS = 1_000; +const CLIENTS_TAKE_TOTAL_MS = 2_500; +// How long a tab that took it has before this worker stops waiting for the outcome. +const CLIENT_RESULT_MS = 5_000; +const ACTION_ROUTE_DEADLINE_MS = 6_000; +// All of the above stay inside the 10 s a browser allows openWindow and focus +// after a notification click, so the fallback can still open the message. + +const OUTBOX_SYNC_TAG = "bulwark-outbox"; + +function notificationActionPrefsKey() { + return `${self.location.origin}${BASE_PATH}/__push-state/actions`; +} + +function pendingReplyKey() { + return `${self.location.origin}${BASE_PATH}/__push-state/pending-reply`; +} + +/** + * What the page mirrors into this store (lib/pwa-notify/worker-state.ts): the + * labels in the user's language, Archive on or off, how Archive files mail. + * Nothing stored: no Archive button, since the admin may have turned it off. + */ +async function readNotificationActionPrefs() { + let stored = null; + try { + const cache = await caches.open(PUSH_STATE_CACHE); + const res = await cache.match(notificationActionPrefsKey()); + stored = res ? await res.json() : null; + } catch (_) { + stored = null; + } + const labels = (stored && stored.labels) || {}; + return { + archiveEnabled: !!stored && stored.archiveEnabled === true, + archiveMode: stored && typeof stored.archiveMode === "string" ? stored.archiveMode : "single", + labels: { + archive: labels.archive || "Archive", + markRead: labels.markRead || "Mark as read", + reply: labels.reply || "Reply", + replyPlaceholder: labels.replyPlaceholder || "Write a reply", + }, + }; +} + +function maxNotificationActions() { + if (typeof Notification === "undefined") return 0; + return Number.isInteger(Notification.maxActions) ? Notification.maxActions : 0; +} + +function inlineReplySupported() { + return typeof NotificationEvent !== "undefined" && "reply" in NotificationEvent.prototype; +} + +/** The buttons for a notification. Only one message, with its login known, gets any. */ +async function notificationActions(data) { + if (!data || data.kind !== "email" || !data.emailId || !data.accountId || !data.login) return []; + const max = maxNotificationActions(); + if (max < 1) return []; + const prefs = await readNotificationActionPrefs(); + const offered = { + "mark-read": { action: "mark-read", title: prefs.labels.markRead }, + reply: inlineReplySupported() + ? { action: "reply", title: prefs.labels.reply, type: "text", placeholder: prefs.labels.replyPlaceholder } + : { action: "reply", title: prefs.labels.reply }, + }; + if (prefs.archiveEnabled) offered.archive = { action: "archive", title: prefs.labels.archive }; + // Chromium allows two. Mark read goes first then: opening the message does + // that too, and nothing else on the toast archives or starts a reply. + const kept = ["archive", "reply", "mark-read"].filter((name) => offered[name]).slice(0, max); + return NOTIFICATION_ACTIONS.filter((name) => kept.includes(name)).map((name) => offered[name]); +} + +/** A notification must show, whatever a browser thinks of its buttons. */ +async function showWithActions(title, options) { + const actions = await notificationActions(options.data); + if (actions.length > 0) { + try { + await self.registration.showNotification(title, { ...options, actions }); + return; + } catch (_) { + // Shown without buttons below. + } + } + await self.registration.showNotification(title, options); +} + +function notificationActionOf(event, data) { + if (!NOTIFICATION_ACTIONS.includes(event.action)) return null; + return data.kind === "email" && data.emailId && data.accountId ? event.action : null; +} + +/** Asks one tab. Resolves "done", "failed" (it tried), or "declined" (it didn't take it). */ +function askClient(client, message) { + return new Promise((resolve) => { + if (typeof MessageChannel !== "function") { + resolve("declined"); + return; + } + const channel = new MessageChannel(); + let timer = null; + const finish = (result) => { + clearTimeout(timer); + channel.port1.onmessage = null; + resolve(result); + }; + timer = setTimeout(() => finish("declined"), CLIENT_TAKE_MS); + channel.port1.onmessage = (answer) => { + const status = answer.data && answer.data.status; + if (status === "accepted") { + // It's on it. A slow server shouldn't send the action round twice. + clearTimeout(timer); + timer = setTimeout(() => finish("done"), CLIENT_RESULT_MS); + return; + } + finish(status === "done" || status === "failed" ? status : "declined"); + }; + try { + client.postMessage(message, [channel.port2]); + } catch (_) { + finish("declined"); + } + }); +} + +/** The first open tab that takes the action, the one in front first. */ +async function askOpenClients(message, allClients) { + const score = (client) => (client.focused ? 0 : client.visibilityState === "visible" ? 1 : 2); + const ordered = allClients + .filter((client) => !client.frameType || client.frameType === "top-level") + .sort((a, b) => score(a) - score(b)); + const giveUpAt = Date.now() + CLIENTS_TAKE_TOTAL_MS; + for (const client of ordered) { + if (Date.now() >= giveUpAt) break; + const result = await askClient(client, message); + if (result !== "declined") return { result, client }; + } + return { result: "declined", client: null }; +} + +/** Keeps a typed reply for the page the click is about to open; it's not for a URL. */ +async function keepPendingReply(emailId, text) { + try { + const cache = await caches.open(PUSH_STATE_CACHE); + await cache.put( + pendingReplyKey(), + new Response(JSON.stringify({ emailId, text, at: Date.now() }), { + headers: { "Content-Type": "application/json" }, + }), + ); + } catch (_) { + // The composer opens empty. + } +} + +async function postNotificationAction(action, data) { + const prefs = await readNotificationActionPrefs(); + if (action === "archive" && !prefs.archiveEnabled) return false; + const body = { action, accountId: data.accountId, emailId: data.emailId, login: data.login }; + if (action === "archive") { + body.archiveMode = prefs.archiveMode; + // Year and month folders go by the date the user sees. + if (typeof Intl !== "undefined") body.timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + } + const controller = typeof AbortController === "function" ? new AbortController() : null; + const timer = controller ? setTimeout(() => controller.abort(), ACTION_ROUTE_DEADLINE_MS) : null; + try { + const res = await fetch(`${BASE_PATH}/api/push/action`, { + method: "POST", + credentials: "include", + cache: "no-store", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + ...(controller ? { signal: controller.signal } : {}), + }); + return res.ok; + } catch (_) { + return false; + } finally { + if (timer) clearTimeout(timer); + } +} + +/** True when the action is done and nothing has to open. */ +async function runNotificationAction(action, data, reply, allClients) { + const text = action === "reply" && typeof reply === "string" ? reply.trim() : ""; + const { result, client } = await askOpenClients({ + kind: "notification-action", + action, + emailId: data.emailId, + threadId: data.threadId, + accountId: data.accountId, + slot: data.slot, + login: data.login, + ...(text ? { text } : {}), + }, allClients); + if (result === "done") { + if (action === "reply" && "focus" in client) { + try { + await client.focus(); + } catch (_) { + // The composer is open in that tab; focusing can be blocked by the browser. + } + } + return true; + } + // A tab with a live login tried and failed: the route won't do better. + if (result === "failed") return false; + if (action === "reply") { + if (text) await keepPendingReply(data.emailId, text); + return false; + } + return postNotificationAction(action, data); +} + +async function tellClientsToFlushOutbox() { + const allClients = await self.clients.matchAll({ type: "window", includeUncontrolled: true }); + for (const client of allClients) { + try { + client.postMessage({ kind: "outbox-flush" }); + } catch (_) { + // Closed or detached client - ignore. + } + } +} From fe5b5d5746a992326ba4c4aa02145c384903c847 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 05:31:34 +0100 Subject: [PATCH 07/16] feat: search mail while offline, with compound words, prefixes and highlights --- lib/store/__tests__/search.test.ts | 392 +++++++++++++++++++++++++++ lib/store/mail/index.ts | 3 +- lib/store/mail/methods.ts | 9 +- lib/store/mail/records.ts | 30 ++- lib/store/mail/search-schema.ts | 38 +++ lib/store/mail/search.ts | 409 ++++++++++++++++++++++++++--- 6 files changed, 832 insertions(+), 49 deletions(-) create mode 100644 lib/store/__tests__/search.test.ts create mode 100644 lib/store/mail/search-schema.ts diff --git a/lib/store/__tests__/search.test.ts b/lib/store/__tests__/search.test.ts new file mode 100644 index 000000000..478c277f4 --- /dev/null +++ b/lib/store/__tests__/search.test.ts @@ -0,0 +1,392 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; +import { buildJMAPFilter, DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils'; +import { parseSearchSnippet, snippetFilterFor } from '@/lib/search-snippet'; +import { DEFAULT_SCOPE } from '../area'; +import { AccountEngine } from '../engine/account-engine'; +import { mailArea } from '../mail'; +import { FAKE_SESSION, FakeJmap } from './helpers/fake-jmap'; +import { openMemoryStore } from './helpers/node-adapter'; + +type Email = Parameters[0]; + +function withBody(value: string): Email { + return { bodyValues: { 1: { value, isTruncated: false, isEncodingProblem: false } } }; +} + +async function storeWith(emails: Email[]) { + const server = new FakeJmap(); + for (const email of emails) server.addEmail(email); + const engine = new AccountEngine({ + account: 'a@fake.test', + db: openMemoryStore(), + session: FAKE_SESSION, + transport: server, + areas: [mailArea], + scope: { ...DEFAULT_SCOPE, days: null }, + now: () => Date.UTC(2026, 10, 3), + sleep: async () => {}, + }); + await engine.requestSync(); + const query = async (filter: Record) => { + // A second call, because a request with no call answered comes back as null. + const answer = await engine.answer([ + ['Email/query', { filter, sort: [{ property: 'receivedAt', isAscending: true }] }, 'q'], + ['Mailbox/get', { ids: [] }, 'm'], + ]); + const [name, result] = answer?.methodResponses[0] ?? ['error', { type: 'no answer' }]; + if (name === 'error') throw new Error(`per-call error: ${result.type}`); + return result.ids as string[]; + }; + const snippets = async (filter: Record | null, emailIds: string[]) => { + const answer = await engine.answer([['SearchSnippet/get', { filter, emailIds }, 's']]); + return answer?.methodResponses[0][1] as { + list: Array<{ emailId: string; subject: string | null; preview: string | null }>; + notFound: string[]; + }; + }; + return { engine, query, snippets }; +} + +const alice = [{ name: 'Alice Adler', email: 'alice@example.org' }]; +const bob = [{ name: 'Bob Berg', email: 'bob@example.org' }]; +const carol = [{ name: 'Carol Conrad', email: 'carol@example.org' }]; +const dave = [{ name: 'Dave Dorn', email: 'dave@example.org' }]; + +describe('matching', () => { + it('finds a word inside a German compound, in the subject and in the body', async () => { + const { query } = await storeWith([ + { id: 'subject', subject: 'Zahlungserinnerungsrechnung Oktober' }, + { id: 'body', subject: 'Hallo', ...withBody('Anbei die Stromrechnung.') }, + { id: 'other', subject: 'Einladung', ...withBody('Bis morgen') }, + ]); + expect(await query({ text: 'rechnung' })).toEqual(['subject', 'body']); + expect(await query({ subject: 'rechnung' })).toEqual(['subject']); + expect(await query({ body: 'rechnung' })).toEqual(['body']); + }); + + it('reads word* as the start of a word, not anywhere in it', async () => { + const { query } = await storeWith([ + { id: 'start', subject: 'Rechnungsbetrag offen' }, + { id: 'inside', subject: 'Stromrechnung offen' }, + ]); + expect(await query({ text: 'rechn*' })).toEqual(['start']); + expect(await query({ text: 'rechn' })).toEqual(['start', 'inside']); + }); + + it('reads a quoted phrase as exact words in order', async () => { + const { query } = await storeWith([ + { id: 'phrase', subject: 'The annual report is here' }, + { id: 'apart', subject: 'The report for annual leave' }, + { id: 'compound', subject: 'Stromrechnung' }, + { id: 'word', subject: 'Eine Rechnung' }, + ]); + expect(await query({ text: '"annual report"' })).toEqual(['phrase']); + expect(await query({ text: '"rechnung"' })).toEqual(['word']); + }); + + it('matches one and two characters as whole words only, unless a star asks for more', async () => { + const { query } = await storeWith([ + { id: 'zu', subject: 'Bitte zu Hause' }, + { id: 'zum', subject: 'Bitte zum Arzt' }, + { id: 'a', subject: 'Plan a today' }, + { id: 'anna', subject: 'Anna kommt' }, + ]); + expect(await query({ subject: 'zu' })).toEqual(['zu']); + expect(await query({ subject: 'zu*' })).toEqual(['zu', 'zum']); + expect(await query({ subject: 'a' })).toEqual(['a']); + // One letter is too expensive as a prefix, so it stays the word. + expect(await query({ subject: 'a*' })).toEqual(['a']); + }); + + it('keeps To, Cc and Bcc apart, and searches all of them for text', async () => { + const { query } = await storeWith([ + { id: 'one', from: alice, to: bob, cc: carol, bcc: dave }, + ]); + expect(await query({ from: 'alice' })).toEqual(['one']); + expect(await query({ to: 'bob' })).toEqual(['one']); + expect(await query({ to: 'carol' })).toEqual([]); + expect(await query({ to: 'dave' })).toEqual([]); + expect(await query({ cc: 'carol' })).toEqual(['one']); + expect(await query({ cc: 'bob' })).toEqual([]); + expect(await query({ bcc: 'dave' })).toEqual(['one']); + expect(await query({ bcc: 'carol' })).toEqual([]); + for (const name of ['alice', 'bob', 'carol', 'dave']) expect(await query({ text: name })).toEqual(['one']); + }); + + it('answers the recipient search the client sends', async () => { + const { query } = await storeWith([ + { id: 'to', to: bob }, + { id: 'cc', to: alice, cc: bob }, + { id: 'none', to: alice }, + ]); + expect(await query({ operator: 'OR', conditions: [{ to: 'bob@example.org' }, { cc: 'bob@example.org' }] })).toEqual(['to', 'cc']); + }); + + it('answers every search shape buildJMAPFilter produces', async () => { + const { query } = await storeWith([ + { + id: 'hit', + subject: 'Budget 2024 annual report', + from: alice, + to: bob, + ...withBody('Die Stromrechnung liegt bei'), + }, + { id: 'miss', subject: 'Budget 2024', from: alice, to: bob, ...withBody('Nichts') }, + ]); + const filter = buildJMAPFilter('"annual report" budg* 2024', { + ...DEFAULT_SEARCH_FILTERS, + from: 'alice', + to: 'bob', + subject: 'annual', + body: 'rechnung', + }, 'inbox'); + expect(await query(filter)).toEqual(['hit']); + }); + + it('ignores diacritics both ways', async () => { + const { query } = await storeWith([ + { id: 'umlaut', subject: 'Frau Müller' }, + { id: 'plain', subject: 'Herr Muller' }, + { id: 'accent', subject: 'Café am Markt' }, + ]); + expect(await query({ text: 'Müller' })).toEqual(['umlaut', 'plain']); + expect(await query({ text: 'muller' })).toEqual(['umlaut', 'plain']); + expect(await query({ text: 'cafe' })).toEqual(['accent']); + expect(await query({ text: 'Café' })).toEqual(['accent']); + expect(await query({ text: 'mül*' })).toEqual(['umlaut', 'plain']); + }); + + it('finds the spelled-out German spelling both ways, without false friends', async () => { + const { query } = await storeWith([ + { id: 'umlaut', subject: 'Frau Müller' }, + { id: 'written', subject: 'Herr Mueller' }, + { id: 'sharp', subject: 'Hauptstraße 1' }, + { id: 'double', subject: 'Hauptstrasse 2' }, + { id: 'care', subject: 'Die Betreuung beginnt' }, + { id: 'loyal', subject: 'Treue' }, + ]); + expect(await query({ text: 'Mueller' })).toEqual(['umlaut', 'written']); + expect(await query({ text: 'Müller' })).toEqual(['umlaut', 'written']); + expect(await query({ text: 'Hauptstraße' })).toEqual(['sharp', 'double']); + expect(await query({ text: 'Hauptstrasse' })).toEqual(['sharp', 'double']); + // "treu" is inside "Betreuung": the plain spelling is a whole word only. + expect(await query({ text: 'Treue' })).toEqual(['loyal']); + }); + + it('finds an umlaut an HTML mail wrote as an entity', async () => { + const { query } = await storeWith([{ + id: 'html', + subject: 'Hallo', + textBody: [], + htmlBody: [{ partId: '1', type: 'text/html' }], + ...withBody('Vorlage

Grüße von Frau Müller &lt;Chefin&gt;

'), + }]); + expect(await query({ body: 'Müller' })).toEqual(['html']); + expect(await query({ body: 'grüße' })).toEqual(['html']); + expect(await query({ body: 'Vorlage' })).toEqual([]); + // Decoded once: the encoded text stays text. + expect(await query({ body: '"<Chefin>"' })).toEqual(['html']); + }); + + it('answers a very common word', async () => { + const emails: Email[] = []; + for (let i = 0; i < 300; i += 1) emails.push({ subject: 'die Rechnung für die Firma' }); + const { query } = await storeWith(emails); + expect((await query({ text: 'die' })).length).toBe(300); + }); +}); + +describe('hostile input', () => { + const HOSTILE = [ + '"', '""', '"""', '" "', '"unterminated', 'odd " quote', '*', '**', '*foo', 'foo*bar', 'a*b*c*', + '-', '--', '-foo', 'foo-', 'foo - bar', ':', 'subject:', 'subject:foo', 'nope:foo', 'from_addr : x', + '(', ')', '()', '(foo', 'foo)', '((((', '))))', '(foo OR bar)', + 'AND', 'OR', 'NOT', 'NEAR', 'NEAR(a b)', 'NEAR(a b, 2)', 'a AND', 'OR b', 'NOT c', 'a OR OR b', + '^', '^foo', '+', 'foo + bar', '{', '{subject body}: foo', '}', '[', ']', '\\', '\\"', "'", "'foo'", '%', '_', ';', + "'; DROP TABLE email; --", 'foo\0bar', '\0', '\x01\x02', '\n', '\t', '\ud83d', '\udc00', '🎉', '🎉🎉🎉', '', + 'x'.repeat(100_000), '"'.repeat(5001), '('.repeat(5001), 'a '.repeat(5000), '"a" '.repeat(2000), 'a* '.repeat(2000), + ]; + + it('never throws, for any field and any input', async () => { + const { query } = await storeWith([{ subject: 'foo bar' }]); + for (const field of ['text', 'subject', 'from', 'to', 'cc', 'bcc', 'body']) { + for (const input of HOSTILE) { + await expect(query({ [field]: input }), `${field}: ${input.slice(0, 30)}`).resolves.toBeInstanceOf(Array); + } + } + }); + + it('reads the punctuation as text around the words', async () => { + const { query } = await storeWith([ + { id: 'hit', subject: 'foo bar' }, + { id: 'miss', subject: 'other words' }, + ]); + for (const input of ['foo - bar', '(foo)', '(foo bar)', 'foo: bar:', '"foo" -', '-foo', 'foo*', 'foo-bar']) { + expect(await query({ text: input }), input).toEqual(['hit']); + } + }); + + it('finds nothing, rather than everything, when no term is left', async () => { + const { query } = await storeWith([{ subject: 'foo bar' }]); + for (const input of ['', ' ', '*', '-', '"', '""', '()', '🎉']) { + expect(await query({ text: input }), input).toEqual([]); + } + }); + + it('caps what one value and one filter can ask, widening rather than failing', async () => { + const { query } = await storeWith([{ id: 'hit', subject: 'foo' }]); + // The terms past the limit are ignored. + expect(await query({ text: `${'foo '.repeat(16)} nothere` })).toEqual(['hit']); + const many = Array.from({ length: 1000 }, (_, i) => ({ text: i < 32 ? 'foo' : 'nothere' })); + expect(await query({ operator: 'AND', conditions: many })).toEqual(['hit']); + }); + + it('refuses too many terms where dropping one would change the meaning', async () => { + const { query } = await storeWith([{ id: 'hit', subject: 'foo' }]); + const many = Array.from({ length: 33 }, () => ({ text: 'foo' })); + await expect(query({ operator: 'OR', conditions: many })).rejects.toThrow('unsupportedFilter'); + await expect(query({ operator: 'NOT', conditions: many })).rejects.toThrow('unsupportedFilter'); + await expect(query({ operator: 'OR', conditions: many.slice(0, 32) })).resolves.toEqual(['hit']); + }); +}); + +describe('SearchSnippet/get', () => { + it('marks the terms in the subject and the body, as the server would', async () => { + const { snippets } = await storeWith([{ id: 'one', subject: 'Die Rechnung', ...withBody('Hier ist die Rechnung von heute.') }]); + const { list, notFound } = await snippets({ text: 'rechnung' }, ['one', 'gone']); + expect(list).toEqual([{ + emailId: 'one', + subject: 'Die Rechnung', + preview: 'Hier ist die Rechnung von heute.', + }]); + expect(notFound).toEqual(['gone']); + }); + + it('escapes everything except its own marks', async () => { + const hostile = 'Tom & fake it\'s Rechnung & more'; + const { snippets } = await storeWith([{ id: 'one', subject: hostile, ...withBody(hostile) }]); + const { list } = await snippets({ text: 'rechnung' }, ['one']); + const escaped = 'Tom & <script>alert("x")</script> <mark>fake</mark> it's Rechnung &amp; more'; + expect(list[0].subject).toBe(escaped); + // The preview starts near the match, and is escaped the same way. + expect(list[0].preview).toBe(escaped.slice(escaped.indexOf('<mark>'))); + // What the list shows: the markup as plain text, one highlighted word. + expect(parseSearchSnippet(list[0].subject as string)).toEqual([ + { text: 'Tom & fake it\'s ', marked: false }, + { text: 'Rechnung', marked: true }, + { text: ' & more', marked: false }, + ]); + }); + + it('escapes a term that holds markup', async () => { + const { snippets } = await storeWith([{ id: 'one', subject: 'a c' }]); + const { list } = await snippets({ subject: '""' }, ['one']); + expect(list[0].subject).toBe('a <b> c'); + }); + + it('marks the original text when diacritics or spelling differ', async () => { + const { snippets } = await storeWith([ + { id: 'umlaut', subject: 'Frau Müller, MÜLLER' }, + { id: 'written', subject: 'Herr Mueller' }, + ]); + expect((await snippets({ text: 'muller' }, ['umlaut'])).list[0].subject).toBe('Frau Müller, MÜLLER'); + expect((await snippets({ text: 'Mueller' }, ['umlaut'])).list[0].subject).toBe('Frau Müller, MÜLLER'); + expect((await snippets({ text: 'Müller' }, ['written'])).list[0].subject).toBe('Herr Mueller'); + }); + + it('marks only where the index would have matched', async () => { + const { snippets } = await storeWith([{ id: 'one', subject: 'zu zum Stromrechnung Rechnung' }]); + expect((await snippets({ subject: 'zu' }, ['one'])).list[0].subject).toBe('zu zum Stromrechnung Rechnung'); + expect((await snippets({ subject: 'rechnung' }, ['one'])).list[0].subject).toBe('zu zum Stromrechnung Rechnung'); + expect((await snippets({ subject: 'rech*' }, ['one'])).list[0].subject).toBe('zu zum Stromrechnung Rechnung'); + }); + + it('marks the words of the loosened filter the client sends', async () => { + const { snippets } = await storeWith([{ id: 'one', subject: 'annual and report, budget' }]); + const filter = buildJMAPFilter('"annual report" budg*', DEFAULT_SEARCH_FILTERS, 'inbox'); + const { list } = await snippets(snippetFilterFor(filter), ['one']); + expect(list[0].subject).toBe('annual and report, budget'); + }); + + it('gives null for the part with no match, and for filters with nothing to mark', async () => { + const { snippets } = await storeWith([{ id: 'one', subject: 'Betreff', from: alice, ...withBody('Inhalt hier') }]); + expect((await snippets({ text: 'inhalt' }, ['one'])).list[0]).toEqual({ emailId: 'one', subject: null, preview: 'Inhalt hier' }); + expect((await snippets({ text: 'betreff' }, ['one'])).list[0]).toEqual({ emailId: 'one', subject: 'Betreff', preview: null }); + // subject and body only mark their own part. + expect((await snippets({ subject: 'inhalt' }, ['one'])).list[0]).toEqual({ emailId: 'one', subject: null, preview: null }); + expect((await snippets({ body: 'betreff' }, ['one'])).list[0]).toEqual({ emailId: 'one', subject: null, preview: null }); + expect((await snippets({ from: 'alice' }, ['one'])).list[0]).toEqual({ emailId: 'one', subject: null, preview: null }); + expect((await snippets({ inMailbox: 'inbox' }, ['one'])).list[0]).toEqual({ emailId: 'one', subject: null, preview: null }); + expect((await snippets(null, ['one'])).list[0]).toEqual({ emailId: 'one', subject: null, preview: null }); + }); + + it('does not mark terms the filter excludes', async () => { + const { snippets } = await storeWith([{ id: 'one', subject: 'foo bar' }]); + const { list } = await snippets({ operator: 'AND', conditions: [{ text: 'foo' }, { operator: 'NOT', conditions: [{ text: 'bar' }] }] }, ['one']); + expect(list[0].subject).toBe('foo bar'); + }); + + it('keeps a preview within 255 bytes, cuts no entity or mark, and starts near the match', async () => { + const before = 'Wörter füllen den Anfang dieser Nachricht ganz ohne Treffer. '.repeat(20); + const after = '& < > " \' ü '.repeat(200); + const { snippets } = await storeWith([{ id: 'one', subject: 'x', ...withBody(`${before}\n\nDie Rechnung ${after} Rechnung`) }]); + const preview = (await snippets({ body: 'rechnung' }, ['one'])).list[0].preview as string; + expect(new TextEncoder().encode(preview).length).toBeLessThanOrEqual(255); + expect(preview.length).toBeGreaterThan(200); + expect(preview).toContain('Die Rechnung & <'); + expect(preview.indexOf('')).toBeLessThan(60); + // Whatever is left over parses back with no half entity. + expect(preview.replace(/<\/?mark>/g, '')).not.toMatch(/&(?!amp;|lt;|gt;|quot;|#39;)/); + expect(preview.split('').length).toBe(preview.split('').length); + }); + + it('never leaves an empty or open mark when the budget runs out on one', async () => { + const { snippets } = await storeWith([{ id: 'one', subject: 'x', ...withBody(`foo ${'abcd '.repeat(48)}foo foo foo foo foo foo foo foo foo`) }]); + const preview = (await snippets({ body: 'foo' }, ['one'])).list[0].preview as string; + expect(new TextEncoder().encode(preview).length).toBeLessThanOrEqual(255); + expect(preview).not.toContain(''); + expect(preview.split('').length).toBe(preview.split('').length); + }); + + it('builds the preview from an HTML body as plain text', async () => { + const { snippets } = await storeWith([{ + id: 'one', + subject: 'x', + textBody: [], + htmlBody: [{ partId: '1', type: 'text/html' }], + ...withBody('

Ihre Rechnung von Müller

'), + }]); + expect((await snippets({ body: 'rechnung' }, ['one'])).list[0].preview).toBe('Ihre Rechnung von Müller'); + }); + + it('works chained onto the query, as the client asks for it', async () => { + const { engine } = await storeWith([{ id: 'one', subject: 'Rechnung' }, { id: 'two', subject: 'Hallo' }]); + const answer = await engine.answer([ + ['Email/query', { filter: { text: 'rechnung' } }, '0'], + ['SearchSnippet/get', { filter: { text: 'rechnung' }, '#emailIds': { resultOf: '0', name: 'Email/query', path: '/ids' } }, 'snippets'], + ]); + expect(answer?.methodResponses[1][0]).toBe('SearchSnippet/get'); + expect(answer?.methodResponses[1][1].list).toEqual([{ emailId: 'one', subject: 'Rechnung', preview: null }]); + }); + + it('answers per-call errors for bad arguments', async () => { + const { engine } = await storeWith([]); + const answer = await engine.answer([ + ['SearchSnippet/get', { filter: { text: 'x' } }, 'a'], + ['SearchSnippet/get', { filter: { text: 'x' }, emailIds: Array.from({ length: 501 }, (_, i) => `e${i}`) }, 'b'], + ['Mailbox/query', {}, 'c'], + ]); + expect(answer?.methodResponses[0]).toEqual(['error', expect.objectContaining({ type: 'invalidArguments' }), 'a']); + expect(answer?.methodResponses[1]).toEqual(['error', expect.objectContaining({ type: 'requestTooLarge' }), 'b']); + }); + + it('survives hostile search terms', async () => { + const { snippets } = await storeWith([{ id: 'one', subject: 'foo (bar) [baz]', ...withBody('foo (bar) [baz] \\ ^ $ . * + ? | /') }]); + for (const text of ['(', ')', '[', ']', '\\', '^', '$', '.', '*', '+', '?', '|', '/', '(bar)', '[baz', 'a|b', '.*', '(?', '\ud83d', 'foo\0bar']) { + await expect(snippets({ text }, ['one']), text).resolves.toHaveProperty('list'); + } + expect((await snippets({ text: '.*' }, ['one'])).list[0].preview).toBeNull(); + expect((await snippets({ text: '(bar)' }, ['one'])).list[0].subject).toBe('foo (bar) [baz]'); + }); +}); diff --git a/lib/store/mail/index.ts b/lib/store/mail/index.ts index e76d61d28..0c7749864 100644 --- a/lib/store/mail/index.ts +++ b/lib/store/mail/index.ts @@ -1,11 +1,12 @@ import type { StoreArea } from '../area'; import { MAIL_METHODS } from './methods'; import { MAIL_MIGRATIONS } from './schema'; +import { SEARCH_MIGRATIONS } from './search-schema'; import { mailSyncer } from './sync'; export const mailArea: StoreArea = { name: 'mail', - migrations: MAIL_MIGRATIONS, + migrations: [...MAIL_MIGRATIONS, ...SEARCH_MIGRATIONS], methods: MAIL_METHODS, syncers: [mailSyncer], }; diff --git a/lib/store/mail/methods.ts b/lib/store/mail/methods.ts index e91bdd45c..34a191df5 100644 --- a/lib/store/mail/methods.ts +++ b/lib/store/mail/methods.ts @@ -3,14 +3,12 @@ import { MethodError, type MethodContext, type MethodHandler, type MethodResult } from '../area'; import { listObjects } from '../engine/core-schema'; import type { SqlValue } from '../storage'; -import { textCondition, type TextField } from './search'; +import { limitTextConditions, searchSnippetGet, textCondition, TEXT_FIELDS, type TextField } from './search'; type Filter = Record; interface Sql { sql: string; params: SqlValue[] } -const TEXT_FIELDS: readonly TextField[] = ['text', 'subject', 'from', 'to', 'body']; - function jmapOf(ctx: MethodContext, args: Record): string { return (args.accountId as string | undefined) ?? ctx.defaultJmap; } @@ -78,7 +76,7 @@ function filterSql(filter: Filter, jmap: string): Sql { params.push(...text.params); break; } - // cc, bcc, header and the thread keyword filters: the server's job. + // header and the thread keyword filters: the server's job. throw new MethodError('unsupportedFilter', `${key} isn't available offline`); } } @@ -132,7 +130,7 @@ function drivingMailbox(filter: Filter | undefined): string | null { const emailQuery: MethodHandler = (ctx, args) => { const jmap = jmapOf(ctx, args); - const filter = (args.filter ?? undefined) as Filter | undefined; + const filter = limitTextConditions((args.filter ?? undefined) as Filter | undefined); if (args.anchor !== undefined) throw new MethodError('unsupportedFilter', 'anchor needs the server'); const where = filter ? filterSql(filter, jmap) : { sql: '1', params: [] }; const order = orderSql(ctx, (args.sort as Comparator[]) ?? []); @@ -270,4 +268,5 @@ export const MAIL_METHODS: Readonly> = { 'Thread/get': threadGet, 'Mailbox/get': mailboxGet, 'Mailbox/query': mailboxQuery, + 'SearchSnippet/get': searchSnippetGet, }; diff --git a/lib/store/mail/records.ts b/lib/store/mail/records.ts index b0e292718..f8496fb9c 100644 --- a/lib/store/mail/records.ts +++ b/lib/store/mail/records.ts @@ -101,6 +101,20 @@ export function deleteEmailRow(db: StorageAdapter, rowid: number): void { db.run('DELETE FROM email WHERE rowid = ?', [rowid]); } +/** German HTML mail often writes its umlauts as entities: unread, "Müller" would never be found. */ +const NAMED_ENTITIES: Record = { + amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', shy: '', + auml: 'ä', ouml: 'ö', uuml: 'ü', Auml: 'Ä', Ouml: 'Ö', Uuml: 'Ü', szlig: 'ß', + eacute: 'é', egrave: 'è', agrave: 'à', ccedil: 'ç', ndash: '-', mdash: '-', hellip: '...', euro: '€', + bdquo: '"', ldquo: '"', rdquo: '"', lsquo: "'", rsquo: "'", +}; + +function decodeEntity(whole: string, name: string): string { + if (name[0] !== '#') return NAMED_ENTITIES[name] ?? whole; + const code = /^#x/i.test(name) ? parseInt(name.slice(2), 16) : parseInt(name.slice(1), 10); + return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : whole; +} + /** Plain text of a message for the search index: the text part, or the HTML part without its tags. */ export function bodyText(email: Record): string { const values = (email.bodyValues ?? {}) as Record; @@ -113,25 +127,25 @@ export function bodyText(email: Record): string { return parts('htmlBody') .map((part) => values[part.partId ?? '']?.value ?? '') .join('\n') - .replace(/<(style|script)[\s\S]*?<\/\1>/gi, ' ') + .replace(//g, ' ') + .replace(/<(style|script|head)\b[\s\S]*?<\/\1>/gi, ' ') .replace(/<[^>]+>/g, ' ') - .replace(/ /g, ' ') - .replace(/&/g, '&') - .replace(/</g, '<') - .replace(/>/g, '>') + .replace(/&(#\d+|#x[\da-f]+|[a-z]+\d*);/gi, decodeEntity) .replace(/\s+/g, ' '); } /** A long message past this is rare and only bloats the index. */ -const INDEX_BODY_CHARS = 200_000; +export const INDEX_BODY_CHARS = 200_000; export function indexEmail(db: StorageAdapter, rowid: number, email: Record, body: string): void { const subject = typeof email.subject === 'string' ? email.subject : ''; const from = addressText(email.from); - const to = [addressText(email.to), addressText(email.cc), addressText(email.bcc)].filter(Boolean).join(' '); + const to = addressText(email.to); + const cc = addressText(email.cc); + const bcc = addressText(email.bcc); const text = body.slice(0, INDEX_BODY_CHARS); for (const table of ['email_fts_word', 'email_fts_tri']) { - db.run(`INSERT INTO ${table} (rowid, subject, from_addr, to_addr, body) VALUES (?, ?, ?, ?, ?)`, [rowid, subject, from, to, text]); + db.run(`INSERT INTO ${table} (rowid, subject, from_addr, to_addr, cc_addr, bcc_addr, body) VALUES (?, ?, ?, ?, ?, ?, ?)`, [rowid, subject, from, to, cc, bcc, text]); } } diff --git a/lib/store/mail/search-schema.ts b/lib/store/mail/search-schema.ts new file mode 100644 index 000000000..d30613b72 --- /dev/null +++ b/lib/store/mail/search-schema.ts @@ -0,0 +1,38 @@ +import type { Migration } from '../storage'; + +const FTS_COLUMNS = 'subject, from_addr, to_addr, cc_addr, bcc_addr, body'; + +/** + * Gives To, Cc and Bcc their own index columns. `to` used to find Cc and Bcc + * too, and `cc` (the client's recipient search asks for it) had no answer. + * + * FTS5 can't add a column and contentless tables have nothing to copy, so the + * indexes are made again and the mail syncs again to fill them. No released + * version has data here: this only touches a development database. + */ +export const SEARCH_MIGRATIONS: readonly Migration[] = [ + { + id: 300, + name: 'search-address-columns', + sql: ` + DROP TABLE email_fts_word; + DROP TABLE email_fts_tri; + CREATE VIRTUAL TABLE email_fts_word USING fts5( + ${FTS_COLUMNS}, + content = '', contentless_delete = 1, + tokenize = 'unicode61 remove_diacritics 2' + ); + CREATE VIRTUAL TABLE email_fts_tri USING fts5( + ${FTS_COLUMNS}, + content = '', contentless_delete = 1, + tokenize = 'trigram remove_diacritics 1' + ); + DELETE FROM email_keyword; + DELETE FROM email_mailbox; + DELETE FROM email_body; + DELETE FROM thread; + DELETE FROM email; + DELETE FROM sync_state WHERE type IN ('Email', 'Thread'); + `, + }, +]; diff --git a/lib/store/mail/search.ts b/lib/store/mail/search.ts index 57b94f849..dba103b4d 100644 --- a/lib/store/mail/search.ts +++ b/lib/store/mail/search.ts @@ -1,59 +1,398 @@ -/** Full-text matching over the two FTS indexes. */ +/** + * Full-text matching and SearchSnippet/get over the two FTS indexes. + * + * What a search value means here (the shapes buildJMAPFilter sends): + * - `rechnung`: a word of three or more characters is found anywhere, also + * inside a longer word, through the trigram index ("Rechnung" in + * "Zahlungserinnerungsrechnung"). + * - `zu`: one or two characters can't use trigrams, so only the whole word + * matches, through the word index. "zu" doesn't find "zum". + * - `rech*`: the start of a word, through the word index. It needs two + * characters: `a*` is the word "a", since reading every word that starts + * with one letter is the most expensive thing FTS5 can be asked. + * - `"annual report"`: these exact words in this order, through the word index. + * - Several words have to all match, each in any field `text` covers. That's a + * little wider than Stalwart, which wants them in one field. + * - Case and diacritics don't count: Müller and muller find each other. + * + * Input never reaches FTS5 as syntax: every term goes in as a quoted string, so + * `"`, `*`, `-`, `:` and parentheses are text. A term without a letter or digit + * has nothing to search for and is left out; a value left with no terms matches + * nothing, because leaving it out would widen the search to every message. + * + * Limits: MAX_TERMS per value, MAX_TERM_CHARS per term, MAX_TEXT_CONDITIONS per + * filter. Past them the extra terms are ignored, which only widens the result, + * so a pasted page of text still gets an answer and can't keep the worker busy. + * A very common word costs one read of its list in each index, at most the size + * of the account. + */ +import { MethodError, type MethodHandler } from '../area'; import type { SqlValue } from '../storage'; +import { bodyText, INDEX_BODY_CHARS } from './records'; -export type TextField = 'text' | 'subject' | 'from' | 'to' | 'body'; +export const TEXT_FIELDS = ['text', 'subject', 'from', 'to', 'cc', 'bcc', 'body'] as const; +export type TextField = (typeof TEXT_FIELDS)[number]; const COLUMNS: Record = { text: null, subject: 'subject', from: 'from_addr', to: 'to_addr', + cc: 'cc_addr', + bcc: 'bcc_addr', body: 'body', }; +const MAX_INPUT_CHARS = 8192; +const MAX_TERMS = 16; +const MAX_TERM_CHARS = 200; +const MAX_TEXT_CONDITIONS = 32; +const MIN_TRIGRAM_CHARS = 3; +const MIN_PREFIX_CHARS = 2; +const MIN_PLAIN_SPELLING_CHARS = 5; +const MAX_SNIPPET_IDS = 500; +/** RFC 8621 §5: a preview is at most 255 octets. */ +const PREVIEW_BYTES = 255; +const PREVIEW_CONTEXT_CHARS = 40; + +const WORD_CHAR = '[\\p{L}\\p{N}\\p{M}]'; +const NOT_WORD_CHAR = '[^\\p{L}\\p{N}\\p{M}]'; +const EDGE_PUNCTUATION = new RegExp(`^${NOT_WORD_CHAR}+|${NOT_WORD_CHAR}+$`, 'gu'); + +interface Term { + needle: string; + kind: 'word' | 'prefix' | 'phrase'; +} + +/** One lookup in one index. A term matches when any of its lookups does. */ +interface Probe { + index: 'word' | 'tri'; + text: string; + prefix: boolean; +} + +function chars(text: string): number { + return [...text].length; +} + +function parseTerms(input: string): Term[] { + const clean = input.slice(0, MAX_INPUT_CHARS).normalize('NFC').replace(/\p{Cc}/gu, ' '); + const terms: Term[] = []; + for (const match of clean.matchAll(/"([^"]*)"|(\S+)/g)) { + const quoted = match[1] !== undefined; + const raw = match[1] ?? match[2]; + const needle = [...raw.replace(EDGE_PUNCTUATION, '')].slice(0, MAX_TERM_CHARS).join(''); + if (!needle) continue; + terms.push({ needle, kind: quoted ? 'phrase' : raw.endsWith('*') ? 'prefix' : 'word' }); + if (terms.length === MAX_TERMS) break; + } + return terms; +} + +/** + * The other German spellings of a term, worked out when searching so the + * indexes stay as they are. The indexes store ü as u, so: + * - Müller also looks for "mueller", the way the term itself is looked for; + * - Mueller also looks for "muller", as a whole word only: Treue must not find + * "Betreuung" through "treu". Short words stay out for the same reason (Poet, + * blue, neue). + * ß and ss work the same way. + */ +function spellings(needle: string): { written: string | null; plain: string | null } { + const lower = needle.toLowerCase(); + const written = lower.replace(/[äöüß]/g, (c) => ({ ä: 'ae', ö: 'oe', ü: 'ue', ß: 'ss' })[c] as string); + const plain = chars(lower) >= MIN_PLAIN_SPELLING_CHARS + ? lower.replace(/ae|oe|ue/g, (pair) => pair[0]).replace(/ss/g, 'ß') + : lower; + return { written: written === lower ? null : written, plain: plain === lower ? null : plain }; +} + +function probesFor(term: Term): Probe[] { + const probes: Probe[] = []; + const addWord = (text: string) => { + probes.push({ index: 'word', text, prefix: term.kind === 'prefix' && chars(text) >= MIN_PREFIX_CHARS }); + }; + const addAll = (text: string) => { + addWord(text); + if (term.kind === 'word' && chars(text) >= MIN_TRIGRAM_CHARS) probes.push({ index: 'tri', text, prefix: false }); + }; + addAll(term.needle); + const { written, plain } = spellings(term.needle); + if (written) addAll(written); + if (plain) addWord(plain); + return probes; +} + function quote(term: string): string { return `"${term.replace(/"/g, '""')}"`; } -/** The terms of a search box value: quoted phrases stay whole, `word*` keeps its star. */ -export function splitTerms(input: string): string[] { - const out: string[] = []; - for (const match of input.matchAll(/"([^"]+)"|(\S+)/g)) out.push(match[1] ?? match[2]); - return out; -} - -/** - * One term as SQL over rowids. The word index finds words and word starts, - * the trigram index finds a term inside a longer word, which is how German - * compounds are found ("Rechnung" in "Zahlungserinnerungsrechnung"). - */ -function termRowids(field: TextField, term: string): { sql: string; params: SqlValue[] } { - const column = COLUMNS[field]; - const star = term.endsWith('*'); - const clean = star ? term.slice(0, -1) : term; - const scope = column ? `${column} : ` : ''; - const word = `${scope}${quote(clean)}${' *'.slice(1)}`; - const parts = ['SELECT rowid FROM email_fts_word WHERE email_fts_word MATCH ?']; - const params: SqlValue[] = [word]; - // Trigrams need three characters. Shorter terms use the word index alone. - if ([...clean].length >= 3) { - parts.push('SELECT rowid FROM email_fts_tri WHERE email_fts_tri MATCH ?'); - params.push(`${scope}${quote(clean)}`); - } - return { sql: parts.join(' UNION '), params }; -} - /** SQL for a text condition, as a condition on `e.rowid`. Every term has to match. */ export function textCondition(field: TextField, input: string): { sql: string; params: SqlValue[] } { - const terms = splitTerms(input); - if (terms.length === 0) return { sql: '1', params: [] }; + const terms = parseTerms(input); + if (terms.length === 0) return { sql: '0', params: [] }; + const column = COLUMNS[field]; + const scope = column ? `${column} : ` : ''; const conditions: string[] = []; const params: SqlValue[] = []; for (const term of terms) { - const part = termRowids(field, term); - conditions.push(`e.rowid IN (${part.sql})`); - params.push(...part.params); + const selects: string[] = []; + for (const probe of probesFor(term)) { + const table = probe.index === 'word' ? 'email_fts_word' : 'email_fts_tri'; + selects.push(`SELECT rowid FROM ${table} WHERE ${table} MATCH ?`); + params.push(`${scope}${quote(probe.text)}${probe.prefix ? '*' : ''}`); + } + conditions.push(`e.rowid IN (${selects.join(' UNION ')})`); } return { sql: conditions.join(' AND '), params }; } + +// ---- Limiting a whole filter ---- + +type Filter = Record; + +function isOperator(filter: Filter): boolean { + return typeof filter.operator === 'string'; +} + +function conditionsOf(filter: Filter): Filter[] { + return Array.isArray(filter.conditions) ? filter.conditions as Filter[] : []; +} + +function textKeysOf(filter: Filter): TextField[] { + return TEXT_FIELDS.filter((field) => field in filter); +} + +function countTextConditions(filter: Filter): number { + if (isOperator(filter)) return conditionsOf(filter).reduce((sum, condition) => sum + countTextConditions(condition), 0); + return textKeysOf(filter).length; +} + +/** + * Keeps a filter within MAX_TEXT_CONDITIONS. Under AND a text condition past + * the limit is dropped, which only widens the result. Under OR or NOT that + * would narrow it or turn it around, so such a branch has to fit or the call + * gets an error. + */ +export function limitTextConditions(filter: Filter | undefined): Filter | undefined { + if (!filter) return filter; + let left = MAX_TEXT_CONDITIONS; + const walk = (node: Filter): Filter | null => { + if (isOperator(node)) { + if (node.operator === 'AND') { + const kept = conditionsOf(node).map(walk).filter((condition): condition is Filter => condition !== null); + return kept.length > 0 || conditionsOf(node).length === 0 ? { ...node, conditions: kept } : null; + } + const count = countTextConditions(node); + if (count > left) throw new MethodError('unsupportedFilter', 'This search has too many terms'); + left -= count; + return node; + } + const keys = textKeysOf(node); + if (keys.length <= left) { + left -= keys.length; + return node; + } + const kept = { ...node }; + for (const key of keys.slice(left)) delete kept[key]; + left = 0; + return Object.keys(kept).length > 0 ? kept : null; + }; + return walk(filter) ?? {}; +} + +// ---- SearchSnippet/get ---- + +const FOLD_CACHE = new Map(); + +function foldChar(ch: string): string { + const code = ch.charCodeAt(0); + if (code < 128) return code >= 65 && code <= 90 ? String.fromCharCode(code + 32) : ch; + let folded = FOLD_CACHE.get(ch); + if (folded === undefined) { + folded = ch.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase(); + if (FOLD_CACHE.size < 4096) FOLD_CACHE.set(ch, folded); + } + return folded; +} + +function fold(text: string): string { + let out = ''; + for (const ch of text) out += foldChar(ch); + return out; +} + +/** Text without case and diacritics, and where each of its positions came from. */ +function foldWithMap(text: string): { folded: string; map: number[] } { + const map: number[] = []; + let folded = ''; + let at = 0; + for (const ch of text) { + const piece = foldChar(ch); + for (let i = 0; i < piece.length; i += 1) map.push(at); + folded += piece; + at += ch.length; + } + map.push(text.length); + return { folded, map }; +} + +function escapeRegExp(text: string): string { + return text.replace(/[\\^$.*+?()[\]{}|/]/g, '\\$&'); +} + +/** What a probe finds, as a pattern over folded text, so a mark lands where the index matched. */ +function probePattern(probe: Probe): string | null { + const text = fold(probe.text); + if (probe.index === 'tri') return escapeRegExp(text); + const words = text.split(new RegExp(`${NOT_WORD_CHAR}+`, 'u')).filter(Boolean).map(escapeRegExp); + if (words.length === 0) return null; + return `(?(); + for (const term of terms) { + for (const probe of probesFor(term)) { + const pattern = probePattern(probe); + if (pattern) patterns.add(pattern); + } + } + if (patterns.size === 0) return null; + // Longest first, so a match isn't cut short by a shorter spelling of it. + return new RegExp([...patterns].sort((a, b) => b.length - a.length).join('|'), 'gu'); +} + +/** The terms a snippet marks. Anything under NOT was searched to be absent, so it isn't marked. */ +function snippetTerms(filter: Filter | null | undefined): { subject: Term[]; body: Term[] } { + const subject: Term[] = []; + const body: Term[] = []; + let left = MAX_TEXT_CONDITIONS; + const walk = (node: Filter) => { + if (isOperator(node)) { + if (node.operator !== 'NOT') conditionsOf(node).forEach(walk); + return; + } + for (const key of ['text', 'subject', 'body'] as const) { + const value = node[key]; + if (typeof value !== 'string' || left === 0) continue; + left -= 1; + const terms = parseTerms(value); + if (key !== 'body') subject.push(...terms); + if (key !== 'subject') body.push(...terms); + } + }; + if (filter) walk(filter); + return { subject, body }; +} + +const ENTITIES: Record = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; + +function escapeHtml(text: string): string { + return text.replace(/[&<>"']/g, (c) => ENTITIES[c]); +} + +function utf8Bytes(text: string): number { + let bytes = 0; + for (const ch of text) { + const code = ch.codePointAt(0) as number; + bytes += code < 0x80 ? 1 : code < 0x800 ? 2 : code < 0x10000 ? 3 : 4; + } + return bytes; +} + +/** The whole text, escaped, with its matches marked; null when nothing matches. */ +function markAll(text: string, pattern: RegExp): string | null { + const { folded, map } = foldWithMap(text); + let out = ''; + let at = 0; + for (const match of folded.matchAll(pattern)) { + const start = map[match.index]; + const end = map[match.index + match[0].length]; + out += `${escapeHtml(text.slice(at, start))}${escapeHtml(text.slice(start, end))}`; + at = end; + } + return out === '' ? null : out + escapeHtml(text.slice(at)); +} + +/** A section around the first match, escaped and marked, never past PREVIEW_BYTES; null when nothing matches. */ +function excerpt(raw: string, pattern: RegExp): string | null { + const text = raw.replace(/\s+/g, ' ').trim(); + const { folded, map } = foldWithMap(text); + let out = ''; + let used = 0; + // Stops before the character that wouldn't fit, keeping room for what must still follow. + const put = (segment: string, reserve: number): boolean => { + for (const ch of segment) { + const piece = escapeHtml(ch); + const size = utf8Bytes(piece); + if (used + size + reserve > PREVIEW_BYTES) return false; + out += piece; + used += size; + } + return true; + }; + let at = -1; + for (const match of folded.matchAll(pattern)) { + const start = map[match.index]; + const end = map[match.index + match[0].length]; + if (at < 0) { + const earliest = Math.max(0, start - PREVIEW_CONTEXT_CHARS); + const space = earliest === 0 ? -1 : text.indexOf(' ', earliest); + at = earliest === 0 ? 0 : space >= 0 && space < start ? space + 1 : start; + } + if (!put(text.slice(at, start), 0)) return out; + const before = out; + const usedBefore = used; + out += ''; + used += ''.length; + const opened = out.length; + const whole = put(text.slice(start, end), ''.length); + if (out.length === opened) { + // Not even one character fit: no empty mark. + out = before; + used = usedBefore; + return out; + } + out += ''; + used += '
'.length; + if (!whole) return out; + at = end; + } + if (at < 0) return null; + put(text.slice(at), 0); + return out; +} + +/** The indexes are contentless, so snippets come from the stored message itself. */ +export const searchSnippetGet: MethodHandler = (ctx, args) => { + const jmap = (args.accountId as string | undefined) ?? ctx.defaultJmap; + const ids = args.emailIds; + if (!Array.isArray(ids)) throw new MethodError('invalidArguments', 'emailIds is required'); + if (ids.length > MAX_SNIPPET_IDS) throw new MethodError('requestTooLarge', `At most ${MAX_SNIPPET_IDS} messages at a time`); + const terms = snippetTerms(args.filter as Filter | null | undefined); + const subjectPattern = highlighter(terms.subject); + const bodyPattern = highlighter(terms.body); + const list: Array<{ emailId: string; subject: string | null; preview: string | null }> = []; + const notFound: string[] = []; + for (const id of ids as string[]) { + const row = ctx.db.get<{ rowid: number; json: string }>('SELECT rowid, json FROM email WHERE jmap = ? AND id = ?', [jmap, id]); + if (!row) { + notFound.push(id); + continue; + } + const email = JSON.parse(row.json) as { subject?: unknown; preview?: unknown }; + const subject = subjectPattern && typeof email.subject === 'string' ? markAll(email.subject, subjectPattern) : null; + let preview: string | null = null; + if (bodyPattern) { + const body = ctx.db.get<{ json: string }>('SELECT json FROM email_body WHERE email_rowid = ?', [row.rowid]); + // Before its body has arrived, a message only has its preview to show. + const text = body ? bodyText(JSON.parse(body.json) as Record) : ''; + const source = text.trim() ? text.slice(0, INDEX_BODY_CHARS) : typeof email.preview === 'string' ? email.preview : ''; + preview = excerpt(source, bodyPattern); + } + list.push({ emailId: id, subject, preview }); + } + return { accountId: jmap, list, notFound }; +}; From a4451dc900f16600fed5aa50791502a3c7f4bf73 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 05:31:35 +0100 Subject: [PATCH 08/16] feat: add results from the local store to online searches --- lib/jmap/__tests__/search-merge.test.ts | 523 ++++++++++++++++++++++++ lib/jmap/client.ts | 205 +++++----- lib/jmap/offline-hooks.ts | 6 + lib/jmap/search-merge.ts | 203 +++++++++ 4 files changed, 838 insertions(+), 99 deletions(-) create mode 100644 lib/jmap/__tests__/search-merge.test.ts create mode 100644 lib/jmap/search-merge.ts diff --git a/lib/jmap/__tests__/search-merge.test.ts b/lib/jmap/__tests__/search-merge.test.ts new file mode 100644 index 000000000..4bfa9135b --- /dev/null +++ b/lib/jmap/__tests__/search-merge.test.ts @@ -0,0 +1,523 @@ +// @vitest-environment node +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { DEFAULT_SCOPE } from '@/lib/store/area'; +import { AccountEngine } from '@/lib/store/engine/account-engine'; +import { mailArea } from '@/lib/store/mail'; +import { FAKE_SESSION, FakeJmap, JMAP_ACCOUNT } from '@/lib/store/__tests__/helpers/fake-jmap'; +import { openMemoryStore } from '@/lib/store/__tests__/helpers/node-adapter'; +import { JMAPClient } from '../client'; +import type { SearchEmailsResult } from '../client-interface'; +import { setOfflineHooks, type OfflineHooks } from '../offline-hooks'; +import { filterHasTextTerms, LocalSearchMerge } from '../search-merge'; +import type { Email } from '../types'; + +function mail(id: string, minute: number, extra: Partial = {}): Email { + return { id, receivedAt: new Date(Date.UTC(2026, 9, 1, 0, minute)).toISOString(), ...extra } as Email; +} + +/** A side of the search: the whole result list, answered a page at a time, newest first. */ +function side(rows: Email[], total: number = rows.length) { + const asked: number[] = []; + const answer = async (position: number, limit: number): Promise => { + asked.push(position); + const emails = rows.slice(position, position + limit); + return { emails, total, hasMore: position + emails.length < total }; + }; + return { asked, answer }; +} + +function pager(server: ReturnType, local: ReturnType | null, limit: number) { + const merge = new LocalSearchMerge(); + return (position: number) => merge.run({ + key: 'search', + limit, + position, + server: (at) => server.answer(at, limit), + local: async (at) => (local ? local.answer(at, limit) : null), + }); +} + +const ids = (result: SearchEmailsResult) => result.emails.map((email) => email.id); + +describe('merging one page', () => { + it('sorts newest first and has no duplicates', async () => { + const server = side([mail('s4', 40), mail('both3', 30), mail('s1', 10)]); + const local = side([mail('l5', 50), mail('both3', 30), mail('l2', 20)]); + const result = await pager(server, local, 50)(0); + expect(ids(result)).toEqual(['l5', 's4', 'both3', 'l2', 's1']); + expect(result.hasMore).toBe(false); + expect(result.total).toBe(5); + }); + + it('keeps the server copy when both have a message', async () => { + const server = side([mail('both', 10, { subject: 'server copy' })]); + const local = side([mail('both', 10, { subject: 'local copy' })]); + const result = await pager(server, local, 50)(0); + expect(result.emails.map((email) => email.subject)).toEqual(['server copy']); + expect(result.total).toBe(1); + }); + + it('keeps the server copy when the local row sorts earlier than it', async () => { + // The local copy is out of date and looks newer: still one row, the server's. + const server = side([mail('s2', 20), mail('both', 10, { subject: 'server copy' })]); + const local = side([mail('both', 30, { subject: 'local copy' })]); + const result = await pager(server, local, 50)(0); + expect(ids(result)).toEqual(['s2', 'both']); + expect(result.emails[1].subject).toBe('server copy'); + }); + + it('passes on what the server added to its result, such as its deferred snippets', async () => { + const snippets = Promise.resolve([]); + const merge = new LocalSearchMerge(); + const result = await merge.run({ + key: 'k', limit: 10, position: 0, + server: async () => ({ emails: [mail('s', 1)], total: 1, hasMore: false, snippets }), + local: async () => ({ emails: [mail('l', 2)], total: 1, hasMore: false }), + }); + expect(result.snippets).toBe(snippets); + }); +}); + +describe('paging', () => { + it('walks both lists with no row lost or repeated, counting by merged rows', async () => { + const serverRows = Array.from({ length: 25 }, (_, i) => mail(`s${i}`, 1000 - i * 10)); + const localOnly = [mail('l-a', 995), mail('l-b', 905), mail('l-c', 805), mail('l-d', 500)]; + const server = side(serverRows); + const local = side([...localOnly, serverRows[1], serverRows[12]].sort((a, b) => Date.parse(b.receivedAt) - Date.parse(a.receivedAt))); + const page = pager(server, local, 10); + + const seen: string[] = []; + let result = await page(0); + seen.push(...ids(result)); + expect(result.emails.length).toBe(10); + expect(result.hasMore).toBe(true); + let guard = 0; + while (result.hasMore && guard < 10) { + // As the store does: the next position is how many rows it holds. + result = await page(seen.length); + seen.push(...ids(result)); + guard += 1; + } + expect(seen.length).toBe(29); + expect(new Set(seen).size).toBe(29); + expect(seen).toEqual(expect.arrayContaining([...serverRows.map((e) => e.id), ...localOnly.map((e) => e.id)])); + const times = [...serverRows, ...localOnly].sort((a, b) => Date.parse(b.receivedAt) - Date.parse(a.receivedAt)).map((e) => e.id); + expect(seen).toEqual(times); + expect(result.total).toBe(29); + }); + + it('asks the server for rows a local row pushed off the page', async () => { + const server = side([mail('s3', 30), mail('s2', 20), mail('s1', 10)]); + const local = side([mail('l4', 40)]); + const page = pager(server, local, 2); + expect(ids(await page(0))).toEqual(['l4', 's3']); + expect(server.asked).toEqual([0]); + // Merged position 2, but the server has only given one row so far. + expect(ids(await page(2))).toEqual(['s2', 's1']); + expect(server.asked).toEqual([0, 1]); + }); + + it('holds back a local row older than what the server has sent, in case the server has it later', async () => { + const serverRows = [mail('s4', 40), mail('s3', 30), mail('both', 20), mail('s1', 10)]; + const server = side(serverRows); + const local = side([mail('both', 20)]); + const page = pager(server, local, 2); + const first = await page(0); + expect(ids(first)).toEqual(['s4', 's3']); + const second = await page(2); + expect(ids(second)).toEqual(['both', 's1']); + expect(second.hasMore).toBe(false); + expect(second.total).toBe(4); + }); + + it('keeps paging through local rows when the server has none', async () => { + const server = side([]); + const local = side([mail('l3', 30), mail('l2', 20), mail('l1', 10)]); + const page = pager(server, local, 2); + const first = await page(0); + expect(ids(first)).toEqual(['l3', 'l2']); + expect(first.hasMore).toBe(true); + expect(first.total).toBe(3); + const second = await page(2); + expect(ids(second)).toEqual(['l1']); + expect(second.hasMore).toBe(false); + expect(second.total).toBe(3); + }); + + it('keeps total at or above what has been returned and what either side knows of', async () => { + const server = side([mail('s2', 20), mail('s1', 10)], 40); + const local = side([mail('l3', 30)], 50); + const result = await pager(server, local, 2)(0); + expect(result.total).toBe(50); + const noLocalLead = await pager(side([mail('s2', 20)], 40), side([mail('l3', 30)], 1), 2)(0); + expect(noLocalLead.total).toBe(41); + }); + + it('never repeats a local row the server later returns', async () => { + // Same time on both sides, split by the page edge: it shows once. + const server = side([mail('s', 20), mail('tie', 20)]); + const local = side([mail('tie', 20), mail('l', 5)]); + const page = pager(server, local, 1); + const seen = [...ids(await page(0))]; + seen.push(...ids(await page(seen.length))); + seen.push(...ids(await page(seen.length))); + expect(seen).toEqual(['s', 'tie', 'l']); + }); + + it('drops a row the server returns later if the store already showed it, and holds older local rows until the server catches up', async () => { + // The local copy of x looks newer than the server's, so it goes out first. + const server = side([mail('a', 30), mail('b', 25), mail('x', 20), mail('c', 10)]); + const local = side([mail('x', 40), mail('l', 5)]); + const page = pager(server, local, 2); + const first = await page(0); + expect(ids(first)).toEqual(['x', 'a']); + // The server's page is b and x. x has been shown, and the server has more, + // so l can't be placed yet: it may be on the server's next page. + const second = await page(2); + expect(ids(second)).toEqual(['b']); + expect(second.hasMore).toBe(true); + const third = await page(3); + expect(ids(third)).toEqual(['c', 'l']); + expect(third.hasMore).toBe(false); + }); + + it('follows a caller whose position has moved since the last page', async () => { + const server = side([mail('s4', 40), mail('s3', 30), mail('s2', 20), mail('s1', 10)]); + const local = side([mail('l5', 50)]); + const page = pager(server, local, 2); + await page(0); + // The caller added a row of its own to the list, as plugins can: one past. + await page(3); + expect(server.asked).toEqual([0, 2]); + }); + + it('starts over at position 0', async () => { + const server = side([mail('s2', 20), mail('s1', 10)]); + const local = side([mail('l3', 30)]); + const page = pager(server, local, 2); + await page(0); + expect(ids(await page(0))).toEqual(['l3', 's2']); + }); + + it('sends a position it has no cursor for to the server alone', async () => { + const server = side([mail('s2', 20), mail('s1', 10)]); + const local = side([mail('l3', 30)]); + const result = await pager(server, local, 2)(1); + expect(ids(result)).toEqual(['s1']); + expect(local.asked).toEqual([]); + }); + + it('keeps searches apart by key', async () => { + const merge = new LocalSearchMerge(); + const server = (rows: Email[]) => async (at: number) => ({ emails: rows.slice(at, at + 1), total: rows.length, hasMore: at + 1 < rows.length }); + const a = { key: 'a', limit: 1, server: server([mail('a2', 20), mail('a1', 10)]), local: async () => ({ emails: [mail('la', 30)], total: 1, hasMore: false }) }; + const b = { key: 'b', limit: 1, server: server([mail('b1', 10)]), local: async () => null }; + expect(ids(await merge.run({ ...a, position: 0 }))).toEqual(['la']); + expect(ids(await merge.run({ ...b, position: 0 }))).toEqual(['b1']); + expect(ids(await merge.run({ ...a, position: 1 }))).toEqual(['a2']); + }); +}); + +describe('errors', () => { + it('keeps the server result when the store fails', async () => { + const merge = new LocalSearchMerge(); + const result = await merge.run({ + key: 'k', limit: 10, position: 0, + server: async () => ({ emails: [mail('s', 1)], total: 1, hasMore: false }), + local: async () => { throw new Error('store broke'); }, + }); + expect(ids(result)).toEqual(['s']); + }); + + it('survives a store that fails before it can return a promise', async () => { + const merge = new LocalSearchMerge(); + const result = await merge.run({ + key: 'k', limit: 10, position: 0, + server: async () => ({ emails: [mail('s', 1)], total: 1, hasMore: false }), + local: () => { throw new Error('store broke'); }, + }); + expect(ids(result)).toEqual(['s']); + }); + + it('keeps the local result when the server fails', async () => { + const merge = new LocalSearchMerge(); + const result = await merge.run({ + key: 'k', limit: 10, position: 0, + server: async () => { throw new Error('server broke'); }, + local: async () => ({ emails: [mail('l', 1)], total: 1, hasMore: false }), + }); + expect(ids(result)).toEqual(['l']); + expect(result.total).toBe(1); + expect(result.hasMore).toBe(false); + }); + + it('asks the server again on the next page after it failed', async () => { + let fail = true; + const server = side([mail('s1', 10)]); + const merge = new LocalSearchMerge(); + const run = (position: number) => merge.run({ + key: 'k', limit: 1, position, + server: async (at) => { + if (fail) throw new Error('server broke'); + return server.answer(at, 1); + }, + local: async (at) => side([mail('l2', 20), mail('l1', 5)]).answer(at, 1), + }); + expect(ids(await run(0))).toEqual(['l2']); + fail = false; + expect(ids(await run(1))).toEqual(['s1']); + }); + + it('keeps the server error when the store has nothing to show', async () => { + const failing = async () => { throw new Error('server broke'); }; + await expect(new LocalSearchMerge().run({ + key: 'k', limit: 10, position: 0, server: failing, local: async () => ({ emails: [], total: 0, hasMore: false }), + })).rejects.toThrow('server broke'); + await expect(new LocalSearchMerge().run({ + key: 'k', limit: 10, position: 0, server: failing, local: async () => null, + })).rejects.toThrow('server broke'); + await expect(new LocalSearchMerge().run({ + key: 'k', limit: 10, position: 0, server: failing, local: async () => { throw new Error('store broke too'); }, + })).rejects.toThrow('server broke'); + }); + + it('keeps the error of a later page, so the user can try again', async () => { + let fail = false; + const merge = new LocalSearchMerge(); + const run = (position: number) => merge.run({ + key: 'k', limit: 1, position, + server: async (at) => { + if (fail) throw new Error('server broke'); + return side([mail('s2', 20), mail('s1', 10)]).answer(at, 1); + }, + local: async (at) => side([mail('l3', 30)]).answer(at, 1), + }); + expect(ids(await run(0))).toEqual(['l3']); + fail = true; + await expect(run(1)).rejects.toThrow('server broke'); + fail = false; + expect(ids(await run(1))).toEqual(['s2']); + }); + + describe('with a store that never answers', () => { + beforeEach(() => { vi.useFakeTimers(); }); + afterEach(() => { vi.useRealTimers(); }); + + it('does not hold the server result back for long', async () => { + const merge = new LocalSearchMerge(); + const answer = merge.run({ + key: 'k', limit: 10, position: 0, + server: async () => ({ emails: [mail('s', 1)], total: 1, hasMore: false }), + local: () => new Promise(() => {}), + }); + await vi.advanceTimersByTimeAsync(1_000); + expect(ids(await answer)).toEqual(['s']); + }); + + it('gives the server error once it has waited for the store', async () => { + const merge = new LocalSearchMerge(); + const answer = merge.run({ + key: 'k', limit: 10, position: 0, + server: async () => { throw new Error('server broke'); }, + local: () => new Promise(() => {}), + }); + const failed = expect(answer).rejects.toThrow('server broke'); + await vi.advanceTimersByTimeAsync(5_000); + await failed; + }); + }); +}); + +describe('filterHasTextTerms', () => { + it('is true for searches for text, in any shape', () => { + for (const filter of [ + { text: 'x' }, { from: 'x' }, { to: 'x' }, { cc: 'x' }, { bcc: 'x' }, { subject: 'x' }, { body: 'x' }, + { operator: 'AND', conditions: [{ inMailbox: 'i' }, { text: 'x' }] }, + { operator: 'OR', conditions: [{ to: 'x' }, { cc: 'x' }] }, + { operator: 'AND', conditions: [{ operator: 'OR', conditions: [{ hasKeyword: 'a' }, { body: 'x' }] }] }, + ]) expect(filterHasTextTerms(filter), JSON.stringify(filter)).toBe(true); + }); + + it('is false when nothing is searched for', () => { + for (const filter of [ + {}, { inMailbox: 'i' }, { hasKeyword: '$seen' }, { text: '' }, { text: ' ' }, + { operator: 'AND', conditions: [{ inMailbox: 'i' }, { hasAttachment: true }] }, + { operator: 'NOT', conditions: [{ text: 'x' }] }, + { operator: 'AND', conditions: [] }, + ]) expect(filterHasTextTerms(filter), JSON.stringify(filter)).toBe(false); + }); +}); + +describe('through the client, with a real store', () => { + const SERVER = 'https://mail.example.org'; + const SESSION = { + apiUrl: `${SERVER}/jmap/`, + downloadUrl: `${SERVER}/jmap/download/{accountId}/{blobId}/{name}?accept={type}`, + uploadUrl: `${SERVER}/jmap/upload/{accountId}/`, + accounts: { [JMAP_ACCOUNT]: { name: 'alice', isPersonal: true, accountCapabilities: {} } }, + primaryAccounts: { 'urn:ietf:params:jmap:mail': JMAP_ACCOUNT }, + capabilities: { 'urn:ietf:params:jmap:core': { maxObjectsInGet: 500 } }, + username: 'alice@example.org', + }; + + let serverRows: Array>; + let serverFails: boolean; + let requests: Array, string]>>; + + function fakeServerFetch() { + return vi.fn(async (url: string, init?: RequestInit) => { + if (String(url).endsWith('/.well-known/jmap')) return new Response(JSON.stringify(SESSION), { status: 200 }); + const { methodCalls } = JSON.parse(String(init?.body)) as { methodCalls: Array<[string, Record, string]> }; + requests.push(methodCalls); + if (serverFails) return new Response('broken', { status: 500 }); + const position = (methodCalls[0][1].position as number) ?? 0; + const limit = methodCalls[0][1].limit as number; + const page = serverRows.slice(position, position + limit); + return new Response(JSON.stringify({ + methodResponses: methodCalls.map(([name, , id]) => { + if (name === 'Email/query') return [name, { ids: page.map((row) => row.id), total: serverRows.length, position }, id]; + if (name === 'Email/get') return [name, { list: page, notFound: [] }, id]; + return [name, { list: [], notFound: [] }, id]; + }), + }), { status: 200 }); + }); + } + + async function storeEngine() { + const fake = new FakeJmap(); + fake.addEmail({ id: 'both', subject: 'Stromrechnung', receivedAt: '2026-10-05T00:00:00Z' }); + fake.addEmail({ id: 'only-local', subject: 'Zahlungserinnerungsrechnung', receivedAt: '2026-10-03T00:00:00Z', mailboxIds: { inbox: true } }); + fake.addEmail({ id: 'not-a-hit', subject: 'Hallo', receivedAt: '2026-10-02T00:00:00Z' }); + const engine = new AccountEngine({ + account: 'a@fake.test', + db: openMemoryStore(), + session: FAKE_SESSION, + transport: fake, + areas: [mailArea], + scope: { ...DEFAULT_SCOPE, days: null }, + now: () => Date.UTC(2026, 10, 3), + sleep: async () => {}, + }); + await engine.requestSync(); + return engine; + } + + function hooksWith(searchLocal: OfflineHooks['searchLocal'], skipNetwork = false): OfflineHooks { + return { + skipNetwork: () => skipNetwork, + answer: async () => null, + record: () => {}, + noteNetwork: () => {}, + serverGone: async () => false, + blob: async () => null, + recordBlob: () => {}, + holdUpload: async () => null, + searchLocal, + }; + } + + async function connected(): Promise { + const client = new JMAPClient(SERVER, 'alice@example.org', 'pw'); + await client.connect(); + return client; + } + + beforeEach(() => { + serverFails = false; + requests = []; + serverRows = [ + { id: 'newer', subject: 'Rechnung', receivedAt: '2026-10-06T00:00:00Z', mailboxIds: { inbox: true } }, + { id: 'both', subject: 'Stromrechnung, server copy', receivedAt: '2026-10-05T00:00:00Z', mailboxIds: { inbox: true } }, + ]; + vi.stubGlobal('fetch', fakeServerFetch()); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + setOfflineHooks(null); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('merges what the store finds into searchEmails, with its highlights', async () => { + const engine = await storeEngine(); + setOfflineHooks(hooksWith((_client, calls) => engine.answer(calls))); + const client = await connected(); + const result = await client.searchEmails('rechnung'); + expect(result.emails.map((email) => email.id)).toEqual(['newer', 'both', 'only-local']); + expect(result.emails[1].subject).toBe('Stromrechnung, server copy'); + expect(result.emails[2].searchSnippet?.subject).toBe('Zahlungserinnerungsrechnung'); + expect(result.total).toBe(3); + expect(result.hasMore).toBe(false); + }); + + it('merges into advancedSearchEmails too, and leaves the server request as it was', async () => { + const engine = await storeEngine(); + setOfflineHooks(hooksWith((_client, calls) => engine.answer(calls))); + const client = await connected(); + const filter = { operator: 'AND', conditions: [{ inMailbox: 'inbox' }, { text: 'rechnung' }] }; + const result = await client.advancedSearchEmails(filter); + expect(result.emails.map((email) => email.id)).toEqual(['newer', 'both', 'only-local']); + const query = requests[0][0]; + expect(query[0]).toBe('Email/query'); + expect(query[1]).toMatchObject({ filter, limit: 50, position: 0, calculateTotal: true, sort: [{ property: 'receivedAt', isAscending: false }] }); + }); + + it('shows what the store found when the server fails', async () => { + const engine = await storeEngine(); + setOfflineHooks(hooksWith((_client, calls) => engine.answer(calls))); + const client = await connected(); + serverFails = true; + const result = await client.searchEmails('rechnung'); + expect(result.emails.map((email) => email.id).sort()).toEqual(['both', 'only-local']); + }); + + it('shows what the server found when the store fails', async () => { + setOfflineHooks(hooksWith(async () => { throw new Error('store broke'); })); + const client = await connected(); + const result = await client.searchEmails('rechnung'); + expect(result.emails.map((email) => email.id)).toEqual(['newer', 'both']); + }); + + it('changes nothing when the store is off for the account', async () => { + const searchLocal = vi.fn(async () => null); + setOfflineHooks(hooksWith(searchLocal)); + const client = await connected(); + const result = await client.searchEmails('rechnung'); + expect(searchLocal).toHaveBeenCalledTimes(1); + expect(result).toEqual({ emails: expect.any(Array), total: 2, hasMore: false }); + expect(result.emails.map((email) => email.id)).toEqual(['newer', 'both']); + expect(requests.length).toBe(1); + }); + + it('changes nothing without a store', async () => { + setOfflineHooks(hooksWith(undefined)); + const client = await connected(); + const result = await client.searchEmails('rechnung', undefined, undefined, 50, 7); + expect(requests[0][0][1].position).toBe(7); + expect(result.total).toBe(2); + }); + + it('leaves filters without text to the server', async () => { + const searchLocal = vi.fn(async () => null); + setOfflineHooks(hooksWith(searchLocal)); + const client = await connected(); + await client.advancedSearchEmails({ inMailbox: 'inbox' }); + await client.advancedSearchEmails({}); + expect(searchLocal).not.toHaveBeenCalled(); + }); + + it('does not ask twice when the device is answering anyway', async () => { + const searchLocal = vi.fn(async () => null); + const hooks = hooksWith(searchLocal, true); + hooks.answer = async (_client, calls) => ({ + methodResponses: calls.map(([name, , id]) => (name === 'Email/query' + ? [name, { ids: [], total: 0, position: 0 }, id] + : [name, { list: [], notFound: [] }, id])), + }); + setOfflineHooks(hooks); + const client = await connected(); + await client.searchEmails('rechnung'); + expect(searchLocal).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 6d35dcdfa..6563320f0 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -9,6 +9,7 @@ import type { SieveScript, SieveCapabilities } from "./sieve-types"; import type { BlobBatch, CalendarEventUpdateOptions, CalendarQueryOptions, FileNodeListings, IJMAPClient, KeywordDiscoveryResult, KeywordInfo, KeywordMigration, SearchEmailsResult, SearchRequestOptions, UploadedEmailImport, UploadedEmailImportResult } from "./client-interface"; import { attachSearchSnippets, filterHasSnippetTerms, snippetFilterFor, type SearchSnippetResult } from "@/lib/search-snippet"; import { textConditions, textFilter } from "./search-utils"; +import { filterHasTextTerms, LocalSearchMerge } from "./search-merge"; import { batched, batchedBySize, itemsPerRequest } from "./request-limits"; import { keywordPointer, pointerToken } from "./patch-pointer"; import { FirstTouchGate } from "./first-touch-gate"; @@ -1638,6 +1639,7 @@ export class JMAPClient implements IJMAPClient { * can be reached again (a reload signs in as usual). */ private offlineOnly = false; + private readonly searchMerge = new LocalSearchMerge(); constructor(serverUrl: string, username: string, password: string) { this.serverUrl = serverUrl.replace(/\/$/, ''); @@ -4392,61 +4394,111 @@ export class JMAPClient implements IJMAPClient { } async searchEmails(query: string, mailboxId?: string, accountId?: string, limit: number = 50, position: number = 0, options: SearchRequestOptions = {}): Promise { + // The JMAP "text" filter searches from, to, cc, bcc, subject and body. + // Quoted phrases and `word*` prefixes go out as conditions of their + // own (see splitSearchText). inMailbox joins them in the same AND: a + // FilterOperator cannot also carry property keys. + const filter: Record = mailboxId + ? { operator: "AND", conditions: [{ inMailbox: mailboxId }, ...textConditions(query)] } + : textFilter(query); + return this.searchWithLocal(filter, accountId, limit, position, options); + } + + /** + * A search on the server, merged with what the local store finds (see + * search-merge.ts). Without a store for this client, or when the request + * would be answered on this device anyway, it's the server's answer alone. + */ + private searchWithLocal( + filter: Record, + accountId: string | undefined, + limit: number, + position: number, + options: SearchRequestOptions, + ): Promise { + const server = (at: number) => this.serverSearch(filter, accountId, limit, at, options); + const hooks = getOfflineHooks(); + const searchLocal = hooks?.searchLocal; + if (!searchLocal || this.offlineOnly || hooks.skipNetwork(this) || !filterHasTextTerms(filter)) { + return server(position); + } + return this.searchMerge.run({ + key: JSON.stringify([accountId ?? null, filter]), + limit, + position, + server, + local: async (at) => { + const targetAccountId = accountId || this.accountId; + const answer = await searchLocal.call(hooks, this, this.searchCalls(targetAccountId, filter, limit, at, false)); + return answer ? this.searchResultFrom(answer as JMAPResponse, accountId, limit, at) : null; + }, + }); + } + + private searchCalls( + targetAccountId: string, + filter: Record, + limit: number, + position: number, + deferSnippets: boolean | undefined, + ): JMAPMethodCall[] { + // Searching all folders with no criteria yields an empty FilterCondition, + // which servers reject; omit the key entirely to mean "no filter". + const hasFilter = Object.keys(filter).length > 0; + return [ + ["Email/query", { + accountId: targetAccountId, + ...(hasFilter ? { filter } : {}), + sort: [{ property: "receivedAt", isAscending: false }], + limit, + position, + calculateTotal: true, + }, "0"], + ["Email/get", { + accountId: targetAccountId, + "#ids": { resultOf: "0", name: "Email/query", path: "/ids" }, + properties: [...EMAIL_LIST_PROPERTIES], + }, "1"], + ...(deferSnippets ? [] : [this.searchSnippetCall(targetAccountId, hasFilter ? filter : undefined)]), + ]; + } + + private searchResultFrom(response: JMAPResponse, accountId: string | undefined, limit: number, position: number): SearchEmailsResult { + assertQuerySucceeded(response, 'Search'); + + const queryResponse = response.methodResponses?.[0]?.[1]; + const emails = applyListAuthenticationResults((response.methodResponses?.[1]?.[1]?.list || []) as Email[]); + emails.sort((a: Email, b: Email) => + new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime() + ); + const total = queryResponse?.total || 0; + const hasMore = computeHasMore(position, emails.length, total, limit); + attachSearchSnippets(emails, this.searchSnippetsFrom(response)); + + // Mirror getEmails: emails fetched from a delegated/shared account carry + // bare owner mailbox ids; namespace them to `${ownerId}:${id}` so they line + // up with the namespaced ids the store holds for shared mailboxes. The + // cross-account views (All mail / Unread / Starred) browse via + // advancedSearchEmails, so they need this too. (#281 V3) + if (accountId && accountId !== this.accountId) { + namespaceMailboxIds(emails, accountId); + } + return { emails, hasMore, total }; + } + + private async serverSearch( + filter: Record, + accountId: string | undefined, + limit: number, + position: number, + options: SearchRequestOptions, + ): Promise { try { const targetAccountId = accountId || this.accountId; - - // The JMAP "text" filter searches from, to, cc, bcc, subject and body. - // Quoted phrases and `word*` prefixes go out as conditions of their - // own (see splitSearchText). inMailbox joins them in the same AND: a - // FilterOperator cannot also carry property keys. - let filter: Record; - if (mailboxId) { - filter = { - operator: "AND", - conditions: [ - { inMailbox: mailboxId }, - ...textConditions(query), - ], - }; - } else { - filter = textFilter(query); - } - - const response = await this.request([ - ["Email/query", { - accountId: targetAccountId, - filter, - sort: [{ property: "receivedAt", isAscending: false }], - limit, - position, - calculateTotal: true, - }, "0"], - ["Email/get", { - accountId: targetAccountId, - "#ids": { resultOf: "0", name: "Email/query", path: "/ids" }, - properties: [...EMAIL_LIST_PROPERTIES], - }, "1"], - ...(options.deferSnippets ? [] : [this.searchSnippetCall(targetAccountId, filter)]), - ]); - assertQuerySucceeded(response, 'Search'); - - const queryResponse = response.methodResponses?.[0]?.[1]; - const emails = applyListAuthenticationResults((response.methodResponses?.[1]?.[1]?.list || []) as Email[]); - emails.sort((a: Email, b: Email) => - new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime() - ); - const total = queryResponse?.total || 0; - const hasMore = computeHasMore(position, emails.length, total, limit); - attachSearchSnippets(emails, this.searchSnippetsFrom(response)); - - // Mirror getEmails: emails fetched from a delegated/shared account carry - // bare owner mailbox ids; namespace them to `${ownerId}:${id}` so they line - // up with the namespaced ids the store holds for shared mailboxes. (#281 V3) - if (accountId && accountId !== this.accountId) { - namespaceMailboxIds(emails, accountId); - } - - return { emails, hasMore, total, ...this.deferredSnippets(options, targetAccountId, filter, emails) }; + const hasFilter = Object.keys(filter).length > 0; + const response = await this.request(this.searchCalls(targetAccountId, filter, limit, position, options.deferSnippets)); + const result = this.searchResultFrom(response, accountId, limit, position); + return { ...result, ...this.deferredSnippets(options, targetAccountId, hasFilter ? filter : undefined, result.emails) }; } catch (error) { // Rethrow: an empty result would read as "No results found". console.error('Search failed:', error); @@ -4518,52 +4570,7 @@ export class JMAPClient implements IJMAPClient { position: number = 0, options: SearchRequestOptions = {}, ): Promise { - try { - const targetAccountId = accountId || this.accountId; - - // Searching all folders with no criteria yields an empty FilterCondition, - // which servers reject; omit the key entirely to mean "no filter". - const hasFilter = Object.keys(filter).length > 0; - - const response = await this.request([ - ["Email/query", { - accountId: targetAccountId, - ...(hasFilter ? { filter } : {}), - sort: [{ property: "receivedAt", isAscending: false }], - limit, - position, - calculateTotal: true, - }, "0"], - ["Email/get", { - accountId: targetAccountId, - "#ids": { resultOf: "0", name: "Email/query", path: "/ids" }, - properties: [...EMAIL_LIST_PROPERTIES], - }, "1"], - ...(options.deferSnippets ? [] : [this.searchSnippetCall(targetAccountId, hasFilter ? filter : undefined)]), - ]); - assertQuerySucceeded(response, 'Search'); - - const queryResponse = response.methodResponses?.[0]?.[1]; - const emails = applyListAuthenticationResults((response.methodResponses?.[1]?.[1]?.list || []) as Email[]); - emails.sort((a: Email, b: Email) => - new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime() - ); - const total = queryResponse?.total || 0; - const hasMore = computeHasMore(position, emails.length, total, limit); - attachSearchSnippets(emails, this.searchSnippetsFrom(response)); - - // Namespace shared/delegated-account mailbox ids (see searchEmails). The - // cross-account views (All mail / Unread / Starred) browse via this method, - // so without it shared emails would carry bare owner ids there. (#281 V3) - if (accountId && accountId !== this.accountId) { - namespaceMailboxIds(emails, accountId); - } - - return { emails, hasMore, total, ...this.deferredSnippets(options, targetAccountId, hasFilter ? filter : undefined, emails) }; - } catch (error) { - console.error('Advanced search failed:', error); - throw error; - } + return this.searchWithLocal(filter, accountId, limit, position, options); } async getEmailFields( diff --git a/lib/jmap/offline-hooks.ts b/lib/jmap/offline-hooks.ts index ab0477fc9..b96dc1818 100644 --- a/lib/jmap/offline-hooks.ts +++ b/lib/jmap/offline-hooks.ts @@ -45,6 +45,12 @@ export interface OfflineHooks { recordBlob(client: OfflineClientRef, blobId: string, accountId: string, blob: Blob): void; /** Keep a file to upload later; the stand-in blob id, or null when offline mode doesn't cover the client. */ holdUpload(client: OfflineClientRef, file: File, accountId: string): Promise<{ blobId: string; size: number; type: string } | null>; + /** + * Search calls answered from the local store while the server is asked too + * (lib/jmap/search-merge.ts). Null when the store doesn't cover the client. + * Left out when there is no store, and searches then go to the server alone. + */ + searchLocal?(client: OfflineClientRef, methodCalls: OfflineMethodCall[]): Promise; } const KEY = '__bulwarkOfflineHooks'; diff --git a/lib/jmap/search-merge.ts b/lib/jmap/search-merge.ts new file mode 100644 index 000000000..a38cb53d1 --- /dev/null +++ b/lib/jmap/search-merge.ts @@ -0,0 +1,203 @@ +/** + * Online, a search also asks the local store and merges the two answers. The + * store finds what the server's index can't, such as a word inside a German + * compound. This module knows nothing about the store: it merges two lists. + * + * The merge walks both lists newest first, as a search sorts them. When both + * have a message, the server's copy is kept. A page never holds more than + * `limit` rows, so a local row can push a server row to the next page. + * + * Callers page by counting the rows they have, so `position` counts merged + * rows and means nothing to the server once a local row is among them. A cursor + * for each search remembers how far each side was read. A request past 0 with + * no cursor goes to the server alone, exactly as it would without a store. + * + * `hasMore` is true while either side has rows this search hasn't returned. + * A local list made only of rows the server also has can therefore cost one + * extra page that comes back empty, and then it's false. + * + * `total` is the server's total plus the local-only rows returned so far, and + * never less than the local total or the rows already returned. It can grow + * while paging, because how many local rows the server also has only shows + * when the lists are read. + * + * Local rows older than the last row the server sent wait for the next page: + * they may still be on the server's next page. + * + * An error on one side keeps the other side's rows. When the server fails and + * the store has nothing, the server's error stands, since an empty list would + * read as "no results". A slow store never holds up the server's answer for + * longer than LOCAL_GRACE_MS. + */ + +import { debug } from '@/lib/debug'; +import type { SearchEmailsResult } from './client-interface'; +import type { Email } from './types'; + +const LOCAL_GRACE_MS = 1_000; +const LOCAL_ALONE_MS = 5_000; +const MAX_CURSORS = 8; + +const TEXT_FILTER_KEYS = ['text', 'from', 'to', 'cc', 'bcc', 'subject', 'body']; + +/** + * Whether a filter searches for text. Other filters (a folder, a flag, a date) + * have the same answer on both sides, so only a stale local row could come of + * merging them. Terms under NOT are searched to be absent, so they don't count. + */ +export function filterHasTextTerms(filter: Record): boolean { + if (typeof filter.operator === 'string') { + if (filter.operator === 'NOT' || !Array.isArray(filter.conditions)) return false; + return (filter.conditions as Array>).some(filterHasTextTerms); + } + return TEXT_FILTER_KEYS.some((key) => typeof filter[key] === 'string' && (filter[key] as string).trim().length > 0); +} + +interface MergeCursor { + /** Merged rows returned so far, which is where the next page should ask from. */ + next: number; + serverPosition: number; + localPosition: number; + serverTotal: number; + /** Local-only rows returned so far. */ + extras: number; + /** Every row returned so far, so none appears twice. */ + shown: Set; +} + +function receivedTime(email: Email): number { + return Date.parse(email.receivedAt) || 0; +} + +interface PageMerge { + emails: Email[]; + serverRead: number; + localRead: number; + extras: number; +} + +/** One merged page. Rows are added to `shown`. */ +export function mergeSearchPages( + server: readonly Email[], + serverHasMore: boolean, + local: readonly Email[], + limit: number, + shown: Set, +): PageMerge { + const serverIds = new Set(server.map((email) => email.id)); + const emails: Email[] = []; + let s = 0; + let l = 0; + let extras = 0; + while (emails.length < limit) { + const fromServer = server[s]; + const fromLocal = local[l]; + if (fromLocal && (serverIds.has(fromLocal.id) || shown.has(fromLocal.id))) { + l += 1; + continue; + } + if (fromServer && shown.has(fromServer.id)) { + s += 1; + continue; + } + if (!fromServer && (!fromLocal || serverHasMore)) break; + if (fromServer && (!fromLocal || receivedTime(fromLocal) <= receivedTime(fromServer))) { + emails.push(fromServer); + shown.add(fromServer.id); + s += 1; + } else { + emails.push(fromLocal); + shown.add(fromLocal.id); + l += 1; + extras += 1; + } + } + return { emails, serverRead: s, localRead: l, extras }; +} + +/** The promise's value, or null if it fails or takes longer than `ms`. */ +function within(promise: Promise, ms: number): Promise { + return new Promise((resolve) => { + const timer = setTimeout(() => resolve(null), ms); + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (error) => { + clearTimeout(timer); + debug.warn('jmap', 'Local search failed:', error); + resolve(null); + }, + ); + }); +} + +export interface MergedSearchRequest { + /** The same for every page of one search. */ + key: string; + limit: number; + position: number; + server(position: number): Promise; + /** Null when the store doesn't cover this account. */ + local(position: number): Promise; +} + +export class LocalSearchMerge { + private readonly cursors = new Map(); + + private startCursor(key: string): MergeCursor { + const cursor: MergeCursor = { next: 0, serverPosition: 0, localPosition: 0, serverTotal: 0, extras: 0, shown: new Set() }; + this.cursors.delete(key); + this.cursors.set(key, cursor); + if (this.cursors.size > MAX_CURSORS) this.cursors.delete(this.cursors.keys().next().value as string); + return cursor; + } + + async run(request: MergedSearchRequest): Promise { + const { key, limit, position } = request; + const cursor = position === 0 ? this.startCursor(key) : this.cursors.get(key); + if (!cursor) return request.server(position); + + // A caller that removed rows or added its own asks from a little off + // where the last page ended, and the server's side moves with it. + const serverPosition = Math.max(0, cursor.serverPosition + position - cursor.next); + const localAnswer = Promise.resolve().then(() => request.local(cursor.localPosition)); + + let serverPage: SearchEmailsResult | null = null; + let serverError: unknown = null; + try { + serverPage = await request.server(serverPosition); + } catch (error) { + serverError = error; + } + const localPage = await within(localAnswer, serverPage ? LOCAL_GRACE_MS : LOCAL_ALONE_MS); + + if (!serverPage && !localPage?.emails.length) throw serverError; + if (serverPage && !localPage && cursor.extras === 0) { + // Nothing local in this search: the server's answer, untouched. + this.cursors.delete(key); + return serverPage; + } + + const server = serverPage?.emails ?? []; + const serverHasMore = serverPage?.hasMore ?? false; + const local = localPage?.emails ?? []; + const merged = mergeSearchPages(server, serverHasMore, local, limit, cursor.shown); + + cursor.serverPosition = serverPosition + merged.serverRead; + cursor.localPosition += merged.localRead; + cursor.next = position + merged.emails.length; + cursor.extras += merged.extras; + if (serverPage) cursor.serverTotal = serverPage.total; + + const serverLeft = merged.serverRead < server.length || serverHasMore; + const localLeft = localPage ? merged.localRead < local.length || localPage.hasMore : false; + return { + ...serverPage, + emails: merged.emails, + hasMore: serverLeft || localLeft, + total: Math.max(cursor.serverTotal + cursor.extras, localPage?.total ?? 0, cursor.next), + }; + } +} From fdeb7963bab486358179f1e1861cff6c6c0abe38 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 05:41:35 +0100 Subject: [PATCH 09/16] refactor: read event durations without loading the calendar UI --- components/calendar/event-card.tsx | 15 +-------------- lib/calendar-alerts.ts | 2 +- lib/calendar-event-normalization.ts | 15 +++++++++++++++ lib/calendar-utils.ts | 2 +- stores/calendar-store.ts | 2 +- 5 files changed, 19 insertions(+), 17 deletions(-) diff --git a/components/calendar/event-card.tsx b/components/calendar/event-card.tsx index d53f88b36..b7618622e 100644 --- a/components/calendar/event-card.tsx +++ b/components/calendar/event-card.tsx @@ -4,6 +4,7 @@ import { useCallback, useRef, useState, type CSSProperties, type DragEvent } fro import { useTranslations } from "next-intl"; import { cn } from "@/lib/utils"; import type { CalendarEvent, Calendar } from "@/lib/jmap/types"; +import { parseDuration } from "@/lib/calendar-event-normalization"; import { format } from "date-fns"; import { ChevronDown, ChevronUp, Users } from "@/components/icons"; import { getParticipantCount, isDeclinedByUser } from "@/lib/calendar-participants"; @@ -101,20 +102,6 @@ export function eventFillClass(tinted: boolean, inactive: boolean): string | und return tinted && !inactive ? "start-bar" : undefined; } -function parseDuration(duration: string | undefined): number { - if (!duration) return 0; - let totalMinutes = 0; - const weekMatch = duration.match(/(\d+)W/); - const hourMatch = duration.match(/(\d+)H/); - const minMatch = duration.match(/(\d+)M/); - const dayMatch = duration.match(/(\d+)D/); - if (weekMatch) totalMinutes += parseInt(weekMatch[1]) * 7 * 24 * 60; - if (dayMatch) totalMinutes += parseInt(dayMatch[1]) * 24 * 60; - if (hourMatch) totalMinutes += parseInt(hourMatch[1]) * 60; - if (minMatch) totalMinutes += parseInt(minMatch[1]); - return totalMinutes; -} - function createEventDragPreview(title: string, timeRange: string, color: string): HTMLElement { const el = document.createElement("div"); el.style.cssText = ` diff --git a/lib/calendar-alerts.ts b/lib/calendar-alerts.ts index bef0151e1..2c5e8ff96 100644 --- a/lib/calendar-alerts.ts +++ b/lib/calendar-alerts.ts @@ -7,7 +7,7 @@ import type { Calendar, CalendarTask, } from '@/lib/jmap/types'; -import { parseDuration } from '@/components/calendar/event-card'; +import { parseDuration } from '@/lib/calendar-event-normalization'; export interface PendingAlert { eventId: string; diff --git a/lib/calendar-event-normalization.ts b/lib/calendar-event-normalization.ts index 6dd31bf83..5305faa8e 100644 --- a/lib/calendar-event-normalization.ts +++ b/lib/calendar-event-normalization.ts @@ -39,6 +39,21 @@ export function parseDurationSeconds(duration: string | undefined): number | nul return (((weeks * 7 + days) * 24 + hours) * 60 + minutes) * 60 + seconds; } +/** A duration in whole minutes, 0 when there is none. */ +export function parseDuration(duration: string | undefined): number { + if (!duration) return 0; + let totalMinutes = 0; + const weekMatch = duration.match(/(\d+)W/); + const hourMatch = duration.match(/(\d+)H/); + const minMatch = duration.match(/(\d+)M/); + const dayMatch = duration.match(/(\d+)D/); + if (weekMatch) totalMinutes += parseInt(weekMatch[1]) * 7 * 24 * 60; + if (dayMatch) totalMinutes += parseInt(dayMatch[1]) * 24 * 60; + if (hourMatch) totalMinutes += parseInt(hourMatch[1]) * 60; + if (minMatch) totalMinutes += parseInt(minMatch[1]); + return totalMinutes; +} + export function normalizeAllDayDurationValue(duration: string | undefined): string | undefined { const totalSeconds = parseDurationSeconds(duration); if (totalSeconds === null || totalSeconds < 86400 || totalSeconds % 86400 !== 0) { diff --git a/lib/calendar-utils.ts b/lib/calendar-utils.ts index 2299fb54c..faff279c4 100644 --- a/lib/calendar-utils.ts +++ b/lib/calendar-utils.ts @@ -1,5 +1,5 @@ import { addDays, differenceInCalendarDays, parseISO, startOfDay, subMilliseconds } from "date-fns"; -import { parseDuration } from "@/components/calendar/event-card"; +import { parseDuration } from "@/lib/calendar-event-normalization"; import type { CalendarEvent } from "@/lib/jmap/types"; import { fromWallClock, isValidTimeZone, parseLocalDateTime, toDisplayDate } from "@/lib/timezone"; diff --git a/stores/calendar-store.ts b/stores/calendar-store.ts index d7f9c53f7..fceb9c4e7 100644 --- a/stores/calendar-store.ts +++ b/stores/calendar-store.ts @@ -5,7 +5,7 @@ import type { Calendar, CalendarEvent, CalendarParticipant, CalendarParticipantI import { debug } from '@/lib/debug'; import { getEventStartDate, normalizeAllDayDuration } from '@/lib/calendar-utils'; import { displayNow } from '@/lib/timezone'; -import { parseDuration } from '@/components/calendar/event-card'; +import { parseDuration } from '@/lib/calendar-event-normalization'; import { sanitizeOutgoingCalendarEventData } from '@/lib/calendar-event-normalization'; import { feedChanged, feedFields } from '@/lib/calendar-feed-diff'; import { expandRecurringEvents } from '@/lib/recurrence-expansion'; From 877a7a93b208cd33bd91cbcab5887a474d2cd266 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 05:42:01 +0100 Subject: [PATCH 10/16] feat: keep calendars on the device and queue calendar changes while offline Adds a `calendar` area to the local store. It syncs calendars, participant identities, events and tasks as masters, through CalendarEvent/changes, and answers Calendar/get, CalendarEvent/query, /get and /set from SQLite when the server can't be reached. - A range query gets series in both client modes, with their overrides, and the calendar store expands them as it does for an older server. - The synthetic-id probe gets the verdict from the last sync, so a client started offline behaves the same once the server is back. - Create, edit, delete and RSVP queue in `calendar_pending`, one merged row per event, and go out through `calendar.flush` or the next sync. A create finds itself by uid after a lost response. - A write the server refuses, or one on a value the server changed, goes to `calendar.conflicts`. - `lib/store/calendar/reminders.ts` lists upcoming alerts with keys that don't depend on synthetic ids. --- lib/recurrence-expansion.ts | 8 +- lib/recurrence-instances.ts | 2 +- .../__tests__/calendar-client-modes.test.ts | 212 ++++ lib/store/__tests__/calendar-no-dom.test.ts | 32 + lib/store/__tests__/calendar-sync.test.ts | 955 ++++++++++++++++++ lib/store/__tests__/helpers/client-wire.ts | 79 ++ lib/store/__tests__/helpers/fake-calendar.ts | 365 +++++++ lib/store/__tests__/helpers/fake-jmap.ts | 2 +- lib/store/area.ts | 5 +- lib/store/areas.ts | 3 +- lib/store/calendar/filter.ts | 220 ++++ lib/store/calendar/flush.ts | 229 +++++ lib/store/calendar/index.ts | 19 + lib/store/calendar/methods.ts | 197 ++++ lib/store/calendar/records.ts | 162 +++ lib/store/calendar/reminders.ts | 160 +++ lib/store/calendar/schema.ts | 77 ++ lib/store/calendar/sync.ts | 264 +++++ lib/store/calendar/writes.ts | 259 +++++ lib/store/engine/account-engine.ts | 2 +- .../calendar-store-offline-occurrence.test.ts | 117 +++ 21 files changed, 3359 insertions(+), 10 deletions(-) create mode 100644 lib/store/__tests__/calendar-client-modes.test.ts create mode 100644 lib/store/__tests__/calendar-no-dom.test.ts create mode 100644 lib/store/__tests__/calendar-sync.test.ts create mode 100644 lib/store/__tests__/helpers/client-wire.ts create mode 100644 lib/store/__tests__/helpers/fake-calendar.ts create mode 100644 lib/store/calendar/filter.ts create mode 100644 lib/store/calendar/flush.ts create mode 100644 lib/store/calendar/index.ts create mode 100644 lib/store/calendar/methods.ts create mode 100644 lib/store/calendar/records.ts create mode 100644 lib/store/calendar/reminders.ts create mode 100644 lib/store/calendar/schema.ts create mode 100644 lib/store/calendar/sync.ts create mode 100644 lib/store/calendar/writes.ts create mode 100644 stores/__tests__/calendar-store-offline-occurrence.test.ts diff --git a/lib/recurrence-expansion.ts b/lib/recurrence-expansion.ts index 90897c253..f8e0f4a33 100644 --- a/lib/recurrence-expansion.ts +++ b/lib/recurrence-expansion.ts @@ -210,7 +210,7 @@ function validDate(value: string | null | undefined): Date | null { } /** The wall-clock fields of a parsed LocalDateTime, read as UTC. */ -function wallAsUtc(wall: Date): number { +export function wallClockFieldsAsUtc(wall: Date): number { return Date.UTC( wall.getFullYear(), wall.getMonth(), wall.getDate(), wall.getHours(), wall.getMinutes(), wall.getSeconds(), @@ -236,7 +236,7 @@ function plausibleInstants( ): { start: Date; end: Date | null } | null { const wall = validDate(event.start); if (!wall) return null; - const wallStart = wallAsUtc(wall); + const wallStart = wallClockFieldsAsUtc(wall); const start = instantNear(event.utcStart, wallStart); if (!start) return null; const end = instantNear(event.utcEnd, wallStart + (parseDurationSeconds(event.duration) ?? 0) * 1000); @@ -288,10 +288,10 @@ function wallClockAsUtcTimestamp(formatter: Intl.DateTimeFormat, instant: number * and return the corresponding UTC instant. Two fixup iterations converge * for all real offsets, including across DST transitions. */ -function zonedWallTimeToUtc(wall: Date, timeZone: string): Date | null { +export function zonedWallTimeToUtc(wall: Date, timeZone: string): Date | null { const formatter = getTzFormatter(timeZone); if (!formatter) return null; - const wallMs = wallAsUtc(wall); + const wallMs = wallClockFieldsAsUtc(wall); let guess = wallMs; for (let i = 0; i < 2; i++) { guess = wallMs - (wallClockAsUtcTimestamp(formatter, guess) - guess); diff --git a/lib/recurrence-instances.ts b/lib/recurrence-instances.ts index 715895d11..562e7b9e7 100644 --- a/lib/recurrence-instances.ts +++ b/lib/recurrence-instances.ts @@ -341,7 +341,7 @@ export function applyOverridePatch>(event: T, p * replaces whatever the entry patches beneath it, and one beneath a value the * entry sets goes into that value. */ -function mergeOverrideEntry(entry: Record, updates: Record): Record { +export function mergeOverrideEntry(entry: Record, updates: Record): Record { const merged = { ...entry }; for (const [key, value] of Object.entries(updates)) { for (const existing of Object.keys(merged)) { diff --git a/lib/store/__tests__/calendar-client-modes.test.ts b/lib/store/__tests__/calendar-client-modes.test.ts new file mode 100644 index 000000000..35cb3ef7a --- /dev/null +++ b/lib/store/__tests__/calendar-client-modes.test.ts @@ -0,0 +1,212 @@ +// @vitest-environment node +import { afterEach, describe, expect, it } from 'vitest'; +import type { JMAPClient } from '@/lib/jmap/client'; +import type { CalendarEvent } from '@/lib/jmap/types'; +import { expandRecurringEvents } from '@/lib/recurrence-expansion'; +import { isBrowserExpandedOccurrence, isServerRecurrenceInstance } from '@/lib/recurrence-instances'; +import { calendarArea } from '../calendar'; +import { FakeCalendarJmap, type StalwartVersion } from './helpers/fake-calendar'; +import { wire, type Wire } from './helpers/client-wire'; + +const NOW = Date.UTC(2026, 10, 3, 12); +const RANGE = { after: '2026-11-02T00:00:00', before: '2026-11-23T00:00:00' }; + +let current: Wire | null = null; + +afterEach(() => { + current?.restore(); + current = null; +}); + +function seeded(version: StalwartVersion): Wire { + const server = new FakeCalendarJmap(version); + server.addEvent({ + id: 'series', + title: 'Weekly', + start: '2026-01-05T10:00:00', + recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'weekly' }, + recurrenceOverrides: { + '2026-11-09T10:00:00': { title: 'Special' }, + '2026-11-16T10:00:00': { excluded: true }, + }, + }); + server.addEvent({ id: 'single', title: 'One off', start: '2026-11-10T09:00:00' }); + current = wire(server, [calendarArea], NOW); + return current; +} + +/** What the calendar grid shows for a range: the client's query, then the store's expansion. */ +async function grid(client: JMAPClient, range = RANGE): Promise { + const raw = await client.queryAllCalendarEvents(range); + return expandRecurringEvents(raw, range.after, range.before); +} + +function summary(events: CalendarEvent[]): Array<[string, string]> { + return events.map((event): [string, string] => [event.start, event.title]).sort(); +} + +const SHOWN = [ + ['2026-11-02T10:00:00', 'Weekly'], + ['2026-11-09T10:00:00', 'Special'], + ['2026-11-10T09:00:00', 'One off'], +]; + +const expandedAsked = (w: Wire) => w.sent.some(([name, args]) => name === 'CalendarEvent/query' && args.expandRecurrences === true); + +describe('a client that probed online and keeps its mode', () => { + for (const version of ['1.0', '0.16'] as const) { + it(`synthetic-id mode on ${version}: the expanded query gets a correct grid`, async () => { + const w = seeded(version); + await w.engine.requestSync(); + const client = await w.newClient(); + await client.getCalendars(); + expect(await client.supportsSyntheticCalendarIds()).toBe(true); + const online = await grid(client); + expect(online.some(isServerRecurrenceInstance)).toBe(true); + expect(summary(online)).toEqual(SHOWN); + + w.online = false; + w.sent.length = 0; + const offline = await grid(client); + expect(expandedAsked(w)).toBe(true); + expect(summary(offline)).toEqual(SHOWN); + // Occurrences the calendar store can write back as overrides on the master. + const occurrences = offline.filter((event) => event.recurrenceId); + expect(occurrences.length).toBe(2); + for (const occurrence of occurrences) { + expect(isBrowserExpandedOccurrence(occurrence)).toBe(true); + expect(occurrence.originalId).toBe('series'); + } + // Back online, the same client still asks the server to expand. + w.online = true; + expect((await grid(client)).some(isServerRecurrenceInstance)).toBe(true); + }); + } + + it('client-expansion mode on an old server: never asks to expand, same grid', async () => { + const w = seeded('0.16.19'); + await w.engine.requestSync(); + const client = await w.newClient(); + await client.getCalendars(); + expect(await client.supportsSyntheticCalendarIds()).toBe(false); + w.online = false; + w.sent.length = 0; + expect(summary(await grid(client))).toEqual(SHOWN); + expect(expandedAsked(w)).toBe(false); + // The second get with the overrides went out, as the online path sends it. + const gets = w.sent.filter(([name]) => name === 'CalendarEvent/get'); + expect(gets.some(([, args]) => (args.properties as string[]).join() === 'id,recurrenceOverrides')).toBe(true); + }); + + it('gives the grid across a range longer than one expanded query may span', async () => { + const w = seeded('1.0'); + await w.engine.requestSync(); + const client = await w.newClient(); + await client.getCalendars(); + w.online = false; + w.sent.length = 0; + const range = { after: '2026-11-02T00:00:00', before: '2028-11-02T00:00:00' }; + const events = await grid(client, range); + const queries = w.sent.filter(([name]) => name === 'CalendarEvent/query'); + expect(queries.length).toBeGreaterThan(1); + expect(new Set(events.map((event) => event.id)).size).toBe(events.length); + expect(events.filter((event) => event.title === 'Weekly').length).toBeGreaterThan(90); + expect(events.filter((event) => event.title === 'Special')).toHaveLength(1); + }); +}); + +describe('a client started offline', () => { + it('takes the mode the server had at the last sync, so it behaves the same once the server is back', async () => { + const w = seeded('1.0'); + await w.engine.requestSync(); + w.online = false; + const client = await w.newClient(); + await client.getCalendars(); + expect(await client.supportsSyntheticCalendarIds()).toBe(true); + expect(summary(await grid(client))).toEqual(SHOWN); + w.online = true; + expect((await grid(client)).some(isServerRecurrenceInstance)).toBe(true); + }); + + it('does not ask to expand on a server that had no synthetic ids', async () => { + const w = seeded('0.16.19'); + await w.engine.requestSync(); + w.online = false; + w.sent.length = 0; + const client = await w.newClient(); + await client.getCalendars(); + expect(await client.supportsSyntheticCalendarIds()).toBe(false); + expect(summary(await grid(client))).toEqual(SHOWN); + expect(expandedAsked(w)).toBe(false); + }); + + it('uses client-side expansion before any sync, and a failed read does not look like an empty calendar', async () => { + const w = seeded('1.0'); + w.online = false; + const client = await w.newClient(); + expect(await client.supportsSyntheticCalendarIds()).toBe(false); + await expect(client.queryAllCalendarEvents(RANGE)).rejects.toThrow(); + }); +}); + +describe('reads and writes through the real client', () => { + it('reads back a created event under its stand-in id, then edits and deletes it', async () => { + const w = seeded('1.0'); + await w.engine.requestSync(); + const client = await w.newClient(); + await client.getCalendars(); + w.online = false; + + const created = await client.createCalendarEvent({ + title: 'Offline', + start: '2026-11-11T08:00:00', + duration: 'PT1H', + timeZone: 'Europe/Berlin', + calendarIds: { cal1: true }, + } as Partial); + expect(created.id).toMatch(/^local-/); + expect(created.title).toBe('Offline'); + expect(created.utcStart).toBe('2026-11-11T07:00:00Z'); + expect(summary(await grid(client))).toContainEqual(['2026-11-11T08:00:00', 'Offline']); + + await client.updateCalendarEvent(created.id, { title: 'Renamed' }); + expect(summary(await grid(client))).toContainEqual(['2026-11-11T08:00:00', 'Renamed']); + + await client.deleteCalendarEvent(created.id); + expect(summary(await grid(client))).toEqual(SHOWN); + expect(await client.getCalendarEvent(created.id)).toBeNull(); + expect(w.server.events.size).toBe(2); + }); + + it('fetches overrides with its one-event read', async () => { + const w = seeded('1.0'); + await w.engine.requestSync(); + const client = await w.newClient(); + w.online = false; + const event = await client.getCalendarEvent('series'); + expect(event?.recurrenceRules).toEqual([{ '@type': 'RecurrenceRule', frequency: 'weekly' }]); + expect(Object.keys(event?.recurrenceOverrides ?? {})).toHaveLength(2); + expect(event?.utcStart).toBe('2026-01-05T09:00:00Z'); + }); + + it('lists tasks through the shape fetchTaskObjects reads', async () => { + const w = seeded('1.0'); + w.server.addEvent({ id: 'task', '@type': 'Task', title: 'To do', start: undefined, due: '2026-11-12T10:00:00', progress: 'needs-action' }); + await w.engine.requestSync(); + const client = await w.newClient(); + w.online = false; + const tasks = await client.getCalendarTasks(); + expect(tasks.map((task) => [task.id, task.title, task.due])).toEqual([['task', 'To do', '2026-11-12T10:00:00']]); + expect(summary(await grid(client)).map(([, title]) => title)).not.toContain('To do'); + }); + + it('marks tasks-only calendars and reads identities from the last sync', async () => { + const w = seeded('1.0'); + await w.engine.requestSync(); + const client = await w.newClient(); + w.online = false; + const calendars = await client.getCalendars(); + expect(calendars.map((calendar) => [calendar.id, calendar.isTasksOnly])).toEqual([['cal1', false]]); + expect((await client.getParticipantIdentities())[0].calendarAddress).toBe('mailto:a@example.org'); + }); +}); diff --git a/lib/store/__tests__/calendar-no-dom.test.ts b/lib/store/__tests__/calendar-no-dom.test.ts new file mode 100644 index 000000000..9ee189b68 --- /dev/null +++ b/lib/store/__tests__/calendar-no-dom.test.ts @@ -0,0 +1,32 @@ +// @vitest-environment node +import path from 'node:path'; +import { build } from 'esbuild'; +import { describe, expect, it } from 'vitest'; + +const root = path.resolve(__dirname, '../../..'); + +async function inputsOf(entry: string): Promise { + const result = await build({ + entryPoints: [path.join(root, entry)], + bundle: true, + write: false, + format: 'esm', + platform: 'browser', + alias: { '@': root }, + metafile: true, + logLevel: 'silent', + // Stay away from the packages' own graph: only our files matter here. + packages: 'external', + }); + return Object.keys(result.metafile.inputs); +} + +describe('what the worker pulls in', () => { + for (const entry of ['lib/store/calendar/index.ts', 'lib/store/calendar/reminders.ts']) { + it(`${entry} reaches no UI code`, async () => { + const inputs = await inputsOf(entry); + expect(inputs.length).toBeGreaterThan(3); + expect(inputs.filter((file) => /(^|\/)(components|stores|hooks|app)\//.test(file))).toEqual([]); + }); + } +}); diff --git a/lib/store/__tests__/calendar-sync.test.ts b/lib/store/__tests__/calendar-sync.test.ts new file mode 100644 index 000000000..45ba37796 --- /dev/null +++ b/lib/store/__tests__/calendar-sync.test.ts @@ -0,0 +1,955 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; +import { DEFAULT_SCOPE, type StoreEvent } from '../area'; +import { calendarArea } from '../calendar'; +import { listUpcomingAlerts } from '../calendar/reminders'; +import { AccountEngine } from '../engine/account-engine'; +import { TransportError, type MethodCall } from '../engine/transport'; +import type { CalendarConflict } from '../calendar/writes'; +import { CAL, CALENDAR_SESSION, FakeCalendarJmap, type StalwartVersion } from './helpers/fake-calendar'; +import { JMAP_ACCOUNT } from './helpers/fake-jmap'; +import { openMemoryStore } from './helpers/node-adapter'; + +const NOW = Date.UTC(2026, 10, 3, 12); +const WEEKLY = { '@type': 'RecurrenceRule', frequency: 'weekly' }; + +function setup(version: StalwartVersion = '1.0', server = new FakeCalendarJmap(version)) { + const db = openMemoryStore(); + const events: StoreEvent[] = []; + const engine = new AccountEngine({ + account: 'a@fake.test', + db, + session: CALENDAR_SESSION, + transport: server, + areas: [calendarArea], + scope: DEFAULT_SCOPE, + emit: (event) => events.push(event), + now: () => NOW, + sleep: async () => {}, + }); + return { server, db, engine, events }; +} + +type Setup = ReturnType; + +async function call(s: Setup, name: string, args: Record) { + // A companion call: the engine answers null when nothing at all could be answered. + const response = await s.engine.answer([ + [name, { accountId: JMAP_ACCOUNT, ...args }, 'x'], + ['CalendarEventNotification/get', { accountId: JMAP_ACCOUNT, ids: [] }, 'y'], + ]); + return response!.methodResponses[0]; +} + +async function queryIds(s: Setup, args: Record): Promise { + const [name, result] = await call(s, 'CalendarEvent/query', args); + expect(name, JSON.stringify(result)).toBe('CalendarEvent/query'); + return result.ids; +} + +async function getOne(s: Setup, id: string, properties?: string[], timeZone?: string): Promise | undefined> { + const [, result] = await call(s, 'CalendarEvent/get', { ids: [id], ...(properties ? { properties } : {}), ...(timeZone ? { timeZone } : {}) }); + return result.list[0]; +} + +async function setEvent(s: Setup, args: Record) { + const [name, result] = await call(s, 'CalendarEvent/set', args); + expect(name).toBe('CalendarEvent/set'); + return result; +} + +const flush = (s: Setup) => s.engine.areaCall('calendar.flush', undefined) as Promise<{ sent: number; conflicts: number; pending: number }>; +const conflicts = (s: Setup) => s.engine.areaCall('calendar.conflicts', undefined) as Promise; +const pendingRows = (s: Setup) => s.db.all<{ kind: string; patch: string; record_id: string }>('SELECT kind, patch, record_id FROM calendar_pending'); +const setCalls = (server: FakeCalendarJmap): MethodCall[] => server.calls.filter(([name]) => name === 'CalendarEvent/set') + .filter(([, args]) => !(args.update && 'h333333' in (args.update as object))); + +describe('calendar sync', () => { + it('keeps a recurring event as its master across sync, with its overrides', async () => { + const s = setup(); + s.server.addEvent({ + id: 'series', + recurrenceRule: WEEKLY, + start: '2026-01-05T10:00:00', + recurrenceOverrides: { '2026-11-09T10:00:00': { title: 'Moved' }, '2026-11-16T10:00:00': { excluded: true } }, + }); + await s.engine.requestSync(); + expect(s.db.all('SELECT id FROM calendar_event').map((row) => row.id)).toEqual(['series']); + const stored = await getOne(s, 'series'); + expect(stored?.recurrenceRule).toEqual(WEEKLY); + expect(stored?.recurrenceOverrides).toEqual({ '2026-11-09T10:00:00': { title: 'Moved' }, '2026-11-16T10:00:00': { excluded: true } }); + }); + + it('asks for overrides without the utc properties, which 1.0 refuses next to them', async () => { + const s = setup('1.0'); + s.server.addEvent({ id: 'series', recurrenceRule: WEEKLY, recurrenceOverrides: { '2026-11-17T10:00:00': { title: 'x' } } }); + await s.engine.requestSync(); + const events = s.server.calls.filter(([name]) => name === 'CalendarEvent/get'); + const withOverrides = events.filter(([, args]) => (args.properties as string[]).includes('recurrenceOverrides')); + expect(withOverrides.length).toBeGreaterThan(0); + for (const [, args] of withOverrides) expect(args.properties).not.toContain('utcStart'); + expect(await getOne(s, 'series')).toMatchObject({ recurrenceOverrides: { '2026-11-17T10:00:00': { title: 'x' } } }); + }); + + it('never runs an expanded query or stores a synthetic id', async () => { + const s = setup('0.16'); + s.server.addEvent({ id: 'series', recurrenceRule: WEEKLY }); + s.server.addEvent({ id: 'single' }); + await s.engine.requestSync(); + expect(s.server.calls.filter(([, args]) => args.expandRecurrences)).toEqual([]); + expect(s.db.all('SELECT id FROM calendar_event ORDER BY id').map((row) => row.id)).toEqual(['series', 'single']); + }); + + it('pages past 500 objects', async () => { + const s = setup(); + for (let i = 0; i < 1234; i += 1) s.server.addEvent(); + await s.engine.requestSync(); + expect(Number(s.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM calendar_event')?.n)).toBe(1234); + expect(s.server.calls.filter(([name]) => name === 'CalendarEvent/query').length).toBe(3); + }); + + it('takes an edit of one occurrence from /changes as an update of the master', async () => { + const s = setup('1.0'); + s.server.addEvent({ id: 'series', recurrenceRule: WEEKLY, start: '2026-11-02T10:00:00' }); + await s.engine.requestSync(); + // Someone edits one occurrence through its synthetic id. + const [, expanded] = (await s.server.request([['CalendarEvent/query', { + accountId: JMAP_ACCOUNT, filter: { after: '2026-11-08T00:00:00', before: '2026-11-10T00:00:00' }, expandRecurrences: true, + }, 'q']])).methodResponses[0]; + expect(expanded.canCalculateChanges).toBe(false); + await s.server.request([['CalendarEvent/set', { accountId: JMAP_ACCOUNT, update: { [expanded.ids[0]]: { title: 'One off' } } }, 'u']]); + await s.engine.requestSync(); + expect(s.db.all('SELECT id FROM calendar_event').map((row) => row.id)).toEqual(['series']); + expect(await getOne(s, 'series')).toMatchObject({ + recurrenceOverrides: { '2026-11-09T10:00:00': { title: 'One off' } }, + }); + }); + + it('keeps old single events out and old series and tasks in, whatever their start', async () => { + const s = setup(); + s.server.addEvent({ id: 'ancient', start: '2020-01-01T10:00:00' }); + s.server.addEvent({ id: 'recent', start: '2025-06-01T10:00:00' }); + s.server.addEvent({ id: 'future', start: '2040-01-01T10:00:00' }); + s.server.addEvent({ id: 'old-series', start: '2015-01-01T10:00:00', recurrenceRule: WEEKLY }); + s.server.addEvent({ id: 'old-task', '@type': 'Task', start: undefined, due: '2015-01-01T10:00:00' }); + await s.engine.requestSync(); + expect(s.db.all('SELECT id FROM calendar_event ORDER BY id').map((row) => row.id)) + .toEqual(['future', 'old-series', 'old-task', 'recent']); + }); + + it('starts over when the log is gone, and drops what the server no longer has', async () => { + const s = setup(); + s.server.addEvent({ id: 'stays' }); + s.server.addEvent({ id: 'goes' }); + await s.engine.requestSync(); + s.server.events.delete('goes'); + s.server.eventLog.push('destroyed', 'goes'); + s.server.eventLog.truncatedBelow = s.server.eventLog.log.length; + await s.engine.requestSync(); + expect(s.db.all('SELECT id FROM calendar_event').map((row) => row.id)).toEqual(['stays']); + }); + + it('keeps ids of two JMAP accounts apart', async () => { + const s = setup(); + s.db.run( + "INSERT INTO calendar_event (jmap, id, json) VALUES ('other', 'same', ?)", + [JSON.stringify({ id: 'same', title: 'Theirs', start: '2026-11-10T10:00:00' })], + ); + s.server.addEvent({ id: 'same', title: 'Mine' }); + await s.engine.requestSync(); + expect((await getOne(s, 'same'))?.title).toBe('Mine'); + const row = s.db.get<{ json: string }>("SELECT json FROM calendar_event WHERE jmap = 'other' AND id = 'same'"); + expect(JSON.parse(row!.json).title).toBe('Theirs'); + }); + + it('stores calendars and participant identities and answers them with the real state', async () => { + const s = setup(); + await s.engine.requestSync(); + const [, calendars] = await call(s, 'Calendar/get', { properties: ['id', 'name'] }); + expect(calendars.list).toEqual([{ id: 'cal1', name: 'Personal' }]); + expect(calendars.state).toBe('cs1'); + const [, identities] = await call(s, 'ParticipantIdentity/get', {}); + expect(identities.list[0].calendarAddress).toBe('mailto:a@example.org'); + }); + + it('refuses to read before anything was kept, instead of saying there is nothing', async () => { + const s = setup(); + const [name, result] = await call(s, 'CalendarEvent/query', {}); + expect(name).toBe('error'); + expect(result.type).toBe('serverUnavailable'); + expect((await call(s, 'Calendar/get', {}))[0]).toBe('error'); + }); +}); + +describe('queries from the store', () => { + async function seeded() { + const s = setup(); + s.server.addEvent({ id: 'late', title: 'Late meeting', start: '2026-11-10T23:30:00', duration: 'PT1H', timeZone: 'Europe/Berlin' }); + s.server.addEvent({ + id: 'lunch', + title: 'Lünch with Bob', + description: 'Bring the slides', + start: '2026-11-11T12:00:00', + calendarIds: { cal2: true }, + locations: { l: { '@type': 'Location', name: 'Room Seven' } }, + organizerCalendarAddress: 'mailto:owner@example.org', + participants: { + p1: { '@type': 'Participant', name: 'Bob', calendarAddress: 'mailto:bob@example.org', roles: { attendee: true }, participationStatus: 'accepted' }, + p2: { '@type': 'Participant', name: 'Olga', calendarAddress: 'mailto:olga@example.org', roles: { owner: true }, participationStatus: 'declined' }, + }, + }); + s.server.addEvent({ id: 'series', title: 'Weekly', start: '2026-01-05T10:00:00', recurrenceRule: WEEKLY }); + s.server.addEvent({ id: 'ended', title: 'Ended', start: '2026-01-05T10:00:00', recurrenceRule: { ...WEEKLY, count: 2 } }); + s.server.addEvent({ id: 'task', '@type': 'Task', title: 'Task', start: undefined, due: '2026-11-12T10:00:00' }); + await s.engine.requestSync(); + return s; + } + + const ids = async (s: Setup, filter: unknown, extra: Record = {}) => (await queryIds(s, { filter, ...extra })).sort(); + + it('answers each filter the client can send', async () => { + const s = await seeded(); + expect(await ids(s, { inCalendar: 'cal2' })).toEqual(['lunch']); + expect(await ids(s, { inCalendars: ['cal2', 'nope'] })).toEqual(['lunch']); + expect(await ids(s, { title: 'lunch' })).toEqual(['lunch']); + expect(await ids(s, { description: 'slides' })).toEqual(['lunch']); + expect(await ids(s, { location: 'seven' })).toEqual(['lunch']); + expect(await ids(s, { owner: 'olga' })).toEqual(['lunch']); + expect(await ids(s, { owner: 'bob' })).toEqual([]); + expect(await ids(s, { attendee: 'bob' })).toEqual(['lunch']); + expect(await ids(s, { attendee: 'olga' })).toEqual([]); + expect(await ids(s, { participationStatus: 'declined' })).toEqual(['lunch']); + expect(await ids(s, { attendee: 'bob', participationStatus: 'declined' })).toEqual([]); + expect(await ids(s, { uid: 'lunch@example.org' })).toEqual(['lunch']); + expect(await ids(s, { uid: 'LUNCH@example.org' })).toEqual([]); + expect(await ids(s, { types: ['Task'] })).toEqual(['task']); + expect(await ids(s, { text: 'BOB' })).toEqual(['lunch']); + expect(await ids(s, { text: 'lunch bob' })).toEqual(['lunch']); + expect(await ids(s, { text: 'lunch nothing' })).toEqual([]); + }); + + it('answers operators', async () => { + const s = await seeded(); + expect(await ids(s, { operator: 'OR', conditions: [{ uid: 'late@example.org' }, { uid: 'task@example.org' }] })).toEqual(['late', 'task']); + expect(await ids(s, { operator: 'AND', conditions: [{ text: 'bob' }, { inCalendar: 'cal2' }] })).toEqual(['lunch']); + expect(await ids(s, { operator: 'NOT', conditions: [{ title: 'weekly' }, { title: 'ended' }, { title: 'late' }, { types: ['Task'] }] })).toEqual(['lunch']); + }); + + it('gives a per-call error for a filter it does not know', async () => { + const s = await seeded(); + const [name, result] = await call(s, 'CalendarEvent/query', { filter: { nonsense: 1 } }); + expect(name).toBe('error'); + expect(result.type).toBe('unsupportedFilter'); + }); + + it('reads bounds as wall clock in the request zone', async () => { + const s = await seeded(); + // 23:30 in Berlin on the 10th is 17:30 in New York, and 22:30 UTC. + const day10 = { after: '2026-11-10T00:00:00', before: '2026-11-11T00:00:00' }; + expect(await ids(s, day10, { timeZone: 'Europe/Berlin' })).toContain('late'); + const night = { after: '2026-11-10T22:45:00', before: '2026-11-10T23:00:00' }; + expect(await ids(s, night, { timeZone: 'Europe/Berlin' })).not.toContain('late'); + expect(await ids(s, night)).toContain('late'); + // A Z says nothing: Stalwart drops it and reads the wall clock. + expect(await ids(s, { after: '2026-11-10T22:45:00Z', before: '2026-11-10T23:00:00Z' }, { timeZone: 'Europe/Berlin' })).not.toContain('late'); + expect(await ids(s, { after: '2026-11-10T17:00:00', before: '2026-11-10T18:00:00' }, { timeZone: 'America/New_York' })).toContain('late'); + }); + + it('matches a series by any occurrence, once, and not when it has none in range', async () => { + const s = await seeded(); + const later = { after: '2026-11-16T00:00:00', before: '2026-11-17T00:00:00' }; + expect(await ids(s, later)).toEqual(['series']); + const bare = { operator: 'AND', conditions: [{ after: later.after }, { before: later.before }] }; + expect(await ids(s, bare)).toEqual(['series']); + // `ended` has two occurrences in January and none after. + expect(await ids(s, { after: '2026-01-05T00:00:00', before: '2026-01-06T00:00:00' })).toEqual(['ended', 'series']); + expect(await ids(s, { after: '2026-02-02T00:00:00', before: '2026-02-03T00:00:00' })).toEqual(['series']); + }); + + it('sorts, pages and counts', async () => { + const s = await seeded(); + const sort = [{ property: 'start', isAscending: false }]; + const [, first] = await call(s, 'CalendarEvent/query', { filter: { types: ['Event'] }, sort, limit: 2, calculateTotal: true }); + expect(first.ids).toEqual(['lunch', 'late']); + expect(first.total).toBe(4); + const [, second] = await call(s, 'CalendarEvent/query', { filter: { types: ['Event'] }, sort, limit: 2, position: 2 }); + expect(second.ids.sort()).toEqual(['ended', 'series']); + expect(first.canCalculateChanges).toBe(false); + }); + + it('returns utcStart for the request zone, and reads a floating event in it', async () => { + const s = setup(); + s.server.addEvent({ id: 'floating', start: '2026-11-10T10:00:00', timeZone: null, duration: 'PT1H' }); + s.server.addEvent({ id: 'zoned', start: '2026-11-10T10:00:00', timeZone: 'Europe/Berlin', duration: 'PT1H' }); + await s.engine.requestSync(); + expect(await getOne(s, 'floating', ['utcStart', 'utcEnd'], 'America/New_York')).toEqual({ + id: 'floating', utcStart: '2026-11-10T15:00:00Z', utcEnd: '2026-11-10T16:00:00Z', + }); + expect((await getOne(s, 'zoned', ['utcStart'], 'America/New_York'))?.utcStart).toBe('2026-11-10T09:00:00Z'); + }); + + it('answers the task list like the client reads it', async () => { + const s = await seeded(); + const list = await ids(s, undefined, { limit: 1000 }); + expect(list).toContain('task'); + const task = await getOne(s, 'task', ['id', '@type', 'due', 'progress', 'title']); + expect(task).toMatchObject({ '@type': 'Task', due: '2026-11-12T10:00:00', title: 'Task' }); + }); + + it('answers notifications with nothing rather than failing', async () => { + const s = await seeded(); + const response = await s.engine.answer([ + ['CalendarEventNotification/query', { accountId: JMAP_ACCOUNT, sort: [] }, '0'], + ['CalendarEventNotification/get', { accountId: JMAP_ACCOUNT, '#ids': { resultOf: '0', name: 'CalendarEventNotification/query', path: '/ids' } }, '1'], + ]); + expect(response!.methodResponses[1][1].list).toEqual([]); + }); + + it('refuses an expanded query without both bounds, as both servers do', async () => { + const s = await seeded(); + const [name, result] = await call(s, 'CalendarEvent/query', { filter: { after: '2026-11-16T00:00:00' }, expandRecurrences: true }); + expect(name).toBe('error'); + expect(result.type).toBe('invalidArguments'); + }); +}); + +describe('writes while offline', () => { + async function synced(version: StalwartVersion = '1.0') { + const s = setup(version); + s.server.addEvent({ + id: 'e1', + title: 'Meeting', + participants: { + me: { '@type': 'Participant', calendarAddress: 'mailto:a@example.org', participationStatus: 'needs-action', roles: { attendee: true } }, + bob: { '@type': 'Participant', calendarAddress: 'mailto:bob@example.org', participationStatus: 'accepted', roles: { attendee: true } }, + }, + }); + await s.engine.requestSync(); + s.server.calls.length = 0; + return s; + } + + it('merges edits of one event into one patch, and shows them at once', async () => { + const s = await synced(); + await setEvent(s, { update: { e1: { title: 'First' } } }); + await setEvent(s, { update: { e1: { description: 'Added' } } }); + await setEvent(s, { update: { e1: { title: 'Second' } } }); + expect(pendingRows(s)).toHaveLength(1); + expect(await getOne(s, 'e1')).toMatchObject({ title: 'Second', description: 'Added' }); + + await flush(s); + const sent = setCalls(s.server); + expect(sent).toHaveLength(1); + expect((sent[0][1].update as Record).e1).toEqual({ title: 'Second', description: 'Added' }); + expect(s.server.events.get('e1')).toMatchObject({ title: 'Second', description: 'Added' }); + expect(pendingRows(s)).toEqual([]); + }); + + it('merges a pointer into a value set whole, and a whole value over pointers beneath it', async () => { + const s = await synced(); + await setEvent(s, { update: { e1: { locations: { a: { name: 'One' } } } } }); + await setEvent(s, { update: { e1: { 'locations/b': { name: 'Two' } } } }); + expect(JSON.parse(pendingRows(s)[0].patch)).toEqual({ locations: { a: { name: 'One' }, b: { name: 'Two' } } }); + + const other = await synced(); + await setEvent(other, { update: { e1: { 'participants/me/participationStatus': 'accepted' } } }); + await setEvent(other, { update: { e1: { participants: { only: { name: 'Solo' } } } } }); + expect(JSON.parse(pendingRows(other)[0].patch)).toEqual({ participants: { only: { name: 'Solo' } } }); + }); + + it('queues an RSVP as a participant patch and sends it with scheduling on', async () => { + const s = await synced(); + const answer = await setEvent(s, { + update: { e1: { 'participants/me/participationStatus': 'accepted' } }, + sendSchedulingMessages: true, + }); + expect(answer.updated).toEqual({ e1: null }); + expect(s.server.events.get('e1')?.participants).toMatchObject({ me: { participationStatus: 'needs-action' } }); + const shown = await getOne(s, 'e1') as { participants: Record }; + expect(shown.participants.me.participationStatus).toBe('accepted'); + expect(shown.participants.bob.participationStatus).toBe('accepted'); + + await flush(s); + const [, args] = setCalls(s.server)[0]; + expect(args.sendSchedulingMessages).toBe(true); + expect(args.update).toEqual({ e1: { 'participants/me/participationStatus': 'accepted' } }); + expect(s.server.events.get('e1')?.participants).toMatchObject({ me: { participationStatus: 'accepted' }, bob: { participationStatus: 'accepted' } }); + }); + + it('keeps scheduling on when a later edit does not ask for it', async () => { + const s = await synced(); + await setEvent(s, { update: { e1: { 'participants/me/participationStatus': 'accepted' } }, sendSchedulingMessages: true }); + await setEvent(s, { update: { e1: { description: 'Note' } }, sendSchedulingMessages: false }); + await flush(s); + expect(setCalls(s.server)[0][1].sendSchedulingMessages).toBe(true); + }); + + it('survives a sync while pending: the server copy moves on underneath, the edit stays on top', async () => { + const s = await synced(); + await setEvent(s, { update: { e1: { title: 'Mine' } } }); + s.server.patchEvent('e1', { description: 'Theirs' }); + // The delta gets through, the write doesn't. + const realRequest = s.server.request.bind(s.server); + let refuseWrites = true; + s.server.request = async (calls: MethodCall[]) => { + if (refuseWrites && calls.some(([name, args]) => name === 'CalendarEvent/set' && args.update && 'e1' in (args.update as object))) { + throw new TransportError('network', 'Failed to fetch'); + } + return realRequest(calls); + }; + await s.engine.requestSync(); + expect(pendingRows(s)).toHaveLength(1); + expect(s.server.events.get('e1')).toMatchObject({ title: 'Meeting', description: 'Theirs' }); + expect(await getOne(s, 'e1')).toMatchObject({ title: 'Mine', description: 'Theirs' }); + + refuseWrites = false; + await s.engine.requestSync(); + expect(await conflicts(s)).toEqual([]); + expect(s.server.events.get('e1')).toMatchObject({ title: 'Mine', description: 'Theirs' }); + expect(await getOne(s, 'e1')).toMatchObject({ title: 'Mine', description: 'Theirs' }); + }); + + it('sends an edit that came in during a send, and takes its own first write for its own', async () => { + const s = await synced(); + await setEvent(s, { update: { e1: { title: 'One' } } }); + const realRequest = s.server.request.bind(s.server); + let once = true; + s.server.request = async (calls: MethodCall[]) => { + const response = await realRequest(calls); + if (once && calls.some(([name]) => name === 'CalendarEvent/set')) { + once = false; + await setEvent(s, { update: { e1: { title: 'Two', description: 'During' } } }); + } + return response; + }; + await flush(s); + expect(await conflicts(s)).toEqual([]); + expect(s.server.events.get('e1')).toMatchObject({ title: 'Two', description: 'During' }); + expect(pendingRows(s)).toEqual([]); + }); + + it('does not take an edit whose answer got lost for a conflict when it comes back through sync', async () => { + const s = await synced(); + await setEvent(s, { update: { e1: { title: 'Sent once' } } }); + s.server.loseNextSetResponse = true; + expect(await flush(s)).toMatchObject({ sent: 0, pending: 1 }); + expect(s.server.events.get('e1')?.title).toBe('Sent once'); + expect(await flush(s)).toMatchObject({ sent: 1, pending: 0 }); + expect(await conflicts(s)).toEqual([]); + }); + + it('gives out a stand-in id on create that later edits and deletes can use', async () => { + const s = await synced(); + const result = await setEvent(s, { create: { 'new-event': { title: 'Fresh', calendarIds: { cal1: true }, start: '2026-11-20T09:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin' } } }); + const id = result.created['new-event'].id as string; + expect(await getOne(s, id, ['title', 'utcStart', 'uid'])).toMatchObject({ title: 'Fresh', utcStart: '2026-11-20T08:00:00Z' }); + expect(await queryIds(s, { filter: { after: '2026-11-20T00:00:00', before: '2026-11-21T00:00:00' } })).toEqual([id]); + + await setEvent(s, { update: { [id]: { title: 'Fresher', 'locations/a': { name: 'Here' } } } }); + expect(pendingRows(s)).toHaveLength(1); + expect(pendingRows(s)[0].kind).toBe('create'); + expect(await getOne(s, id)).toMatchObject({ title: 'Fresher', locations: { a: { name: 'Here' } } }); + + await flush(s); + const creates = setCalls(s.server).filter(([, args]) => args.create); + expect(creates).toHaveLength(1); + expect(s.server.events.size).toBe(2); + const onServer = [...s.server.events.values()].find((event) => event.title === 'Fresher'); + expect(onServer?.locations).toEqual({ a: { name: 'Here' } }); + expect(await getOne(s, id)).toMatchObject({ id: onServer?.id, title: 'Fresher' }); + expect(s.db.get('SELECT 1 AS x FROM calendar_event WHERE id = ?', [id])).toBeUndefined(); + }); + + it('sends nothing for a create deleted again before it left', async () => { + const s = await synced(); + const result = await setEvent(s, { create: { c: { title: 'Gone', start: '2026-11-20T09:00:00', calendarIds: { cal1: true } } } }); + const id = result.created.c.id as string; + const gone = await setEvent(s, { destroy: [id] }); + expect(gone.destroyed).toEqual([id]); + expect(await getOne(s, id)).toBeUndefined(); + await flush(s); + expect(setCalls(s.server)).toEqual([]); + expect(s.server.events.size).toBe(1); + }); + + it('sends an edit then delete as just the delete, and hides it meanwhile', async () => { + const s = await synced(); + await setEvent(s, { update: { e1: { title: 'Doomed' } } }); + await setEvent(s, { destroy: ['e1'] }); + expect(pendingRows(s)[0].kind).toBe('destroy'); + expect(await getOne(s, 'e1')).toBeUndefined(); + expect(await queryIds(s, {})).toEqual([]); + await flush(s); + expect(setCalls(s.server).map(([, args]) => args.destroy)).toEqual([['e1']]); + expect(s.server.events.has('e1')).toBe(false); + expect(s.db.get('SELECT 1 AS x FROM calendar_event WHERE id = ?', ['e1'])).toBeUndefined(); + }); + + it('takes a delete of an event already gone as done', async () => { + const s = await synced(); + await setEvent(s, { destroy: ['e1'] }); + s.server.removeEvent('e1'); + await flush(s); + expect(await conflicts(s)).toEqual([]); + expect(pendingRows(s)).toEqual([]); + }); + + it('keeps writes across a run without a network and sends them after', async () => { + const s = await synced(); + await setEvent(s, { update: { e1: { title: 'Later' } } }); + s.server.offline = true; + const offline = await flush(s); + expect(offline).toMatchObject({ sent: 0, pending: 1 }); + expect(await getOne(s, 'e1')).toMatchObject({ title: 'Later' }); + s.server.offline = false; + expect(await flush(s)).toMatchObject({ sent: 1, pending: 0 }); + expect(s.server.events.get('e1')?.title).toBe('Later'); + }); + + it('flushes during a normal sync pass too', async () => { + const s = await synced(); + await setEvent(s, { update: { e1: { title: 'By sync' } } }); + await s.engine.requestSync(); + expect(s.server.events.get('e1')?.title).toBe('By sync'); + expect(pendingRows(s)).toEqual([]); + }); + + it('tells the page something changed', async () => { + const s = await synced(); + s.events.length = 0; + await setEvent(s, { update: { e1: { title: 'x' } } }); + expect(s.events).toContainEqual({ type: 'changed', account: 'a@fake.test', collections: ['CalendarEvent'] }); + }); +}); + +describe('a create and a lost response', () => { + const NEW = { title: 'Once', calendarIds: { cal1: true }, start: '2026-11-20T09:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin' }; + + async function queued() { + const s = setup(); + await s.engine.requestSync(); + s.server.calls.length = 0; + const result = await setEvent(s, { create: { c: NEW } }); + return { s, id: result.created.c.id as string, uid: result.created.c.uid as string }; + } + + it('gives the event a uid of its own before it goes out', async () => { + const { s, uid } = await queued(); + expect(uid).toBeTruthy(); + await flush(s); + expect([...s.server.events.values()][0].uid).toBe(uid); + }); + + it('keeps a uid the client chose', async () => { + const s = setup(); + await s.engine.requestSync(); + const result = await setEvent(s, { create: { c: { ...NEW, uid: 'mine@example.org' } } }); + expect(result.created.c.uid).toBe('mine@example.org'); + }); + + it('finds the event by uid instead of creating it again', async () => { + const { s, id, uid } = await queued(); + s.server.loseNextSetResponse = true; + expect(await flush(s)).toMatchObject({ sent: 0, pending: 1 }); + expect(s.server.events.size).toBe(1); + + expect(await flush(s)).toMatchObject({ sent: 1, pending: 0 }); + expect(s.server.events.size).toBe(1); + expect(setCalls(s.server).filter(([, args]) => args.create)).toHaveLength(1); + expect(s.server.calls.some(([name, args]) => name === 'CalendarEvent/query' && (args.filter as { uid?: string })?.uid === uid)).toBe(true); + const real = [...s.server.events.keys()][0]; + expect(await queryIds(s, {})).toEqual([real]); + expect(await getOne(s, id)).toMatchObject({ id: real }); + expect(await conflicts(s)).toEqual([]); + }); + + it('shows one event when a sync finds the created event before the flush does', async () => { + const { s } = await queued(); + s.server.loseNextSetResponse = true; + await flush(s); + await s.engine.requestSync(); + expect((await queryIds(s, {}))).toHaveLength(1); + expect(s.server.events.size).toBe(1); + }); + + it('creates it after all when the request never reached the server', async () => { + const { s } = await queued(); + // The attempt is marked, the network drops before it arrives. + const realRequest = s.server.request.bind(s.server); + let dropped = false; + s.server.request = async (calls: MethodCall[]) => { + if (!dropped && calls.some(([name, args]) => name === 'CalendarEvent/set' && args.create)) { + dropped = true; + throw new TransportError('network', 'Failed to fetch'); + } + return realRequest(calls); + }; + await flush(s); + expect(s.server.events.size).toBe(0); + expect(await flush(s)).toMatchObject({ sent: 1, pending: 0 }); + expect(s.server.events.size).toBe(1); + }); + + it('treats the uid refusal as "it exists already", should the mark have been lost', async () => { + const { s, uid } = await queued(); + s.db.run('UPDATE calendar_pending SET attempted = 0'); + s.server.addEvent({ ...NEW, uid, id: 'already' }); + await flush(s); + expect(s.server.events.size).toBe(1); + expect(await conflicts(s)).toEqual([]); + expect(await queryIds(s, {})).toEqual(['already']); + }); + + it('follows a create with its delete when it went out before the delete came', async () => { + const { s, id } = await queued(); + s.server.loseNextSetResponse = true; + await flush(s); + expect(s.server.events.size).toBe(1); + await setEvent(s, { destroy: [id] }); + expect(await queryIds(s, {})).toEqual([]); + await flush(s); + expect(s.server.events.size).toBe(0); + expect(pendingRows(s)).toEqual([]); + }); + + it('applies a write that came in during the send as a write of its own', async () => { + const { s, id } = await queued(); + const realRequest = s.server.request.bind(s.server); + s.server.request = async (calls: MethodCall[]) => { + const response = await realRequest(calls); + if (calls.some(([name, args]) => name === 'CalendarEvent/set' && args.create)) { + await setEvent(s, { update: { [id]: { title: 'Changed meanwhile' } } }); + } + return response; + }; + await flush(s); + expect([...s.server.events.values()][0].title).toBe('Changed meanwhile'); + expect(pendingRows(s)).toEqual([]); + }); +}); + +describe('conflicts', () => { + async function synced(version: StalwartVersion = '1.0') { + const s = setup(version); + s.server.addEvent({ id: 'e1', title: 'Meeting', recurrenceRule: WEEKLY }); + await s.engine.requestSync(); + s.server.calls.length = 0; + return s; + } + + it('lists a patch on a value the server changed too, and does not send it', async () => { + const s = await synced(); + await setEvent(s, { update: { e1: { title: 'Mine' } } }); + s.server.patchEvent('e1', { title: 'Theirs' }); + const result = await flush(s); + expect(setCalls(s.server)).toEqual([]); + expect(result).toMatchObject({ sent: 0, pending: 0 }); + const [conflict] = await conflicts(s); + expect(conflict).toMatchObject({ recordId: 'e1', kind: 'update', reason: 'changedOnServer', local: { title: 'Mine' } }); + expect(conflict.server).toMatchObject({ title: 'Theirs' }); + expect(s.server.events.get('e1')?.title).toBe('Theirs'); + expect(await getOne(s, 'e1')).toMatchObject({ title: 'Theirs' }); + }); + + it('lets an edit through when the server changed something else', async () => { + const s = await synced(); + await setEvent(s, { update: { e1: { title: 'Mine' } } }); + s.server.patchEvent('e1', { description: 'Theirs' }); + await flush(s); + expect(await conflicts(s)).toEqual([]); + expect(s.server.events.get('e1')).toMatchObject({ title: 'Mine', description: 'Theirs' }); + }); + + it('does not take the same value on both sides for a conflict', async () => { + const s = await synced(); + await setEvent(s, { update: { e1: { title: 'Same' } } }); + s.server.patchEvent('e1', { title: 'Same' }); + await flush(s); + expect(await conflicts(s)).toEqual([]); + }); + + it('lets an RSVP through when the organizer changed the title', async () => { + const s = setup(); + s.server.addEvent({ id: 'e1', participants: { me: { participationStatus: 'needs-action' } } }); + await s.engine.requestSync(); + await setEvent(s, { update: { e1: { 'participants/me/participationStatus': 'accepted' } } }); + s.server.patchEvent('e1', { title: 'New title' }); + await flush(s); + expect(await conflicts(s)).toEqual([]); + expect(s.server.events.get('e1')).toMatchObject({ title: 'New title', participants: { me: { participationStatus: 'accepted' } } }); + }); + + it('lists a patch the server refuses, with its reason, and goes on with the others', async () => { + const s = await synced(); + s.server.addEvent({ id: 'e2', title: 'Other' }); + await s.engine.requestSync(); + await setEvent(s, { update: { e1: { title: 'Refused' } } }); + await setEvent(s, { update: { e2: { title: 'Fine' } } }); + s.server.refuse.e1 = { type: 'forbidden', description: 'No rights' }; + const result = await flush(s); + expect(result).toMatchObject({ sent: 1, conflicts: 1, pending: 0 }); + const [conflict] = await conflicts(s); + expect(conflict).toMatchObject({ recordId: 'e1', reason: 'refused', errorType: 'forbidden', description: 'No rights', local: { title: 'Refused' } }); + expect(await getOne(s, 'e1')).toMatchObject({ title: 'Meeting' }); + expect(s.server.events.get('e2')?.title).toBe('Fine'); + }); + + it('lists an edit of an event the server deleted, and drops the event', async () => { + const s = await synced(); + await setEvent(s, { update: { e1: { title: 'Too late' } } }); + s.server.removeEvent('e1'); + await flush(s); + const [conflict] = await conflicts(s); + expect(conflict).toMatchObject({ reason: 'destroyedOnServer', local: { title: 'Too late' }, server: null }); + expect(await getOne(s, 'e1')).toBeUndefined(); + }); + + it('lists a delete of an event the server changed, and brings the event back', async () => { + const s = await synced(); + await setEvent(s, { destroy: ['e1'] }); + s.server.patchEvent('e1', { title: 'Still wanted' }); + await flush(s); + expect(s.server.events.has('e1')).toBe(true); + expect((await conflicts(s))[0]).toMatchObject({ kind: 'destroy', reason: 'changedOnServer' }); + expect(await getOne(s, 'e1')).toMatchObject({ title: 'Still wanted' }); + }); + + it('lists a create the server refuses and takes the stand-in away', async () => { + const s = await synced(); + const result = await setEvent(s, { create: { c: { title: 'Bad', calendarIds: { nope: true }, start: '2026-11-20T09:00:00' } } }); + s.server.refuse.c = { type: 'invalidProperties', description: 'calendarIds', properties: ['calendarIds'] }; + await flush(s); + const [conflict] = await conflicts(s); + expect(conflict).toMatchObject({ kind: 'create', reason: 'refused', local: { title: 'Bad' } }); + expect(await getOne(s, result.created.c.id)).toBeUndefined(); + expect(pendingRows(s)).toEqual([]); + }); + + it('retries without series keys when 1.0 refuses them inside an override, instead of a conflict', async () => { + const s = setup('1.0'); + s.server.addEvent({ id: 'e1', recurrenceRule: WEEKLY, privacy: 'private' }); + await s.engine.requestSync(); + s.server.calls.length = 0; + await setEvent(s, { update: { e1: { recurrenceOverrides: { '2026-11-17T10:00:00': { title: 'One', privacy: 'private' } } } } }); + await flush(s); + expect(await conflicts(s)).toEqual([]); + expect(setCalls(s.server)).toHaveLength(2); + expect(s.server.events.get('e1')?.recurrenceOverrides).toEqual({ '2026-11-17T10:00:00': { title: 'One' } }); + }); + + it('keeps a write for later when the server has a bad moment', async () => { + const s = await synced(); + await setEvent(s, { update: { e1: { title: 'Soon' } } }); + const realRequest = s.server.request.bind(s.server); + let failed = false; + s.server.request = async (calls: MethodCall[]) => { + if (!failed && calls.some(([name]) => name === 'CalendarEvent/set')) { + failed = true; + return { methodResponses: [['error', { type: 'serverFail' }, 's']] }; + } + return realRequest(calls); + }; + expect(await flush(s)).toMatchObject({ sent: 0, conflicts: 0, pending: 1 }); + expect(await conflicts(s)).toEqual([]); + expect(await flush(s)).toMatchObject({ sent: 1, pending: 0 }); + }); + + it('lists a method error that means the write will not work', async () => { + const s = await synced(); + await setEvent(s, { update: { e1: { title: 'x' } } }); + const realRequest = s.server.request.bind(s.server); + s.server.request = async (calls: MethodCall[]) => { + if (calls.some(([name]) => name === 'CalendarEvent/set')) return { methodResponses: [['error', { type: 'accountReadOnly' }, 's']] }; + return realRequest(calls); + }; + expect(await flush(s)).toMatchObject({ conflicts: 1, pending: 0 }); + expect((await conflicts(s))[0].errorType).toBe('accountReadOnly'); + }); + + it('can dismiss a conflict', async () => { + const s = await synced(); + await setEvent(s, { update: { e1: { title: 'Mine' } } }); + s.server.patchEvent('e1', { title: 'Theirs' }); + await flush(s); + const [conflict] = await conflicts(s); + await s.engine.areaCall('calendar.dismissConflict', { id: conflict.id }); + expect(await conflicts(s)).toEqual([]); + }); + + it('keeps a pending edit through a resync, and lists it when the event is gone', async () => { + const s = await synced(); + s.server.addEvent({ id: 'e2', title: 'Stays' }); + await s.engine.requestSync(); + await setEvent(s, { update: { e1: { title: 'Mine' } } }); + await setEvent(s, { update: { e2: { title: 'Mine too' } } }); + s.server.events.delete('e1'); + s.server.eventLog.push('destroyed', 'e1'); + s.server.eventLog.truncatedBelow = s.server.eventLog.log.length; + await s.engine.requestSync(); + const [conflict] = await conflicts(s); + expect(conflict).toMatchObject({ recordId: 'e1', reason: 'destroyedOnServer' }); + expect(s.server.events.get('e2')?.title).toBe('Mine too'); + }); +}); + +describe('the synthetic-id probe', () => { + const probe = { update: { h333333: {} } }; + + it('tells a client what the server said at the last sync', async () => { + for (const version of ['1.0', '0.16'] as const) { + const s = setup(version); + await s.engine.requestSync(); + expect((await setEvent(s, probe)).notUpdated.h333333.type).toBe('notFound'); + } + const old = setup('0.16.19'); + await old.engine.requestSync(); + const refusal = (await setEvent(old, probe)).notUpdated.h333333; + expect(refusal.type).toBe('invalidProperties'); + expect(refusal.description).toMatch(/synthetic ids is not yet supported/); + }); + + it('says not supported before any sync, because client-side expansion works on every server', async () => { + const s = setup(); + const refusal = (await setEvent(s, probe)).notUpdated.h333333; + expect(refusal.type).toBe('invalidProperties'); + }); + + it('queues nothing for the probe', async () => { + const s = setup(); + await s.engine.requestSync(); + await setEvent(s, probe); + expect(pendingRows(s)).toEqual([]); + }); + + it('runs the probe on the sync request, and follows a server that was upgraded', async () => { + const s = setup('0.16.19'); + await s.engine.requestSync(); + expect(s.server.requests).toBeGreaterThan(0); + expect((await setEvent(s, probe)).notUpdated.h333333.type).toBe('invalidProperties'); + Object.defineProperty(s.server, 'version', { value: '1.0' }); + await s.engine.requestSync(); + expect((await setEvent(s, probe)).notUpdated.h333333.type).toBe('notFound'); + }); +}); + +describe('occurrence writes for ids the store does not hold', () => { + it('refuses them the way an old server does, so the client writes on the base event', async () => { + const s = setup(); + s.server.addEvent({ id: 'series', recurrenceRule: WEEKLY }); + await s.engine.requestSync(); + const update = await setEvent(s, { update: { h20261110series: { title: 'x' } } }); + expect(update.notUpdated.h20261110series.description).toMatch(/synthetic ids?\b.*\bnot (?:yet )?supported/i); + const destroy = await setEvent(s, { destroy: ['h20261110series'] }); + expect(destroy.notDestroyed.h20261110series.description).toMatch(/synthetic ids?\b.*\bnot (?:yet )?supported/i); + expect(pendingRows(s)).toEqual([]); + }); +}); + +describe('reminders from stored events', () => { + const ALERT = { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } }; + const FROM = Date.UTC(2026, 10, 10); + const TO = Date.UTC(2026, 10, 11); + const OPTIONS = { from: FROM, to: TO, timeZone: 'Europe/Berlin' }; + + it('lists an occurrence of a series, keyed by the master, with the time from its own zone', async () => { + const s = setup(); + s.server.addEvent({ id: 'series', start: '2026-01-06T10:00:00', recurrenceRule: WEEKLY, alerts: { a: ALERT } }); + await s.engine.requestSync(); + const alerts = listUpcomingAlerts(s.db, OPTIONS); + expect(alerts).toHaveLength(1); + const fire = Date.UTC(2026, 10, 10, 8, 45); + expect(alerts[0]).toMatchObject({ + baseEventId: 'series', alertId: 'a', fireTimeMs: fire, recurrenceId: '2026-11-10T10:00:00', key: `series:a:${fire}`, kind: 'event', jmap: JMAP_ACCOUNT, + }); + }); + + it('follows a moved occurrence and skips an excluded one', async () => { + const s = setup(); + s.server.addEvent({ + id: 'moved', + start: '2026-01-06T10:00:00', + recurrenceRule: WEEKLY, + alerts: { a: ALERT }, + recurrenceOverrides: { '2026-11-10T10:00:00': { start: '2026-11-10T15:00:00' } }, + }); + s.server.addEvent({ + id: 'excluded', + start: '2026-01-06T10:00:00', + recurrenceRule: WEEKLY, + alerts: { a: ALERT }, + recurrenceOverrides: { '2026-11-10T10:00:00': { excluded: true } }, + }); + await s.engine.requestSync(); + const alerts = listUpcomingAlerts(s.db, OPTIONS); + expect(alerts.map((alert) => [alert.baseEventId, alert.fireTimeMs])).toEqual([['moved', Date.UTC(2026, 10, 10, 13, 45)]]); + }); + + it('finds an alert days before its occurrence, and a fixed-time alert of a series', async () => { + const s = setup(); + s.server.addEvent({ + id: 'days', + start: '2026-11-13T10:00:00', + alerts: { a: { ...ALERT, trigger: { '@type': 'OffsetTrigger', offset: '-P3D' } } }, + }); + s.server.addEvent({ + id: 'days-series', + start: '2026-01-06T10:00:00', + recurrenceRule: { frequency: 'yearly', byMonth: ['11'], byMonthDay: [13] }, + alerts: { a: { ...ALERT, trigger: { '@type': 'OffsetTrigger', offset: '-P3D' } } }, + }); + s.server.addEvent({ + id: 'fixed', + start: '2026-01-06T10:00:00', + recurrenceRule: WEEKLY, + alerts: { a: { ...ALERT, trigger: { '@type': 'AbsoluteTrigger', when: '2026-11-10T06:00:00Z' } } }, + }); + await s.engine.requestSync(); + const alerts = listUpcomingAlerts(s.db, OPTIONS); + expect(alerts.map((alert) => alert.baseEventId).sort()).toEqual(['days', 'days-series', 'fixed']); + expect(alerts.filter((alert) => alert.baseEventId === 'fixed')).toHaveLength(1); + }); + + it('uses the calendar defaults while the event does, and reads a floating event in the given zone', async () => { + const s = setup(); + s.server.addEvent({ id: 'def', start: '2026-11-10T10:00:00', timeZone: null, useDefaultAlerts: true }); + await s.engine.requestSync(); + const [alert] = listUpcomingAlerts(s.db, OPTIONS); + expect(alert).toMatchObject({ baseEventId: 'def', alertId: 'd1', calendarName: 'Personal', fireTimeMs: Date.UTC(2026, 10, 10, 8, 45) }); + expect(listUpcomingAlerts(s.db, { ...OPTIONS, timeZone: 'UTC' })[0].fireTimeMs).toBe(Date.UTC(2026, 10, 10, 9, 45)); + }); + + it('leaves out what should not fire, and what is outside the window', async () => { + const s = setup(); + s.server.addEvent({ id: 'email', alerts: { a: { ...ALERT, action: 'email' } } }); + s.server.addEvent({ id: 'seen', alerts: { a: { ...ALERT, acknowledged: '2026-11-01T00:00:00Z' } } }); + s.server.addEvent({ id: 'off', status: 'cancelled', alerts: { a: ALERT } }); + s.server.addEvent({ id: 'later', start: '2026-12-10T10:00:00', alerts: { a: ALERT } }); + s.server.addEvent({ id: 'task-done', '@type': 'Task', start: undefined, due: '2026-11-10T10:00:00', progress: 'completed', alerts: { a: ALERT } }); + await s.engine.requestSync(); + expect(listUpcomingAlerts(s.db, OPTIONS)).toEqual([]); + }); + + it('lists a task, and an edit or a delete made offline', async () => { + const s = setup(); + s.server.addEvent({ id: 'task', '@type': 'Task', start: undefined, due: '2026-11-10T10:00:00Z', alerts: { a: ALERT } }); + s.server.addEvent({ id: 'moved', start: '2026-12-10T10:00:00', alerts: { a: ALERT } }); + s.server.addEvent({ id: 'dropped', start: '2026-11-10T11:00:00', alerts: { a: ALERT } }); + await s.engine.requestSync(); + await setEvent(s, { update: { moved: { start: '2026-11-10T09:00:00' } } }); + await setEvent(s, { destroy: ['dropped'] }); + expect(listUpcomingAlerts(s.db, OPTIONS).map((alert) => [alert.baseEventId, alert.kind])).toEqual([['moved', 'event'], ['task', 'task']]); + }); +}); + +it('uses only capabilities the session lists', async () => { + const s = setup(); + s.server.addEvent(); + const usings: string[][] = []; + const realRequest = s.server.request.bind(s.server) as (calls: MethodCall[]) => ReturnType; + s.server.request = ((calls: MethodCall[], using: string[]) => { + usings.push(using); + return realRequest(calls); + }) as FakeCalendarJmap['request']; + await s.engine.requestSync(); + for (const using of usings) expect(using).toEqual(['urn:ietf:params:jmap:core', CAL]); +}); diff --git a/lib/store/__tests__/helpers/client-wire.ts b/lib/store/__tests__/helpers/client-wire.ts new file mode 100644 index 000000000..d3c9860b7 --- /dev/null +++ b/lib/store/__tests__/helpers/client-wire.ts @@ -0,0 +1,79 @@ +/** + * A real JMAPClient talking to a FakeCalendarJmap while "online" and to the + * store's answers while "offline", through a stubbed fetch. + */ + +import { vi } from 'vitest'; +import { JMAPClient } from '@/lib/jmap/client'; +import { DEFAULT_SCOPE, type StoreArea } from '../../area'; +import { AccountEngine } from '../../engine/account-engine'; +import type { MethodCall } from '../../engine/transport'; +import { CALENDAR_SESSION, FakeCalendarJmap } from './fake-calendar'; +import { JMAP_ACCOUNT } from './fake-jmap'; +import { openMemoryStore } from './node-adapter'; + +const CLIENT_SESSION = { + ...CALENDAR_SESSION, + primaryAccounts: { ...CALENDAR_SESSION.primaryAccounts, 'urn:ietf:params:jmap:mail': JMAP_ACCOUNT }, + accounts: { + [JMAP_ACCOUNT]: { + name: 'a@example.org', + isPersonal: true, + accountCapabilities: { ...CALENDAR_SESSION.accounts[JMAP_ACCOUNT].accountCapabilities, 'urn:ietf:params:jmap:mail': {} }, + }, + }, + capabilities: { ...CALENDAR_SESSION.capabilities, 'urn:ietf:params:jmap:mail': {} }, + eventSourceUrl: '', +}; + +function json(body: unknown): Response { + return new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } }); +} + +export interface Wire { + server: FakeCalendarJmap; + engine: AccountEngine; + db: ReturnType; + /** Everything the client sent, wherever it went. */ + sent: MethodCall[]; + online: boolean; + newClient(): Promise; + restore(): void; +} + +export function wire(server: FakeCalendarJmap, areas: readonly StoreArea[], now: number): Wire { + const db = openMemoryStore(); + const engine = new AccountEngine({ + account: 'a@fake.test', + db, + session: CALENDAR_SESSION, + transport: server, + areas, + scope: DEFAULT_SCOPE, + now: () => now, + sleep: async () => {}, + }); + const spy = vi.spyOn(globalThis, 'fetch'); + const state: Wire = { + server, + engine, + db, + sent: [], + online: true, + newClient: async () => { + const client = new JMAPClient('https://fake.test', 'a@example.org', 'pw'); + await client.connect(); + return client; + }, + restore: () => spy.mockRestore(), + }; + spy.mockImplementation(async (_url: RequestInfo | URL, init?: RequestInit) => { + if (!init?.body) return json(CLIENT_SESSION); + const calls = (JSON.parse(String(init.body)) as { methodCalls: MethodCall[] }).methodCalls; + state.sent.push(...calls); + const answer = state.online ? await server.request(calls) : await engine.answer(calls); + if (!answer) throw new TypeError('Failed to fetch'); + return json(answer); + }); + return state; +} diff --git a/lib/store/__tests__/helpers/fake-calendar.ts b/lib/store/__tests__/helpers/fake-calendar.ts new file mode 100644 index 000000000..752017d57 --- /dev/null +++ b/lib/store/__tests__/helpers/fake-calendar.ts @@ -0,0 +1,365 @@ +/** + * FakeJmap with calendars, modelled on what was measured on both Stalwarts + * (local-data/desktop-offline-facts-parts/stalwart-changes-prf.md section 7, and + * the probe run in desktop-offline-decisions.md): + * + * - /changes lists masters only. An edit through a synthetic id gives + * `updated: [master]`. + * - A plain query returns masters. An expanded one needs both bounds, says + * canCalculateChanges false on 1.0 and true on 0.16, refuses more than P52W1D + * on 1.0, and gives a single event a synthetic id on 0.16 only. + * - 1.0 refuses recurrenceOverrides next to utcStart in one get, and series keys + * inside an override. `recurrenceRules` is an invalid property on both. + * - A pointer into overrides fails while a series has none. + * - A second event with one uid is refused, uid filters match exactly. + */ + +import { normalizeCalendarEventLike } from '@/lib/calendar-event-normalization'; +import { applyPatchObject } from '@/lib/jmap/patch-object'; +import type { CalendarEvent } from '@/lib/jmap/types'; +import { expandRecurringEvents } from '@/lib/recurrence-expansion'; +import { SYNTHETIC_ID_PROBE } from '@/lib/recurrence-instances'; +import { TransportError, type JmapResponse, type MethodCall, type MethodResponse, type StoreSession } from '../../engine/transport'; +import { Collection, FakeJmap, JMAP_ACCOUNT } from './fake-jmap'; + +export const CAL = 'urn:ietf:params:jmap:calendars'; + +export const CALENDAR_SESSION: StoreSession = { + apiUrl: 'https://fake.test/jmap/', + downloadUrl: 'https://fake.test/d/{accountId}/{blobId}/{name}?type={type}', + uploadUrl: 'https://fake.test/u/{accountId}', + primaryAccounts: { [CAL]: JMAP_ACCOUNT }, + accounts: { + [JMAP_ACCOUNT]: { name: 'a@example.org', isPersonal: true, accountCapabilities: { [CAL]: { maxExpandedQueryDuration: 'P52W1D' } } }, + }, + capabilities: { 'urn:ietf:params:jmap:core': {}, [CAL]: {} }, +}; + +export type FakeEvent = Record & { id: string }; +export type StalwartVersion = '1.0' | '0.16' | '0.16.19'; + +function jmapError(type: string, description?: string): Error { + return Object.assign(new Error(type), { jmapError: type, description }); +} + +const SERIES_KEYS_IN_OVERRIDE = ['privacy', 'organizerCalendarAddress']; + +interface Instance { + id: string; + master: FakeEvent; + recurrenceId: string | null; +} + +export class FakeCalendarJmap extends FakeJmap { + readonly events = new Map(); + readonly calendars = new Map & { id: string }>(); + readonly identities: Array> = [{ id: 'pi1', name: 'A', calendarAddress: 'mailto:a@example.org', isDefault: true }]; + readonly eventLog = new Collection(); + private readonly instances = new Map(); + private nextEvent = 1; + /** The next create or update reaches the server, but its answer doesn't reach the client. */ + loseNextSetResponse = false; + /** The next CalendarEvent/set refuses this id like a server would. */ + refuse: Record = {}; + + constructor(readonly version: StalwartVersion = '1.0') { + super(); + this.calendars.set('cal1', { + id: 'cal1', name: 'Personal', isDefault: true, myRights: { mayWriteAll: true }, + defaultAlertsWithTime: { d1: { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } } }, + }); + } + + addEvent(partial: Partial = {}): FakeEvent { + const id = partial.id ?? `ev${this.nextEvent++}`; + const event: FakeEvent = { + '@type': 'Event', + uid: `${id}@example.org`, + calendarIds: { cal1: true }, + title: `Event ${id}`, + start: '2026-11-10T10:00:00', + duration: 'PT1H', + timeZone: 'Europe/Berlin', + baseEventId: null, + isOrigin: true, + useDefaultAlerts: false, + ...partial, + id, + }; + this.events.set(id, event); + this.eventLog.push('created', id); + return event; + } + + /** Someone else changes an event. */ + patchEvent(id: string, patch: Record): void { + this.events.set(id, applyPatchObject(this.events.get(id) as FakeEvent, patch)); + this.eventLog.push('updated', id); + } + + removeEvent(id: string): void { + this.events.delete(id); + this.eventLog.push('destroyed', id); + } + + private expanded(after: string, before: string): Instance[] { + const out: Instance[] = []; + for (const master of this.events.values()) { + const normal = normalizeCalendarEventLike(master as unknown as CalendarEvent); + if (!normal.recurrenceRules?.length) { + const start = String(master.start); + if (start < after || start >= before) continue; + out.push({ id: this.version === '1.0' ? master.id : `e${master.id}`, master, recurrenceId: null }); + continue; + } + for (const occurrence of expandRecurringEvents([normal], after, before)) { + out.push({ + id: `h${String(occurrence.recurrenceId).replace(/\D/g, '')}${master.id}`, + master, + recurrenceId: occurrence.recurrenceId as string, + }); + } + } + for (const instance of out) this.instances.set(instance.id, instance); + return out; + } + + private instanceView(instance: Instance): FakeEvent { + const { master, recurrenceId } = instance; + if (!recurrenceId) return { ...master, id: instance.id, baseEventId: this.version === '1.0' ? null : master.id }; + const override = ((master.recurrenceOverrides ?? {}) as Record>)[recurrenceId] ?? {}; + const { recurrenceRule: _r, recurrenceOverrides: _o, ...rest } = master; + return { + ...rest, + start: recurrenceId, + ...override, + id: instance.id, + baseEventId: master.id, + recurrenceId, + recurrenceRule: null, + }; + } + + private query(args: Record): Record { + const filter = (args.filter ?? {}) as { uid?: string; after?: string; before?: string; inCalendar?: string; operator?: string }; + const position = Number(args.position ?? 0); + const limit = Number(args.limit ?? 5000) || 5000; + const page = (ids: string[], canCalculateChanges: boolean) => ({ + accountId: args.accountId, + queryState: this.eventLog.state(), + canCalculateChanges, + position, + ids: ids.slice(position, position + limit), + }); + if (args.expandRecurrences) { + if (!filter.after || !filter.before) throw jmapError('invalidArguments', 'Expansion needs after and before'); + const days = (Date.parse(`${filter.before}Z`) - Date.parse(`${filter.after}Z`)) / 86_400_000; + if (this.version === '1.0' && days > 365) throw jmapError('expandDurationTooLarge'); + const list = this.expanded(filter.after, filter.before); + return page(list.map((instance) => instance.id), this.version !== '1.0'); + } + const masters = [...this.events.values()].filter((event) => { + if (filter.uid !== undefined && event.uid !== filter.uid) return false; + if (filter.inCalendar && !(event.calendarIds as Record)[filter.inCalendar]) return false; + return true; + }); + return page(masters.map((event) => event.id), true); + } + + private get(args: Record): Record { + const properties = args.properties as string[] | undefined; + if (properties?.includes('recurrenceRules')) throw jmapError('invalidArguments', 'recurrenceRules'); + if (this.version === '1.0' && properties?.includes('recurrenceOverrides') + && (properties.includes('utcStart') || properties.includes('utcEnd'))) { + throw jmapError('invalidArguments', 'recurrenceOverrides cannot be requested together with utcStart or utcEnd'); + } + const ids = (args.ids as string[] | null) ?? [...this.events.keys()].slice(0, 500); + if (ids.length > 500) throw jmapError('requestTooLarge'); + const list: Record[] = []; + const notFound: string[] = []; + for (const id of ids) { + const instance = this.instances.get(id); + const event = instance && this.events.has(instance.master.id) + ? this.instanceView({ ...instance, master: this.events.get(instance.master.id) as FakeEvent }) + : this.events.get(id); + if (!event) { + notFound.push(id); + continue; + } + const full = { ...event, utcStart: `${String(event.start)}Z`, utcEnd: `${String(event.start)}Z` }; + if (!properties) { + list.push({ ...event }); + continue; + } + const out: Record = { id }; + for (const name of properties) if (name in full) out[name] = (full as Record)[name]; + list.push(out); + } + return { accountId: args.accountId, state: this.eventLog.state(), list, notFound }; + } + + private seriesKeyInOverride(patch: Record): string | null { + if (this.version !== '1.0') return null; + for (const [key, value] of Object.entries(patch)) { + if (key !== 'recurrenceOverrides' && !key.startsWith('recurrenceOverrides/')) continue; + const entries = key === 'recurrenceOverrides' ? Object.values(value as object) : [value]; + for (const entry of entries) { + for (const name of SERIES_KEYS_IN_OVERRIDE) { + if (entry && typeof entry === 'object' && name in entry) return name; + } + } + } + return null; + } + + private update(id: string, patch: Record): { type: string; description?: string; properties?: string[] } | null { + if (this.refuse[id]) { + const refusal = this.refuse[id]; + delete this.refuse[id]; + return refusal; + } + if (id === SYNTHETIC_ID_PROBE) { + return this.version === '0.16.19' + ? { type: 'invalidProperties', description: 'Updating synthetic ids is not yet supported.', properties: ['id'] } + : { type: 'notFound' }; + } + const instance = this.instances.get(id); + if (instance?.recurrenceId) { + if (this.version === '0.16.19') { + return { type: 'invalidProperties', description: 'Updating synthetic ids is not yet supported.', properties: ['id'] }; + } + const master = this.events.get(instance.master.id); + if (!master) return { type: 'notFound' }; + const overrides = { ...(master.recurrenceOverrides as object | undefined) } as Record>; + overrides[instance.recurrenceId] = { ...overrides[instance.recurrenceId], ...patch }; + this.patchEvent(master.id, { recurrenceOverrides: overrides }); + return null; + } + const event = this.events.get(id); + if (!event) return { type: 'notFound' }; + const seriesKey = this.seriesKeyInOverride(patch); + if (seriesKey) { + return { type: 'invalidProperties', description: `${seriesKey} cannot be set inside recurrenceOverrides.`, properties: ['recurrenceOverrides'] }; + } + const pointsIntoNothing = Object.keys(patch).some((key) => key.startsWith('recurrenceOverrides/')) && !event.recurrenceOverrides; + if (pointsIntoNothing) return { type: this.version === '1.0' ? 'invalidPatch' : 'invalidProperties', description: 'Patch operation failed.' }; + this.patchEvent(id, patch); + return null; + } + + private set(args: Record): Record { + const out: Record = { accountId: args.accountId, oldState: this.eventLog.state() }; + const created: Record = {}; + const notCreated: Record = {}; + for (const [key, event] of Object.entries((args.create ?? {}) as Record>)) { + const seriesKey = this.seriesKeyInOverride(event); + if (seriesKey) { + notCreated[key] = { type: 'invalidProperties', description: `${seriesKey} cannot be set inside recurrenceOverrides.`, properties: ['recurrenceOverrides'] }; + } else if (typeof event.uid === 'string' && [...this.events.values()].some((other) => other.uid === event.uid)) { + notCreated[key] = { type: 'invalidProperties', description: `An event with UID ${event.uid} already exists.`, properties: ['uid'] }; + } else if (this.refuse[key]) { + notCreated[key] = this.refuse[key]; + delete this.refuse[key]; + } else { + created[key] = { id: this.addEvent(event).id }; + } + } + const updated: Record = {}; + const notUpdated: Record = {}; + for (const [id, patch] of Object.entries((args.update ?? {}) as Record>)) { + const refusal = this.update(id, patch); + if (refusal) notUpdated[id] = refusal; + else updated[id] = null; + } + const destroyed: string[] = []; + const notDestroyed: Record = {}; + for (const id of (args.destroy ?? []) as string[]) { + const instance = this.instances.get(id); + if (instance?.recurrenceId && this.events.has(instance.master.id)) { + const master = this.events.get(instance.master.id) as FakeEvent; + this.patchEvent(master.id, { + recurrenceOverrides: { ...(master.recurrenceOverrides as object | undefined), [instance.recurrenceId]: { excluded: true } }, + }); + destroyed.push(id); + } else if (this.refuse[id]) { + notDestroyed[id] = this.refuse[id]; + delete this.refuse[id]; + } else if (this.events.has(id)) { + this.removeEvent(id); + destroyed.push(id); + } else { + notDestroyed[id] = { type: 'notFound' }; + } + } + if (Object.keys(created).length > 0) out.created = created; + if (Object.keys(notCreated).length > 0) out.notCreated = notCreated; + if (Object.keys(updated).length > 0) out.updated = updated; + if (Object.keys(notUpdated).length > 0) out.notUpdated = notUpdated; + if (destroyed.length > 0) out.destroyed = destroyed; + if (Object.keys(notDestroyed).length > 0) out.notDestroyed = notDestroyed; + out.newState = this.eventLog.state(); + return out; + } + + private answerCalendarCall(name: string, args: Record): Record { + switch (name) { + case 'Calendar/get': + return { accountId: args.accountId, state: 'cs1', list: [...this.calendars.values()], notFound: [] }; + case 'ParticipantIdentity/get': + return { accountId: args.accountId, list: this.identities, notFound: [] }; + case 'CalendarEvent/query': + return this.query(args); + case 'CalendarEvent/get': + return this.get(args); + case 'CalendarEvent/set': + return this.set(args); + case 'CalendarEvent/changes': { + const since = String(args.sinceState); + if (!/^s\d+$/.test(since)) throw jmapError('invalidArguments'); + const from = Number(since.slice(1)); + if (from < this.eventLog.truncatedBelow || from > this.eventLog.log.length) throw jmapError('cannotCalculateChanges'); + const maxChanges = Math.min(Number(args.maxChanges ?? 5000) || 5000, 5000); + return { accountId: args.accountId, oldState: since, ...this.eventLog.changes(since, maxChanges) }; + } + default: + throw jmapError('unknownMethod'); + } + } + + override async request(calls: MethodCall[]): Promise { + if (!calls.every(([name]) => /^(Calendar|CalendarEvent|ParticipantIdentity)\//.test(name))) return super.request(calls); + this.requests += 1; + this.beforeRequest?.(this.requests); + if (this.offline) throw new TransportError('network', 'Failed to fetch'); + if (calls.length > 16) throw new TransportError('http', 'too many calls', 400, 'urn:ietf:params:jmap:error:limit'); + const done: MethodResponse[] = []; + let lose = false; + for (const call of calls) { + this.calls.push(call); + const [name, raw, id] = call; + const args: Record = {}; + for (const [key, value] of Object.entries(raw)) { + if (key.startsWith('#')) { + const ref = value as { resultOf: string; path: string }; + const source = done.find(([, , callId]) => callId === ref.resultOf); + args[key.slice(1)] = source?.[1][ref.path.slice(1)]; + } else { + args[key] = value; + } + } + try { + done.push([name, this.answerCalendarCall(name, args), id]); + if (name === 'CalendarEvent/set' && (raw.create || raw.update) && this.loseNextSetResponse) lose = true; + } catch (error) { + const type = (error as { jmapError?: string }).jmapError; + if (!type) throw error; + done.push(['error', { type, description: (error as { description?: string }).description }, id]); + } + } + if (lose) { + this.loseNextSetResponse = false; + throw new TransportError('network', 'The connection dropped before the answer came'); + } + return { methodResponses: done }; + } +} diff --git a/lib/store/__tests__/helpers/fake-jmap.ts b/lib/store/__tests__/helpers/fake-jmap.ts index 9aa917be5..bac572400 100644 --- a/lib/store/__tests__/helpers/fake-jmap.ts +++ b/lib/store/__tests__/helpers/fake-jmap.ts @@ -25,7 +25,7 @@ type ChangeKind = 'created' | 'updated' | 'destroyed'; interface Log { kind: ChangeKind; id: string } -class Collection { +export class Collection { log: Log[] = []; /** Below this log position, states are refused as truncated. */ truncatedBelow = 0; diff --git a/lib/store/area.ts b/lib/store/area.ts index fceed110e..b73740b5b 100644 --- a/lib/store/area.ts +++ b/lib/store/area.ts @@ -96,9 +96,10 @@ export type MethodHandler = (ctx: MethodContext, args: Record, /** * A call from the page that isn't JMAP: outbox rows, usage, clearing. Reached * from any tab with `store.area(account, 'outbox.list', params)`. Names are - * unique across areas and start with the area's name. + * unique across areas and start with the area's name. It gets the sync context, + * because some calls talk to the server (`calendar.flush`). */ -export type AreaCall = (ctx: StoreContext, params: never) => unknown | Promise; +export type AreaCall = (ctx: SyncContext, params: never) => unknown | Promise; export interface StoreArea { name: string; diff --git a/lib/store/areas.ts b/lib/store/areas.ts index 6bff665ca..30a6541ff 100644 --- a/lib/store/areas.ts +++ b/lib/store/areas.ts @@ -1,6 +1,7 @@ /** Every part of the local store. New areas go here. */ import type { StoreArea } from './area'; +import { calendarArea } from './calendar'; import { mailArea } from './mail'; -export const STORE_AREAS: readonly StoreArea[] = [mailArea]; +export const STORE_AREAS: readonly StoreArea[] = [mailArea, calendarArea]; diff --git a/lib/store/calendar/filter.ts b/lib/store/calendar/filter.ts new file mode 100644 index 000000000..41a29ffd3 --- /dev/null +++ b/lib/store/calendar/filter.ts @@ -0,0 +1,220 @@ +/** + * CalendarEvent/query filters, read the way Stalwart reads them: after and + * before are LocalDateTimes in the request's time zone, and a series matches + * when any occurrence is in the range. Text matches parts of words, where the + * server only matches whole ones: offline search finds more, not less. + */ + +import { addDays, format, parseISO } from 'date-fns'; +import { isTaskLikeObject } from '@/lib/calendar-component-detection'; +import { normalizeCalendarEventLike, parseDurationSeconds } from '@/lib/calendar-event-normalization'; +import type { CalendarEvent, CalendarEventFilter } from '@/lib/jmap/types'; +import { expandRecurringEvents } from '@/lib/recurrence-expansion'; +import { MethodError } from '../area'; +import { instantOf, instantsOf, isSeries, wallClock, type CalendarObject } from './records'; + +export interface RangeBounds { + after?: string; + before?: string; +} + +/** The range of a filter, in the two shapes the client sends it: plain keys, or an AND of one-key conditions. */ +export function boundsOf(filter: CalendarEventFilter): RangeBounds { + if (filter.operator === 'AND') { + const bounds: RangeBounds = {}; + for (const condition of filter.conditions ?? []) { + if (condition.operator || Object.keys(condition).length !== 1) continue; + if (condition.after) bounds.after = condition.after; + if (condition.before) bounds.before = condition.before; + } + return bounds; + } + if (filter.operator) return {}; + return { after: filter.after, before: filter.before }; +} + +function fold(text: string): string { + return text.normalize('NFKD').replace(/\p{M}/gu, '').toLowerCase(); +} + +function containsWords(haystack: Array, needle: string): boolean { + const text = fold(haystack.filter((part): part is string => typeof part === 'string').join('\n')); + return fold(needle).split(/\s+/).filter(Boolean).every((word) => text.includes(word)); +} + +type Participant = { + name?: string; + email?: string; + calendarAddress?: string; + participationStatus?: string; + roles?: Record; +}; + +function participantsOf(object: CalendarObject): Participant[] { + return Object.values((object.participants ?? {}) as Record); +} + +function placeTexts(object: CalendarObject): unknown[] { + const places = Object.values((object.locations ?? {}) as Record); + const virtual = Object.values((object.virtualLocations ?? {}) as Record); + return [...places, ...virtual].flatMap((place) => [place.name, place.description]); +} + +function personTexts(participant: Participant): unknown[] { + return [participant.name, participant.email, participant.calendarAddress]; +} + +function isOwner(participant: Participant): boolean { + return participant.roles?.owner === true; +} + +function isAttendee(participant: Participant): boolean { + return !participant.roles || participant.roles.attendee === true; +} + +function ownerTexts(object: CalendarObject): unknown[] { + return [object.organizerCalendarAddress, ...participantsOf(object).filter(isOwner).flatMap(personTexts)]; +} + +// An occurrence's wall clock is in the series' zone and the bounds in the +// request's: up to 26 hours apart. The client narrows the rest when it expands. +const SERIES_SLACK_DAYS = 2; +const OPEN_RANGE_DAYS = 5 * 366; + +function seriesOccursIn(object: CalendarObject, after: string | undefined, before: string | undefined): boolean { + const seriesStart = typeof object.start === 'string' ? parseISO(object.start) : null; + if (!seriesStart || Number.isNaN(seriesStart.getTime())) return false; + const lengthMs = (parseDurationSeconds(typeof object.duration === 'string' ? object.duration : undefined) ?? 0) * 1000; + const from = after + ? new Date(addDays(parseISO(wallClock(after)), -SERIES_SLACK_DAYS).getTime() - lengthMs) + : seriesStart; + const to = before ? addDays(parseISO(wallClock(before)), SERIES_SLACK_DAYS) : addDays(from, OPEN_RANGE_DAYS); + if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime()) || from >= to) return false; + const stamp = (date: Date) => format(date, "yyyy-MM-dd'T'HH:mm:ss"); + const event = normalizeCalendarEventLike(object as unknown as CalendarEvent); + return expandRecurringEvents([event], stamp(from), stamp(to)).length > 0; +} + +function inRange(object: CalendarObject, bounds: RangeBounds, zone: string): boolean { + if (isSeries(object)) return seriesOccursIn(object, bounds.after, bounds.before); + const { start, end } = instantsOf(object, zone); + if (start === null || end === null) return false; + const after = bounds.after ? instantOf(bounds.after, zone) : null; + const before = bounds.before ? instantOf(bounds.before, zone) : null; + if (before !== null && start >= before) return false; + if (after === null) return true; + return end > after || (end === start && start >= after); +} + +function matchesCondition(object: CalendarObject, condition: CalendarEventFilter, zone: string): boolean { + const bounds: RangeBounds = {}; + for (const [key, value] of Object.entries(condition)) { + if (value === undefined) continue; + switch (key) { + case 'after': + case 'before': + bounds[key as 'after' | 'before'] = value as string; + break; + case 'inCalendar': + if (!(object.calendarIds as Record | undefined)?.[value as string]) return false; + break; + case 'inCalendars': + if (!(value as string[]).some((id) => (object.calendarIds as Record | undefined)?.[id])) return false; + break; + case 'uid': + if (object.uid !== value) return false; + break; + case 'title': + if (!containsWords([object.title], value as string)) return false; + break; + case 'description': + if (!containsWords([object.description], value as string)) return false; + break; + case 'location': + if (!containsWords(placeTexts(object), value as string)) return false; + break; + case 'owner': + if (!containsWords(ownerTexts(object), value as string)) return false; + break; + case 'attendee': { + // With participationStatus in the same condition: the same person. + const status = condition.participationStatus; + const found = participantsOf(object).some((person) => isAttendee(person) + && containsWords(personTexts(person), value as string) + && (!status || person.participationStatus === status)); + if (!found) return false; + break; + } + case 'participationStatus': + if (!participantsOf(object).some((person) => person.participationStatus === value)) return false; + break; + case 'text': + if (!containsWords([ + object.title, + object.description, + ...placeTexts(object), + ...ownerTexts(object), + ...participantsOf(object).flatMap(personTexts), + ], value as string)) return false; + break; + case 'types': { + const type = isTaskLikeObject(object) ? 'task' : 'event'; + if (!(value as string[]).some((name) => name.toLowerCase() === type)) return false; + break; + } + default: + throw new MethodError('unsupportedFilter', `${key} isn't available offline`); + } + } + return bounds.after || bounds.before ? inRange(object, bounds, zone) : true; +} + +export function matchesFilter(object: CalendarObject, filter: CalendarEventFilter, zone: string): boolean { + const conditions = filter.conditions ?? []; + switch (filter.operator) { + case 'AND': { + // A range split over two conditions is one range: a series must have one + // occurrence inside it, not one after the start and another before the end. + const bounds = boundsOf(filter); + const isBound = (c: CalendarEventFilter) => !c.operator && Object.keys(c).length === 1 && Boolean(c.after || c.before); + if ((bounds.after || bounds.before) && !inRange(object, bounds, zone)) return false; + return conditions.every((c) => isBound(c) || matchesFilter(object, c, zone)); + } + case 'OR': + return conditions.some((c) => matchesFilter(object, c, zone)); + case 'NOT': + return !conditions.some((c) => matchesFilter(object, c, zone)); + case undefined: + return matchesCondition(object, filter, zone); + default: + throw new MethodError('unsupportedFilter', `Unknown operator ${String(filter.operator)}`); + } +} + +export interface EventComparator { + property?: string; + isAscending?: boolean; +} + +function sortValue(object: CalendarObject, property: string | undefined, zone: string): number | string { + if (property === 'start') return instantsOf(object, zone).start ?? 0; + const value = property ? object[property] : undefined; + return typeof value === 'number' || typeof value === 'string' ? value : ''; +} + +/** By start when the request names no order. Ties go by id, so paging is stable. */ +export function sortEvents(objects: CalendarObject[], sort: readonly EventComparator[], zone: string): CalendarObject[] { + const levels = sort.length > 0 ? sort : [{ property: 'start', isAscending: true }]; + const keyed = objects.map((object) => ({ object, keys: levels.map((level) => sortValue(object, level.property, zone)) })); + keyed.sort((a, b) => { + for (let i = 0; i < levels.length; i += 1) { + const x = a.keys[i]; + const y = b.keys[i]; + if (x === y) continue; + const order = x < y ? -1 : 1; + return levels[i].isAscending === false ? -order : order; + } + return a.object.id < b.object.id ? -1 : a.object.id > b.object.id ? 1 : 0; + }); + return keyed.map((entry) => entry.object); +} diff --git a/lib/store/calendar/flush.ts b/lib/store/calendar/flush.ts new file mode 100644 index 000000000..b4266a473 --- /dev/null +++ b/lib/store/calendar/flush.ts @@ -0,0 +1,229 @@ +/** + * Sends pending calendar writes, one event per request, so a lost response + * leaves one event's outcome open and not a batch's. + * + * Only a create can go wrong by being repeated. It's marked `attempted` in its + * own transaction before it goes out, and carries a uid the device chose. A + * create with that mark first looks for its uid on the server. An edit sent + * twice writes the same values twice, and a delete sent twice finds nothing the + * second time. + */ + +import { jmapError } from '@/lib/jmap/jmap-error'; +import { isOverrideSeriesKeyRejection, stableJson, withoutOverrideSeriesKeys } from '@/lib/recurrence-instances'; +import type { CalendarEvent } from '@/lib/jmap/types'; +import { applyPatchObject } from '@/lib/jmap/patch-object'; +import type { SyncContext } from '../area'; +import { JmapMethodError, responseFor } from '../engine/transport'; +import { calendarUsing, currentGen, deleteEventRow, EVENT_PROPERTIES, eventRow, valueAtPointer, writeEvent, type CalendarObject } from './records'; +import { recordConflict, type PendingRow } from './writes'; + +export interface FlushTally { + sent: number; + conflicts: number; +} + +interface SetError { + type?: string; + description?: string; + properties?: string[]; +} + +interface SetResult { + created?: Record | null; + updated?: Record | null; + destroyed?: string[] | null; + notCreated?: Record | null; + notUpdated?: Record | null; + notDestroyed?: Record | null; +} + +// The server had a bad moment, nothing is wrong with this write: try again later. +const PASSING_ERRORS = new Set(['serverFail', 'serverPartialFail', 'serverUnavailable', 'rateLimit', 'tooManyRequests']); + +export function isPassingError(error: unknown): boolean { + return error instanceof JmapMethodError && PASSING_ERRORS.has(error.type); +} + +type Outcome = 'sent' | 'conflict' | 'again'; + +async function sendSet(ctx: SyncContext, row: PendingRow, args: Record): Promise { + const response = await ctx.transport.request([['CalendarEvent/set', { + accountId: row.jmap, + ...(row.send_scheduling === null ? {} : { sendSchedulingMessages: row.send_scheduling === 1 }), + ...args, + }, 's']], calendarUsing(ctx.session)); + return responseFor(response, 's')[1] as SetResult; +} + +/** Stalwart 1.0 refuses what 0.16 expects inside an override. The client normally learns that from the refusal. */ +function isSeriesKeyRefusal(error: SetError | undefined): boolean { + return Boolean(error) && isOverrideSeriesKeyRejection(jmapError(error?.description ?? '', error)); +} + +async function findByUid(ctx: SyncContext, jmap: string, uid: string | null): Promise { + if (!uid) return null; + const response = await ctx.transport.request([['CalendarEvent/query', { accountId: jmap, filter: { uid }, limit: 1 }, 'u']], calendarUsing(ctx.session)); + const ids = responseFor(response, 'u')[1].ids as string[]; + return ids[0] ?? null; +} + +async function fetchEvent(ctx: SyncContext, jmap: string, id: string): Promise { + const response = await ctx.transport.request([['CalendarEvent/get', { accountId: jmap, ids: [id], properties: EVENT_PROPERTIES }, 'g']], calendarUsing(ctx.session)); + return (responseFor(response, 'g')[1].list as CalendarObject[])[0] ?? null; +} + +/** The stand-in becomes the real event. What came in while the create was on its way goes on as a write of its own. */ +function finishCreate(ctx: SyncContext, row: PendingRow, sent: Record, realId: string, event: CalendarObject): Outcome { + const { db } = ctx; + return db.transaction(() => { + const current = db.get('SELECT * FROM calendar_pending WHERE seq = ?', [row.seq]); + const gen = currentGen(db, row.jmap); + deleteEventRow(db, row.jmap, row.record_id); + db.run( + 'INSERT OR REPLACE INTO calendar_id_map (jmap, local_id, server_id) VALUES (?, ?, ?)', + [row.jmap, row.record_id, realId], + ); + ctx.emit({ type: 'changed', account: ctx.account, collections: ['CalendarEvent'] }); + if (current?.kind === 'destroy') { + writeEvent(db, row.jmap, { shown: event, server: event, hidden: true, gen }); + db.run('UPDATE calendar_pending SET record_id = ?, uid = NULL, attempted = 0 WHERE seq = ?', [realId, row.seq]); + return 'again'; + } + if (current && current.rev !== row.rev) { + const now = JSON.parse(current.patch) as Record; + const patch: Record = {}; + for (const key of new Set([...Object.keys(sent), ...Object.keys(now)])) { + if (stableJson(sent[key]) !== stableJson(now[key])) patch[key] = now[key] ?? null; + } + const base = Object.fromEntries(Object.keys(patch).map((key) => [key, valueAtPointer(event, key) ?? null])); + db.run( + `UPDATE calendar_pending SET kind = 'update', record_id = ?, patch = ?, base = ?, uid = NULL, attempted = 0 WHERE seq = ?`, + [realId, JSON.stringify(patch), JSON.stringify(base), row.seq], + ); + writeEvent(db, row.jmap, { shown: applyPatchObject(event, patch), server: event, gen }); + return 'again'; + } + db.run('DELETE FROM calendar_pending WHERE seq = ?', [row.seq]); + writeEvent(db, row.jmap, { shown: event, gen }); + return 'sent'; + }); +} + +async function sendCreate(ctx: SyncContext, row: PendingRow): Promise { + const payload = JSON.parse(row.patch) as Record; + let realId = row.attempted ? await findByUid(ctx, row.jmap, row.uid) : null; + if (!realId) { + ctx.db.transaction(() => ctx.db.run('UPDATE calendar_pending SET attempted = 1 WHERE seq = ?', [row.seq])); + let result = await sendSet(ctx, row, { create: { c: payload } }); + if (isSeriesKeyRefusal(result.notCreated?.c)) { + const without = withoutOverrideSeriesKeys(payload as Partial); + result = await sendSet(ctx, row, { create: { c: without } }); + } + realId = result.created?.c?.id ?? null; + const refusal = result.notCreated?.c; + // Both Stalwarts refuse a second event with one uid, which also covers a + // create that got through when the device didn't know it had tried. + if (!realId && refusal?.type === 'invalidProperties' && refusal.properties?.includes('uid')) { + realId = await findByUid(ctx, row.jmap, row.uid); + } + if (!realId) { + ctx.db.transaction(() => recordConflict(ctx, row, 'refused', refusal ?? { type: 'serverFail' })); + return 'conflict'; + } + } + const event = await fetchEvent(ctx, row.jmap, realId) ?? ({ ...payload, id: realId } as CalendarObject); + return finishCreate(ctx, row, payload, realId, event); +} + +function finishUpdate(ctx: SyncContext, row: PendingRow, sent: Record): Outcome { + const { db } = ctx; + return db.transaction(() => { + const current = db.get('SELECT * FROM calendar_pending WHERE seq = ?', [row.seq]); + const event = eventRow(db, row.jmap, row.record_id); + ctx.emit({ type: 'changed', account: ctx.account, collections: ['CalendarEvent'] }); + if (!current || !event) return 'sent'; + if (current.rev === row.rev) { + db.run('DELETE FROM calendar_pending WHERE seq = ?', [row.seq]); + writeEvent(db, row.jmap, { shown: JSON.parse(event.json) as CalendarObject, gen: event.gen }); + return 'sent'; + } + // A newer write came in during the send. What went out is the server's now, + // so the next sync must not take it for someone else's change. + const server = applyPatchObject(JSON.parse(event.server_json ?? event.json) as CalendarObject, sent); + const base = JSON.parse(current.base) as Record; + for (const key of Object.keys(sent)) if (key in base) base[key] = sent[key]; + db.run('UPDATE calendar_pending SET base = ? WHERE seq = ?', [JSON.stringify(base), row.seq]); + writeEvent(db, row.jmap, { shown: JSON.parse(event.json) as CalendarObject, server, hidden: Boolean(event.hidden), gen: event.gen }); + return 'again'; + }); +} + +async function sendUpdate(ctx: SyncContext, row: PendingRow): Promise { + let patch = JSON.parse(row.patch) as Record; + const id = row.record_id; + let result = await sendSet(ctx, row, { update: { [id]: patch } }); + if (isSeriesKeyRefusal(result.notUpdated?.[id])) { + patch = withoutOverrideSeriesKeys(patch as Partial) as Record; + result = await sendSet(ctx, row, { update: { [id]: patch } }); + } + const refusal = result.notUpdated?.[id]; + if (refusal) { + ctx.db.transaction(() => recordConflict(ctx, row, refusal.type === 'notFound' ? 'destroyedOnServer' : 'refused', refusal)); + return 'conflict'; + } + return finishUpdate(ctx, row, patch); +} + +async function sendDestroy(ctx: SyncContext, row: PendingRow): Promise { + // A create turned delete while its create was out: find out what the server has. + const id = row.uid ? await findByUid(ctx, row.jmap, row.uid) : row.record_id; + const finish = () => ctx.db.transaction(() => { + ctx.db.run('DELETE FROM calendar_pending WHERE seq = ?', [row.seq]); + deleteEventRow(ctx.db, row.jmap, row.record_id); + ctx.emit({ type: 'changed', account: ctx.account, collections: ['CalendarEvent'] }); + }); + if (!id) { + finish(); + return 'sent'; + } + const result = await sendSet(ctx, row, { destroy: [id] }); + const refusal = result.notDestroyed?.[id]; + if (refusal && refusal.type !== 'notFound') { + ctx.db.transaction(() => recordConflict(ctx, row, 'refused', refusal)); + return 'conflict'; + } + finish(); + return 'sent'; +} + +/** + * Sends what's pending for one JMAP account. Network failures and a server's + * bad moment are thrown, with the rest still pending. Any other method error + * means this write won't work as it is, so it goes to the conflict list and the + * others still go out. + */ +export async function sendPending(ctx: SyncContext, jmap: string): Promise { + const tally: FlushTally = { sent: 0, conflicts: 0 }; + const finished = new Set(); + for (;;) { + if (ctx.stopped()) return tally; + const row = ctx.db.all('SELECT * FROM calendar_pending WHERE jmap = ? ORDER BY seq', [jmap]) + .find((candidate) => !finished.has(Number(candidate.seq))); + if (!row) return tally; + let outcome: Outcome; + try { + if (row.kind === 'create') outcome = await sendCreate(ctx, row); + else if (row.kind === 'update') outcome = await sendUpdate(ctx, row); + else outcome = await sendDestroy(ctx, row); + } catch (error) { + if (!(error instanceof JmapMethodError) || isPassingError(error)) throw error; + ctx.db.transaction(() => recordConflict(ctx, row, 'refused', { type: error.type, description: error.message })); + outcome = 'conflict'; + } + if (outcome === 'again') continue; + finished.add(Number(row.seq)); + if (outcome === 'sent') tally.sent += 1; + else tally.conflicts += 1; + } +} diff --git a/lib/store/calendar/index.ts b/lib/store/calendar/index.ts new file mode 100644 index 000000000..da9115b78 --- /dev/null +++ b/lib/store/calendar/index.ts @@ -0,0 +1,19 @@ +import type { StoreArea } from '../area'; +import { CALENDAR_METHODS } from './methods'; +import { CALENDAR_MIGRATIONS } from './schema'; +import { calendarSyncer, flushCalendar } from './sync'; +import { listConflicts } from './writes'; + +export const calendarArea: StoreArea = { + name: 'calendar', + migrations: CALENDAR_MIGRATIONS, + methods: CALENDAR_METHODS, + syncers: [calendarSyncer], + calls: { + 'calendar.flush': (ctx) => flushCalendar(ctx), + 'calendar.conflicts': (ctx) => listConflicts(ctx.db), + 'calendar.dismissConflict': (ctx, params: { id: number }) => { + ctx.db.run('DELETE FROM calendar_conflict WHERE id = ?', [params.id]); + }, + }, +}; diff --git a/lib/store/calendar/methods.ts b/lib/store/calendar/methods.ts new file mode 100644 index 000000000..2443584dc --- /dev/null +++ b/lib/store/calendar/methods.ts @@ -0,0 +1,197 @@ +/** + * Calendar calls answered from the store. + * + * Series, not occurrences, in both client modes. A client in synthetic-id mode + * sends `expandRecurrences: true` and gets the masters, with their overrides + * (see projectEvent). hydrateRecurrenceInstances leaves those alone, and + * expandRecurringEvents in the calendar store makes the occurrences, the same as + * for a client in the other mode. Occurrence writes then arrive as overrides on + * the master, which is what a stand-in id and the pending queue can work with. + */ + +import type { CalendarEventFilter } from '@/lib/jmap/types'; +import { SYNTHETIC_ID_PROBE } from '@/lib/recurrence-instances'; +import { MethodError, type MethodContext, type MethodHandler, type MethodResult } from '../area'; +import { listObjects } from '../engine/core-schema'; +import type { SqlValue, StorageAdapter } from '../storage'; +import { boundsOf, matchesFilter, sortEvents, type EventComparator } from './filter'; +import { CALENDARS_CAPABILITY, instantOf, projectEvent, type CalendarObject } from './records'; +import { syntheticIdVerdict } from './sync'; +import { queueCreate, queueDestroy, queueUpdate, resolveId } from './writes'; + +function jmapOf(ctx: MethodContext, args: Record): string { + return (args.accountId as string | undefined) ?? ctx.session.primaryAccounts[CALENDARS_CAPABILITY] ?? ctx.defaultJmap; +} + +function stateOf(db: StorageAdapter, jmap: string, type: string): string | undefined { + return db.get<{ state: string | null }>('SELECT state FROM sync_state WHERE jmap = ? AND type = ?', [jmap, type])?.state ?? undefined; +} + +/** An account never synced has no answer yet. An empty one would read as "no events". */ +function assertSynced(db: StorageAdapter, jmap: string, type: string): void { + const row = db.get<{ state: string | null; phase: string | null }>('SELECT state, phase FROM sync_state WHERE jmap = ? AND type = ?', [jmap, type]); + if (!row?.state && !row?.phase) throw new MethodError('serverUnavailable', `${type} hasn't been kept on this device yet`); +} + +// An event's start_ms reads a floating event in UTC, and a request's bounds are +// in its own zone: up to 14 hours apart. This only narrows, the filter decides. +const ZONE_SLACK_MS = 15 * 3_600_000; + +function candidates(db: StorageAdapter, jmap: string, filter: CalendarEventFilter, zone: string): CalendarObject[] { + const where = ['jmap = ?', 'hidden = 0']; + const params: SqlValue[] = [jmap]; + if (typeof filter.uid === 'string' && !filter.operator) { + where.push('uid = ?'); + params.push(filter.uid); + } + const { after, before } = boundsOf(filter); + const afterMs = after ? instantOf(after, zone) : null; + const beforeMs = before ? instantOf(before, zone) : null; + if (afterMs !== null || beforeMs !== null) { + where.push(`(is_series = 1 OR start_ms IS NULL OR (start_ms < ? AND end_ms >= ?))`); + params.push(beforeMs === null ? Number.MAX_SAFE_INTEGER : beforeMs + ZONE_SLACK_MS); + params.push(afterMs === null ? Number.MIN_SAFE_INTEGER : afterMs - ZONE_SLACK_MS); + } + return db.all<{ json: string }>(`SELECT json FROM calendar_event WHERE ${where.join(' AND ')}`, params) + .map((row) => JSON.parse(row.json) as CalendarObject); +} + +const calendarGet: MethodHandler = (ctx, args) => { + const jmap = jmapOf(ctx, args); + assertSynced(ctx.db, jmap, 'Calendar'); + const ids = args.ids as string[] | null | undefined; + const properties = args.properties as string[] | undefined; + const all = listObjects<{ id: string } & Record>(ctx.db, jmap, 'Calendar'); + const chosen = ids ? all.filter((calendar) => ids.includes(calendar.id)) : all; + const state = stateOf(ctx.db, jmap, 'Calendar'); + return { + accountId: jmap, + ...(state ? { state } : {}), + list: chosen.map((calendar) => (properties + ? Object.fromEntries(['id', ...properties].filter((name) => name in calendar).map((name) => [name, calendar[name]])) + : calendar)), + notFound: ids ? ids.filter((id) => !all.some((calendar) => calendar.id === id)) : [], + }; +}; + +const eventQuery: MethodHandler = (ctx, args) => { + const jmap = jmapOf(ctx, args); + assertSynced(ctx.db, jmap, 'CalendarEvent'); + const filter = (args.filter ?? {}) as CalendarEventFilter; + const zone = typeof args.timeZone === 'string' && args.timeZone ? args.timeZone : 'UTC'; + const { after, before } = boundsOf(filter); + if (args.expandRecurrences && !(after && before)) { + throw new MethodError('invalidArguments', 'expandRecurrences needs both after and before'); + } + const matching = candidates(ctx.db, jmap, filter, zone).filter((object) => matchesFilter(object, filter, zone)); + const sorted = sortEvents(matching, (args.sort ?? []) as EventComparator[], zone); + const position = Math.max(0, Number(args.position ?? 0) || 0); + const limit = Number(args.limit); + const ids = sorted.map((object) => object.id); + return { + accountId: jmap, + // No queryState: the client doesn't read it, and the store has no real one. + canCalculateChanges: false, + position, + ids: ids.slice(position, limit > 0 ? position + limit : undefined), + ...(args.calculateTotal ? { total: ids.length } : {}), + }; +}; + +const eventGet: MethodHandler = (ctx, args) => { + const jmap = jmapOf(ctx, args); + const requested = args.ids as string[] | null | undefined; + // A page's first calendar list carries `ids: []` to check a time zone, also before any sync. + if (!(Array.isArray(requested) && requested.length === 0)) assertSynced(ctx.db, jmap, 'CalendarEvent'); + const properties = (args.properties as string[] | undefined) ?? null; + const zone = typeof args.timeZone === 'string' && args.timeZone ? args.timeZone : 'UTC'; + const list: Record[] = []; + const notFound: string[] = []; + if (requested) { + for (const id of requested) { + const row = ctx.db.get<{ json: string }>('SELECT json FROM calendar_event WHERE jmap = ? AND id = ? AND hidden = 0', [jmap, resolveId(ctx.db, jmap, id)]); + if (row) list.push(projectEvent(JSON.parse(row.json) as CalendarObject, properties, zone)); + else notFound.push(id); + } + } else { + const rows = ctx.db.all<{ json: string }>('SELECT json FROM calendar_event WHERE jmap = ? AND hidden = 0', [jmap]); + for (const row of rows) list.push(projectEvent(JSON.parse(row.json) as CalendarObject, properties, zone)); + } + const state = stateOf(ctx.db, jmap, 'CalendarEvent'); + return { accountId: jmap, ...(state ? { state } : {}), list, notFound }; +}; + +// What a server without synthetic-id writes says. It's the answer here too, for +// an id the store doesn't hold, which is an occurrence still on screen from +// before the connection went: the client catches this (updateOccurrence, +// destroyOccurrence in stores/calendar-store.ts) and writes the change as an +// override on the base event, which the store does hold. +function syntheticRefusal(verb: 'Updating' | 'Deleting'): { type: string; description: string; properties: string[] } { + return { type: 'invalidProperties', description: `${verb} synthetic ids is not yet supported.`, properties: ['id'] }; +} + +const eventSet: MethodHandler = (ctx, args) => { + const jmap = jmapOf(ctx, args); + const send = typeof args.sendSchedulingMessages === 'boolean' ? args.sendSchedulingMessages : undefined; + const created: Record = {}; + const updated: Record = {}; + const destroyed: string[] = []; + const notUpdated: Record = {}; + const notDestroyed: Record = {}; + let wrote = false; + ctx.db.transaction(() => { + for (const [key, event] of Object.entries((args.create ?? {}) as Record>)) { + created[key] = queueCreate(ctx, jmap, event, send); + wrote = true; + } + for (const [id, patch] of Object.entries((args.update ?? {}) as Record>)) { + if (id === SYNTHETIC_ID_PROBE) { + // A client that started offline asks here. Tell it what the server said + // at the last sync, so it behaves the same when the server is back. With + // no answer yet: client-side expansion, which works on every server. + notUpdated[id] = syntheticIdVerdict(ctx, jmap) === true ? { type: 'notFound' } : syntheticRefusal('Updating'); + continue; + } + if (queueUpdate(ctx, jmap, resolveId(ctx.db, jmap, id), patch, send)) { + updated[id] = null; + wrote = true; + } else { + notUpdated[id] = syntheticRefusal('Updating'); + } + } + for (const id of (args.destroy ?? []) as string[]) { + if (queueDestroy(ctx, jmap, resolveId(ctx.db, jmap, id), send)) { + destroyed.push(id); + wrote = true; + } else { + notDestroyed[id] = syntheticRefusal('Deleting'); + } + } + }); + if (wrote) ctx.emit({ type: 'changed', account: ctx.account, collections: ['CalendarEvent'] }); + const result: MethodResult = { accountId: jmap }; + if (Object.keys(created).length > 0) result.created = created; + if (Object.keys(updated).length > 0) result.updated = updated; + if (destroyed.length > 0) result.destroyed = destroyed; + if (Object.keys(notUpdated).length > 0) result.notUpdated = notUpdated; + if (Object.keys(notDestroyed).length > 0) result.notDestroyed = notDestroyed; + return result; +}; + +const identityGet: MethodHandler = (ctx, args) => { + const jmap = jmapOf(ctx, args); + const list = listObjects(ctx.db, jmap, 'ParticipantIdentity'); + if (list.length === 0) throw new MethodError('serverUnavailable', 'No participant identities kept yet'); + return { accountId: jmap, list, notFound: [] }; +}; + +export const CALENDAR_METHODS: Readonly> = { + 'Calendar/get': calendarGet, + 'CalendarEvent/query': eventQuery, + 'CalendarEvent/get': eventGet, + 'CalendarEvent/set': eventSet, + 'ParticipantIdentity/get': identityGet, + // Notifications about others' changes wait for the server. + 'CalendarEventNotification/query': (ctx, args) => ({ accountId: jmapOf(ctx, args), canCalculateChanges: false, position: 0, ids: [] }), + 'CalendarEventNotification/get': (ctx, args) => ({ accountId: jmapOf(ctx, args), list: [], notFound: [] }), +}; diff --git a/lib/store/calendar/records.ts b/lib/store/calendar/records.ts new file mode 100644 index 000000000..84cdcb0cc --- /dev/null +++ b/lib/store/calendar/records.ts @@ -0,0 +1,162 @@ +/** How calendar objects are stored and read back. */ + +import { parseISO } from 'date-fns'; +import { isTaskLikeObject } from '@/lib/calendar-component-detection'; +import { parseDurationSeconds } from '@/lib/calendar-event-normalization'; +import { pointerTokenValue } from '@/lib/jmap/patch-pointer'; +import { wallClockFieldsAsUtc, zonedWallTimeToUtc } from '@/lib/recurrence-expansion'; +import type { StoreSession } from '../engine/transport'; +import type { SqlValue, StorageAdapter } from '../storage'; + +export const CALENDARS_CAPABILITY = 'urn:ietf:params:jmap:calendars'; +const PRINCIPALS_OWNER = 'urn:ietf:params:jmap:principals:owner'; + +export type CalendarObject = Record & { id: string }; + +export function calendarUsing(session: StoreSession): string[] { + const using = ['urn:ietf:params:jmap:core', CALENDARS_CAPABILITY]; + if (PRINCIPALS_OWNER in session.capabilities) using.push(PRINCIPALS_OWNER); + return using; +} + +/** Stalwart's default list for Calendar/get leaves out sharing and default alerts. */ +export const CALENDAR_PROPERTIES = [ + 'id', 'name', 'description', 'color', 'sortOrder', 'isSubscribed', 'isVisible', 'isDefault', + 'includeInAvailability', 'defaultAlertsWithTime', 'defaultAlertsWithoutTime', 'timeZone', 'shareWith', 'myRights', +] as const; + +/** + * Everything the client's event and task property lists ask for, in one get. + * No utcStart or utcEnd: Stalwart 1.0 refuses those next to recurrenceOverrides, + * and they depend on the zone of the request, so answers work them out. + */ +export const EVENT_PROPERTIES = [ + 'id', '@type', 'uid', 'calendarIds', 'title', 'description', 'descriptionContentType', 'created', 'updated', + 'sequence', 'start', 'due', 'duration', 'timeZone', 'showWithoutTime', 'status', 'freeBusyStatus', 'privacy', + 'color', 'keywords', 'categories', 'locale', 'replyTo', 'organizerCalendarAddress', 'participants', + 'mayInviteSelf', 'mayInviteOthers', 'hideAttendees', 'recurrenceId', 'recurrenceIdTimeZone', 'recurrenceRule', + 'excludedRecurrenceRule', 'recurrenceOverrides', 'useDefaultAlerts', 'alerts', 'locations', 'virtualLocations', + 'links', 'relatedTo', 'isDraft', 'isOrigin', 'baseEventId', 'progress', 'priority', 'percentComplete', +] as const; + +export function isSeries(object: Record): boolean { + const rule = object.recurrenceRule ?? object.recurrenceRules; + return Array.isArray(rule) ? rule.length > 0 : Boolean(rule); +} + +/** The value a PatchObject key points at. */ +export function valueAtPointer(object: unknown, pointer: string): unknown { + let value = object; + for (const token of pointer.split('/')) { + if (value === null || typeof value !== 'object') return undefined; + value = (value as Record)[pointerTokenValue(token)]; + } + return value; +} + +/** A LocalDateTime without any `Z` or offset: Stalwart drops those instead of honouring them. */ +export function wallClock(value: string): string { + return value.replace(/(?:Z|[+-]\d{2}:?\d{2})$/, ''); +} + +/** The instant a wall clock names in `zone`. A zone the runtime doesn't know reads as UTC, as 0.16 does. */ +export function instantOf(value: string, zone: string | null | undefined): number | null { + const wall = parseISO(wallClock(value)); + if (Number.isNaN(wall.getTime())) return null; + const zoned = zone ? zonedWallTimeToUtc(wall, zone) : null; + return zoned ? zoned.getTime() : wallClockFieldsAsUtc(wall); +} + +/** An object's start and end as instants. A floating one is read in `zone`. A task without a start goes by its due. */ +export function instantsOf(object: Record, zone: string | null | undefined): { start: number | null; end: number | null } { + const at = typeof object.start === 'string' ? object.start : typeof object.due === 'string' ? object.due : null; + if (!at) return { start: null, end: null }; + const start = instantOf(at, typeof object.timeZone === 'string' ? object.timeZone : zone); + if (start === null) return { start: null, end: null }; + const seconds = typeof object.duration === 'string' ? parseDurationSeconds(object.duration) : null; + return { start, end: start + (seconds ?? 0) * 1000 }; +} + +function utcString(ms: number): string { + return new Date(ms).toISOString().replace('.000Z', 'Z'); +} + +/** + * An object as a CalendarEvent/get asked for it. utcStart and utcEnd are worked + * out here, so they hold for the request's zone and for events edited offline. + * A series keeps its overrides even when not asked for: the client's + * synthetic-id mode reads occurrences without them, and it gets series from the + * store, which it can only expand correctly with them. + */ +export function projectEvent(object: CalendarObject, properties: readonly string[] | null, zone: string | null | undefined): Record { + if (!properties) return { ...object }; + const out: Record = { id: object.id }; + for (const name of properties) { + if (name === 'utcStart' || name === 'utcEnd') continue; + if (name in object) out[name] = object[name]; + } + if (properties.includes('utcStart') || properties.includes('utcEnd')) { + const { start, end } = typeof object.start === 'string' ? instantsOf(object, zone) : { start: null, end: null }; + if (start !== null && properties.includes('utcStart')) out.utcStart = utcString(start); + if (end !== null && properties.includes('utcEnd')) out.utcEnd = utcString(end); + } + if (properties.includes('recurrenceRule') && object.recurrenceOverrides && isSeries(object)) { + out.recurrenceOverrides = object.recurrenceOverrides; + } + return out; +} + +export interface EventRow { + jmap: string; + id: string; + json: string; + server_json: string | null; + hidden: number; + gen: number; +} + +export function eventRow(db: StorageAdapter, jmap: string, id: string): EventRow | undefined { + return db.get('SELECT jmap, id, json, server_json, hidden, gen FROM calendar_event WHERE jmap = ? AND id = ?', [jmap, id]); +} + +export function currentGen(db: StorageAdapter, jmap: string): number { + return Number(db.get<{ gen: number | null }>('SELECT MAX(gen) AS gen FROM calendar_event WHERE jmap = ?', [jmap])?.gen ?? 0); +} + +export interface EventWrite { + /** What answers show. */ + shown: CalendarObject; + /** The server's copy while a write is pending, otherwise null. */ + server?: CalendarObject | null; + hidden?: boolean; + gen: number; +} + +export function writeEvent(db: StorageAdapter, jmap: string, write: EventWrite): void { + const { shown } = write; + const { start, end } = instantsOf(shown, 'UTC'); + const params: SqlValue[] = [ + jmap, + shown.id, + typeof shown.uid === 'string' ? shown.uid : null, + isTaskLikeObject(shown) ? 1 : 0, + isSeries(shown) ? 1 : 0, + start, + end, + JSON.stringify(shown), + write.server ? JSON.stringify(write.server) : null, + write.hidden ? 1 : 0, + write.gen, + ]; + db.run( + `INSERT INTO calendar_event (jmap, id, uid, is_task, is_series, start_ms, end_ms, json, server_json, hidden, gen) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (jmap, id) DO UPDATE SET uid = excluded.uid, is_task = excluded.is_task, is_series = excluded.is_series, start_ms = excluded.start_ms, + end_ms = excluded.end_ms, json = excluded.json, server_json = excluded.server_json, hidden = excluded.hidden, gen = excluded.gen`, + params, + ); +} + +export function deleteEventRow(db: StorageAdapter, jmap: string, id: string): void { + db.run('DELETE FROM calendar_event WHERE jmap = ? AND id = ?', [jmap, id]); +} diff --git a/lib/store/calendar/reminders.ts b/lib/store/calendar/reminders.ts new file mode 100644 index 000000000..4ab5e3227 --- /dev/null +++ b/lib/store/calendar/reminders.ts @@ -0,0 +1,160 @@ +/** + * Which alerts fire in a time window, from what's stored. No DOM, no stores, no + * React: desktop reminders call this while the window is hidden. + * + * Keys use the master's id and the fire time, never an occurrence id. Synthetic + * ids can change, and browser occurrences don't exist on the server. A moved + * occurrence gets a new key, as it should: it's a new time to remind. + */ + +import { format, parseISO } from 'date-fns'; +import { buildAlertKey, computeFireTime, computeTaskFireTime, getEffectiveAlerts, parseAlertOffset } from '@/lib/calendar-alerts'; +import { isTaskLikeObject } from '@/lib/calendar-component-detection'; +import { normalizeCalendarEventLike, parseDurationSeconds } from '@/lib/calendar-event-normalization'; +import type { Calendar, CalendarEvent, CalendarEventAlert, CalendarTask } from '@/lib/jmap/types'; +import { expandRecurringEvents } from '@/lib/recurrence-expansion'; +import { listObjects } from '../engine/core-schema'; +import type { StorageAdapter } from '../storage'; +import { instantsOf, isSeries, type CalendarObject } from './records'; + +export interface UpcomingAlert { + /** Stable for this alert at this time. Use it to remember what was shown. */ + key: string; + jmap: string; + kind: 'event' | 'task'; + /** The stored event or task: the master of a series. */ + baseEventId: string; + /** Which occurrence of the series, when it's one. */ + recurrenceId: string | null; + alertId: string; + fireTimeMs: number; + title: string; + /** The start as a wall clock, or a task's due when it has no start. */ + start: string | null; + timeZone: string | null; + calendarId: string | null; + calendarName: string | null; +} + +export interface UpcomingAlertsOptions { + /** Alerts firing at or after this. */ + from: number; + /** Alerts firing before this. */ + to: number; + /** Reads floating events. The user's zone. */ + timeZone: string; +} + +type AlertMap = Record | null | undefined; + +// An alert fires up to its offset away from its occurrence, and the occurrence's +// wall clock is up to 26 hours from the instant. Expand wide, filter by instant. +function offsetReachMs(alerts: AlertMap): number { + let reach = 0; + for (const alert of Object.values(alerts ?? {})) { + if (alert.trigger?.['@type'] === 'AbsoluteTrigger') continue; + reach = Math.max(reach, Math.abs(parseAlertOffset(alert.trigger?.offset ?? '') ?? 0)); + } + return reach; +} + +function alertsInOverrides(event: CalendarEvent): AlertMap[] { + return Object.values(event.recurrenceOverrides ?? {}).map((override) => (override as { alerts?: AlertMap }).alerts); +} + +const ZONE_REACH_MS = 27 * 3_600_000; + +function occurrencesNear(event: CalendarEvent, calendars: Calendar[], options: UpcomingAlertsOptions): CalendarEvent[] { + const reach = ZONE_REACH_MS + + Math.max(offsetReachMs(getEffectiveAlerts(event, calendars)), ...alertsInOverrides(event).map(offsetReachMs), 0) + + (parseDurationSeconds(event.duration) ?? 0) * 1000; + const stamp = (ms: number) => format(new Date(ms), "yyyy-MM-dd'T'HH:mm:ss"); + return expandRecurringEvents([event], stamp(options.from - reach), stamp(options.to + reach)); +} + +function withInstants(object: CalendarObject, zone: string): CalendarObject { + const { start, end } = instantsOf(object, zone); + return { + ...object, + ...(start !== null ? { utcStart: new Date(start).toISOString() } : {}), + ...(end !== null ? { utcEnd: new Date(end).toISOString() } : {}), + }; +} + +function firstCalendar(calendarIds: unknown, calendars: Calendar[]): { id: string | null; name: string | null } { + const id = Object.keys((calendarIds ?? {}) as Record)[0] ?? null; + return { id, name: calendars.find((calendar) => calendar.id === id)?.name ?? null }; +} + +/** Every `display` alert of the stored events and tasks that fires in the window, soonest first. */ +export function listUpcomingAlerts(db: StorageAdapter, options: UpcomingAlertsOptions): UpcomingAlert[] { + const found = new Map(); + const add = (alert: UpcomingAlert) => { + if (alert.fireTimeMs >= options.from && alert.fireTimeMs < options.to) found.set(alert.key, alert); + }; + const rows = db.all<{ jmap: string; json: string }>('SELECT jmap, json FROM calendar_event WHERE hidden = 0'); + const calendarsOf = new Map(); + for (const row of rows) { + if (!calendarsOf.has(row.jmap)) calendarsOf.set(row.jmap, listObjects(db, row.jmap, 'Calendar')); + const calendars = calendarsOf.get(row.jmap)!; + const object = JSON.parse(row.json) as CalendarObject; + const calendar = firstCalendar(object.calendarIds, calendars); + + if (isTaskLikeObject(object)) { + const task = object as unknown as CalendarTask; + if (task.progress === 'completed' || task.progress === 'cancelled') continue; + for (const [alertId, alert] of Object.entries(task.alerts ?? {})) { + if (alert.action !== 'display' || alert.acknowledged) continue; + const fireTimeMs = computeTaskFireTime(task, alert.trigger); + if (fireTimeMs === null) continue; + add({ + key: buildAlertKey(object.id, alertId, fireTimeMs), + jmap: row.jmap, + kind: 'task', + baseEventId: object.id, + recurrenceId: null, + alertId, + fireTimeMs, + title: task.title ?? '', + start: task.start ?? task.due ?? null, + timeZone: task.timeZone ?? null, + calendarId: calendar.id, + calendarName: calendar.name, + }); + } + continue; + } + + const master = normalizeCalendarEventLike(withInstants(object, options.timeZone) as unknown as CalendarEvent); + if (typeof master.start !== 'string' || Number.isNaN(parseISO(master.start).getTime())) continue; + const series = isSeries(object); + const occurrences = series ? occurrencesNear(master, calendars, options) : [master]; + // A fixed time doesn't need an occurrence nearby. + const candidates = series ? [master, ...occurrences] : occurrences; + for (const occurrence of candidates) { + if (occurrence.status === 'cancelled') continue; + const isMasterOfSeries = series && occurrence === master; + for (const [alertId, alert] of Object.entries(getEffectiveAlerts(occurrence, calendars) ?? {})) { + if (alert.action !== 'display' || alert.acknowledged) continue; + if (isMasterOfSeries && alert.trigger['@type'] !== 'AbsoluteTrigger') continue; + const fireTimeMs = computeFireTime(occurrence, alert.trigger); + if (fireTimeMs === null) continue; + add({ + key: buildAlertKey(object.id, alertId, fireTimeMs), + jmap: row.jmap, + kind: 'event', + baseEventId: object.id, + recurrenceId: occurrence.recurrenceId ?? null, + alertId, + fireTimeMs, + title: occurrence.title ?? '', + start: occurrence.start, + timeZone: occurrence.timeZone ?? null, + calendarId: calendar.id, + calendarName: calendar.name, + }); + } + } + } + return [...found.values()].sort((a, b) => a.fireTimeMs - b.fireTimeMs || a.key.localeCompare(b.key)); +} diff --git a/lib/store/calendar/schema.ts b/lib/store/calendar/schema.ts new file mode 100644 index 000000000..1d56d505e --- /dev/null +++ b/lib/store/calendar/schema.ts @@ -0,0 +1,77 @@ +import type { Migration } from '../storage'; + +export const CALENDAR_MIGRATIONS: readonly Migration[] = [ + { + id: 500, + name: 'calendar', + sql: ` + CREATE TABLE calendar_event ( + jmap TEXT NOT NULL, + id TEXT NOT NULL, + uid TEXT, + is_task INTEGER NOT NULL DEFAULT 0, + -- 1 for a series master. Occurrences are never stored: /changes only + -- lists masters, and the client expands them. + is_series INTEGER NOT NULL DEFAULT 0, + -- Instants to narrow a query with. A floating event is read in UTC here. + start_ms INTEGER, + end_ms INTEGER, + -- What answers show: the server's copy with any pending write applied. + json TEXT NOT NULL, + -- The server's copy while a write is pending on it, otherwise NULL. + server_json TEXT, + -- 1 while a delete is pending. + hidden INTEGER NOT NULL DEFAULT 0, + gen INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (jmap, id) + ) WITHOUT ROWID; + CREATE INDEX calendar_event_uid ON calendar_event (jmap, uid); + CREATE INDEX calendar_event_start ON calendar_event (jmap, start_ms); + CREATE INDEX calendar_event_end ON calendar_event (jmap, end_ms); + + -- One row per event: a later write merges into it (see writes.ts). + CREATE TABLE calendar_pending ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + jmap TEXT NOT NULL, + record_id TEXT NOT NULL, + kind TEXT NOT NULL CHECK (kind IN ('create', 'update', 'destroy')), + -- create: the whole event. update: a PatchObject. destroy: empty. + patch TEXT NOT NULL, + -- The server's value at each patched path when it was queued. + base TEXT NOT NULL DEFAULT '{}', + -- For a create: how to find it again after a lost response. + uid TEXT, + -- sendSchedulingMessages: NULL when no write named it. + send_scheduling INTEGER, + -- 1 once a create went out, so the server may hold it already. + attempted INTEGER NOT NULL DEFAULT 0, + -- Moves on every merge, to see a write that came in during a send. + rev INTEGER NOT NULL DEFAULT 0, + queued_at INTEGER NOT NULL, + UNIQUE (jmap, record_id) + ); + + -- What a stand-in id became, for a page still holding the stand-in. + CREATE TABLE calendar_id_map ( + jmap TEXT NOT NULL, + local_id TEXT NOT NULL, + server_id TEXT NOT NULL, + PRIMARY KEY (jmap, local_id) + ) WITHOUT ROWID; + + CREATE TABLE calendar_conflict ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + jmap TEXT NOT NULL, + record_id TEXT NOT NULL, + kind TEXT NOT NULL, + reason TEXT NOT NULL, + error_type TEXT, + description TEXT, + title TEXT, + local TEXT NOT NULL, + server TEXT, + created_at INTEGER NOT NULL + ); + `, + }, +]; diff --git a/lib/store/calendar/sync.ts b/lib/store/calendar/sync.ts new file mode 100644 index 000000000..8463a0896 --- /dev/null +++ b/lib/store/calendar/sync.ts @@ -0,0 +1,264 @@ +/** + * Calendar sync for one JMAP account: calendars, the last participant + * identities, then every event and task. + * + * Masters only. CalendarEvent/changes lists nothing else on either Stalwart, an + * instance edit shows up as `updated: [master]`, and 1.0's expanded queries + * can't give changes and refuse long ranges. Occurrences are made when read. + */ + +import { isTaskLikeObject } from '@/lib/calendar-component-detection'; +import { SYNTHETIC_ID_PROBE } from '@/lib/recurrence-instances'; +import type { StoreContext, SyncContext, Syncer } from '../area'; +import { runChangesLoop } from '../engine/changes'; +import { metaGet, metaSet, putObject } from '../engine/core-schema'; +import { JmapMethodError, responseFor, TransportError } from '../engine/transport'; +import type { StorageAdapter } from '../storage'; +import { isPassingError, sendPending, type FlushTally } from './flush'; +import { + CALENDAR_PROPERTIES, + CALENDARS_CAPABILITY, + calendarUsing, + currentGen, + EVENT_PROPERTIES, + eventRow, + instantsOf, + isSeries, + type CalendarObject, +} from './records'; +import { applyServerDestroy, applyServerEvent, isStandIn, pendingFor } from './writes'; + +const PAGE = 500; +/** How far back single events are kept. A series stays whatever its start, a task whatever its date. */ +const PAST_WINDOW_MS = 2 * 366 * 86_400_000; + +function syntheticIdsKey(jmap: string): string { + return `calendar.syntheticIds.${jmap}`; +} + +/** + * What this account's server said to the synthetic-id probe at the last sync. + * Null before it ever answered. A client that probed online holds its verdict + * for life and never asks again; only one started offline asks the store. + */ +export function syntheticIdVerdict(ctx: StoreContext, jmap: string): boolean | null { + const value = metaGet(ctx.db, syntheticIdsKey(jmap)); + return value === null ? null : value === '1'; +} + +// A shared account probed on suspicion that grants no calendar access (the +// client treats these the same way). +function isAccessDenial(error: unknown): boolean { + return error instanceof JmapMethodError + && (error.type === 'forbidden' || error.type === 'accountNotFound' || error.type === 'accountNotSupportedByMethod'); +} + +/** False when the account grants no calendar access. */ +async function syncCalendars(ctx: SyncContext, jmap: string): Promise { + const response = await ctx.transport.request([ + ['Calendar/get', { accountId: jmap, ids: null, properties: CALENDAR_PROPERTIES }, 'c'], + ['CalendarEvent/set', { accountId: jmap, update: { [SYNTHETIC_ID_PROBE]: {} } }, 'p'], + ['ParticipantIdentity/get', { accountId: jmap }, 'i'], + ], calendarUsing(ctx.session)); + let result: { list: Array<{ id: string }>; state: unknown }; + try { + result = responseFor(response, 'c')[1]; + } catch (error) { + if (isAccessDenial(error)) return false; + throw error; + } + const probe = response.methodResponses.find(([, , id]) => id === 'p'); + const identities = response.methodResponses.find(([, , id]) => id === 'i'); + const before = ctx.syncState(jmap, 'Calendar').state; + ctx.db.transaction(() => { + ctx.db.run("DELETE FROM object WHERE jmap = ? AND type = 'Calendar'", [jmap]); + for (const calendar of result.list) putObject(ctx.db, jmap, 'Calendar', calendar.id, calendar); + ctx.setSyncState(jmap, 'Calendar', { state: String(result.state) }); + if (probe?.[0] === 'CalendarEvent/set') { + const failure = probe[1]?.notUpdated?.[SYNTHETIC_ID_PROBE] as { type?: string } | undefined; + metaSet(ctx.db, syntheticIdsKey(jmap), failure?.type === 'notFound' ? '1' : '0'); + } + // Only ever read as "the last value". 0.16 has no usable /changes for it. + if (identities?.[0] === 'ParticipantIdentity/get') { + ctx.db.run("DELETE FROM object WHERE jmap = ? AND type = 'ParticipantIdentity'", [jmap]); + for (const identity of identities[1].list as Array<{ id: string }>) putObject(ctx.db, jmap, 'ParticipantIdentity', identity.id, identity); + } + }); + if (before !== String(result.state)) ctx.emit({ type: 'changed', account: ctx.account, collections: ['Calendar'] }); + return true; +} + +function inWindow(ctx: SyncContext, event: CalendarObject): boolean { + if (isSeries(event) || isTaskLikeObject(event)) return true; + const { end } = instantsOf(event, 'UTC'); + return end === null || end >= ctx.now() - PAST_WINDOW_MS; +} + +interface ListCursor { position: number; gen: number; captured: string } + +async function startFullList(ctx: SyncContext, jmap: string): Promise { + const response = await ctx.transport.request([['CalendarEvent/get', { accountId: jmap, ids: [], properties: ['id'] }, 's']], calendarUsing(ctx.session)); + const captured = String(responseFor(response, 's')[1].state); + const cursor: ListCursor = { position: 0, gen: currentGen(ctx.db, jmap) + 1, captured }; + ctx.db.transaction(() => ctx.setSyncState(jmap, 'CalendarEvent', { state: null, phase: 'list', cursor: { ...cursor } })); + return cursor; +} + +function storeEvent(ctx: SyncContext, jmap: string, event: CalendarObject, gen: number): void { + // An old event changing doesn't pull it back into the window. + if (!inWindow(ctx, event) && !eventRow(ctx.db, jmap, event.id) && !pendingFor(ctx.db, jmap, event.id)) return; + applyServerEvent(ctx, jmap, event, gen); +} + +/** Pages through every object: the query has no filter for tasks, and old series must stay. False when stopped. */ +async function runFullList(ctx: SyncContext, jmap: string, start: ListCursor): Promise { + const cursor = { ...start }; + for (;;) { + if (ctx.stopped()) return false; + const response = await ctx.transport.request([ + ['CalendarEvent/query', { accountId: jmap, position: cursor.position, limit: PAGE }, 'q'], + ['CalendarEvent/get', { + accountId: jmap, + '#ids': { resultOf: 'q', name: 'CalendarEvent/query', path: '/ids' }, + properties: EVENT_PROPERTIES, + }, 'g'], + ], calendarUsing(ctx.session)); + const ids = responseFor(response, 'q')[1].ids as string[]; + const list = responseFor(response, 'g')[1].list as CalendarObject[]; + const done = ids.length < PAGE; + cursor.position += ids.length; + ctx.db.transaction(() => { + for (const event of list) storeEvent(ctx, jmap, event, cursor.gen); + if (done) { + // Whatever this pass didn't see is gone from the server. + const stale = ctx.db.all<{ id: string }>('SELECT id FROM calendar_event WHERE jmap = ? AND gen < ?', [jmap, cursor.gen]); + for (const { id } of stale) if (!isStandIn(id)) applyServerDestroy(ctx, jmap, id); + ctx.setSyncState(jmap, 'CalendarEvent', { state: cursor.captured, phase: null, cursor: null }); + } else { + ctx.setSyncState(jmap, 'CalendarEvent', { cursor: { ...cursor } }); + } + }); + ctx.emit({ type: 'progress', account: ctx.account, area: 'calendar', phase: 'list', done: cursor.position, total: null }); + if (done) { + ctx.emit({ type: 'changed', account: ctx.account, collections: ['CalendarEvent'] }); + 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: 'CalendarEvent', + jmap, + using: calendarUsing(ctx.session), + since, + target, + stopped: ctx.stopped, + apply: async (page) => { + const ids = [...page.created, ...page.updated]; + const fetched: CalendarObject[] = []; + for (let i = 0; i < ids.length; i += PAGE) { + const response = await ctx.transport.request([['CalendarEvent/get', { + accountId: jmap, + ids: ids.slice(i, i + PAGE), + properties: EVENT_PROPERTIES, + }, 'g']], calendarUsing(ctx.session)); + fetched.push(...responseFor(response, 'g')[1].list as CalendarObject[]); + } + ctx.db.transaction(() => { + const gen = currentGen(ctx.db, jmap); + for (const event of fetched) storeEvent(ctx, jmap, event, gen); + for (const id of page.destroyed) applyServerDestroy(ctx, jmap, id); + ctx.setSyncState(jmap, 'CalendarEvent', { state: page.newState }); + }); + if (ids.length + page.destroyed.length > 0) ctx.emit({ type: 'changed', account: ctx.account, collections: ['CalendarEvent'] }); + }, + }); + if (outcome.outcome === 'resync') return 'resync'; + return outcome.outcome === 'stopped' ? 'stopped' : 'done'; +} + +/** False when stopped before catching up. */ +async function syncEvents(ctx: SyncContext, jmap: string): Promise { + const sync = ctx.syncState(jmap, 'CalendarEvent'); + let cursor = sync.phase === 'list' ? sync.cursor as unknown as ListCursor : null; + if (!cursor && !sync.state) cursor = await startFullList(ctx, jmap); + if (!cursor && sync.state) { + const outcome = await syncDelta(ctx, jmap, sync.state, sync.target); + if (outcome === 'stopped') return false; + if (outcome === 'resync') cursor = await startFullList(ctx, jmap); + } + if (cursor) { + if (!(await runFullList(ctx, jmap, cursor))) return false; + // What changed while paging. + const after = ctx.syncState(jmap, 'CalendarEvent'); + if (after.state && (await syncDelta(ctx, jmap, after.state, after.target)) === 'stopped') return false; + } + return true; +} + +/** Single events that aged out of the window. The window moves on without any change on the server. */ +function pruneOld(ctx: SyncContext, jmap: string): void { + ctx.db.run( + `DELETE FROM calendar_event WHERE jmap = ? AND is_series = 0 AND is_task = 0 AND end_ms IS NOT NULL AND end_ms < ? + AND NOT EXISTS (SELECT 1 FROM calendar_pending p WHERE p.jmap = calendar_event.jmap AND p.record_id = calendar_event.id)`, + [jmap, ctx.now() - PAST_WINDOW_MS], + ); +} + +// A sync and a flush must not interleave. Our own write coming back through +// /changes while it's still pending would read as someone else's change. +const queues = new WeakMap>(); + +function oneAtATime(db: StorageAdapter, work: () => Promise): Promise { + const next = (queues.get(db) ?? Promise.resolve()).catch(() => {}).then(work); + queues.set(db, next); + return next; +} + +async function syncAndSend(ctx: SyncContext, jmap: string): Promise { + const none: FlushTally = { sent: 0, conflicts: 0 }; + if (!(await syncEvents(ctx, jmap)) || ctx.stopped()) return none; + const tally = await sendPending(ctx, jmap); + if (tally.sent > 0 && !ctx.stopped()) await syncEvents(ctx, jmap); + return tally; +} + +export const calendarSyncer: Syncer = { + name: 'calendar', + capability: CALENDARS_CAPABILITY, + async run(ctx, jmap) { + if (!(await syncCalendars(ctx, jmap)) || ctx.stopped()) return; + await oneAtATime(ctx.db, () => syncAndSend(ctx, jmap)); + pruneOld(ctx, jmap); + }, +}; + +export interface FlushResult extends FlushTally { + /** Writes still waiting: the network went away, or the server had a bad moment. */ + pending: number; +} + +/** + * `calendar.flush`: catches up with the server, so a write that collides with + * a change there is found, then sends every pending write. Doesn't throw for a + * lost connection: what wasn't sent stays pending. + */ +export function flushCalendar(ctx: SyncContext): Promise { + return oneAtATime(ctx.db, async () => { + const accounts = ctx.db.all<{ jmap: string }>('SELECT DISTINCT jmap FROM calendar_pending').map((row) => row.jmap); + const total: FlushTally = { sent: 0, conflicts: 0 }; + try { + for (const jmap of accounts) { + const tally = await syncAndSend(ctx, jmap); + total.sent += tally.sent; + total.conflicts += tally.conflicts; + } + } catch (error) { + if (!(error instanceof TransportError) && !isPassingError(error)) throw error; + } + const pending = Number(ctx.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM calendar_pending')?.n ?? 0); + return { ...total, pending }; + }); +} diff --git a/lib/store/calendar/writes.ts b/lib/store/calendar/writes.ts new file mode 100644 index 000000000..80d6b005b --- /dev/null +++ b/lib/store/calendar/writes.ts @@ -0,0 +1,259 @@ +/** + * Calendar writes made while the server can't be reached. + * + * Each event has at most one pending row. A later write merges into it, so a + * flush sends one change per event: edit + edit is one patch, create + edit is + * one create, create + delete is nothing, edit + delete is a delete. + * + * Answers read calendar_event.json, which is the server's copy with the pending + * write applied. server_json keeps the server's copy underneath, so sync can + * move it on and reapply the write, or notice that the two collide. + */ + +import { applyPatchObject } from '@/lib/jmap/patch-object'; +import { mergeOverrideEntry, stableJson } from '@/lib/recurrence-instances'; +import type { StoreContext } from '../area'; +import type { StorageAdapter } from '../storage'; +import { currentGen, deleteEventRow, eventRow, valueAtPointer, writeEvent, type CalendarObject, type EventRow } from './records'; + +export interface PendingRow { + seq: number; + jmap: string; + record_id: string; + kind: 'create' | 'update' | 'destroy'; + patch: string; + base: string; + uid: string | null; + send_scheduling: number | null; + attempted: number; + rev: number; + queued_at: number; +} + +export type ConflictReason = 'refused' | 'changedOnServer' | 'destroyedOnServer'; + +export interface CalendarConflict { + id: number; + jmap: string; + recordId: string; + kind: PendingRow['kind']; + reason: ConflictReason; + errorType: string | null; + description: string | null; + title: string | null; + /** The write that wasn't made: the whole event for a create, otherwise the patch. */ + local: Record; + /** The event as the server has it, when it still does. */ + server: Record | null; + createdAt: number; +} + +const STAND_IN_PREFIX = 'local-'; + +export function isStandIn(id: string): boolean { + return id.startsWith(STAND_IN_PREFIX); +} + +export function pendingFor(db: StorageAdapter, jmap: string, id: string): PendingRow | undefined { + return db.get('SELECT * FROM calendar_pending WHERE jmap = ? AND record_id = ?', [jmap, id]); +} + +/** What a stand-in id became, once its create reached the server. */ +export function resolveId(db: StorageAdapter, jmap: string, id: string): string { + if (!isStandIn(id)) return id; + return db.get<{ server_id: string }>('SELECT server_id FROM calendar_id_map WHERE jmap = ? AND local_id = ?', [jmap, id])?.server_id ?? id; +} + +function mergedScheduling(old: number | null, next: boolean | undefined): number | null { + if (next === undefined) return old; + return old === 1 || next ? 1 : 0; +} + +function serverCopy(row: EventRow): CalendarObject { + return JSON.parse(row.server_json ?? row.json) as CalendarObject; +} + +function changed(ctx: StoreContext, ...collections: string[]): void { + ctx.emit({ type: 'changed', account: ctx.account, collections }); +} + +/** Creates a local event under a stand-in id. The uid is what finds it again after a lost response. */ +export function queueCreate(ctx: StoreContext, jmap: string, event: Record, sendScheduling: boolean | undefined): { id: string; uid: string } { + const id = `${STAND_IN_PREFIX}${crypto.randomUUID()}`; + const uid = typeof event.uid === 'string' && event.uid ? event.uid : crypto.randomUUID(); + const payload = { '@type': 'Event', ...event, uid }; + ctx.db.run( + `INSERT INTO calendar_pending (jmap, record_id, kind, patch, uid, send_scheduling, queued_at) VALUES (?, ?, 'create', ?, ?, ?, ?)`, + [jmap, id, JSON.stringify(payload), uid, sendScheduling === undefined ? null : Number(sendScheduling), ctx.now()], + ); + writeEvent(ctx.db, jmap, { shown: { ...payload, id, isOrigin: true }, gen: currentGen(ctx.db, jmap) }); + return { id, uid }; +} + +/** False when the store has no such event. */ +export function queueUpdate(ctx: StoreContext, jmap: string, id: string, patch: Record, sendScheduling: boolean | undefined): boolean { + const row = eventRow(ctx.db, jmap, id); + if (!row || row.hidden) return false; + const pending = pendingFor(ctx.db, jmap, id); + const gen = row.gen; + if (pending?.kind === 'create') { + const payload = applyPatchObject(JSON.parse(pending.patch) as Record, patch); + ctx.db.run( + 'UPDATE calendar_pending SET patch = ?, send_scheduling = ?, rev = rev + 1 WHERE seq = ?', + [JSON.stringify(payload), mergedScheduling(pending.send_scheduling, sendScheduling), pending.seq], + ); + writeEvent(ctx.db, jmap, { shown: { ...payload, id, isOrigin: true } as CalendarObject, gen }); + return true; + } + const server = serverCopy(row); + const earlier = pending ? JSON.parse(pending.patch) as Record : {}; + const earlierBase = pending ? JSON.parse(pending.base) as Record : {}; + const merged = mergeOverrideEntry(earlier, patch); + const base: Record = {}; + for (const key of Object.keys(merged)) { + base[key] = key in earlierBase ? earlierBase[key] : valueAtPointer(server, key) ?? null; + } + if (pending) { + ctx.db.run( + 'UPDATE calendar_pending SET patch = ?, base = ?, send_scheduling = ?, rev = rev + 1 WHERE seq = ?', + [JSON.stringify(merged), JSON.stringify(base), mergedScheduling(pending.send_scheduling, sendScheduling), pending.seq], + ); + } else { + ctx.db.run( + `INSERT INTO calendar_pending (jmap, record_id, kind, patch, base, send_scheduling, queued_at) VALUES (?, ?, 'update', ?, ?, ?, ?)`, + [jmap, id, JSON.stringify(merged), JSON.stringify(base), sendScheduling === undefined ? null : Number(sendScheduling), ctx.now()], + ); + } + writeEvent(ctx.db, jmap, { shown: applyPatchObject(server, merged), server, gen }); + return true; +} + +/** False when the store has no such event. */ +export function queueDestroy(ctx: StoreContext, jmap: string, id: string, sendScheduling: boolean | undefined): boolean { + const row = eventRow(ctx.db, jmap, id); + if (!row || row.hidden) return false; + const pending = pendingFor(ctx.db, jmap, id); + if (pending?.kind === 'create' && !pending.attempted) { + // It never left the device. + ctx.db.run('DELETE FROM calendar_pending WHERE seq = ?', [pending.seq]); + deleteEventRow(ctx.db, jmap, id); + return true; + } + if (pending) { + // A create that went out stays findable by its uid, so the delete can follow it. + ctx.db.run( + `UPDATE calendar_pending SET kind = 'destroy', patch = '{}', base = '{}', send_scheduling = ?, rev = rev + 1 WHERE seq = ?`, + [mergedScheduling(pending.send_scheduling, sendScheduling), pending.seq], + ); + } else { + ctx.db.run( + `INSERT INTO calendar_pending (jmap, record_id, kind, patch, send_scheduling, queued_at) VALUES (?, ?, 'destroy', '{}', ?, ?)`, + [jmap, id, sendScheduling === undefined ? null : Number(sendScheduling), ctx.now()], + ); + } + const shown = JSON.parse(row.json) as CalendarObject; + writeEvent(ctx.db, jmap, { + shown, + server: pending?.kind === 'create' ? null : serverCopy(row), + hidden: true, + gen: row.gen, + }); + return true; +} + +/** Whether the server moved a value this patch writes, to something other than what the patch writes. */ +function collides(pending: PendingRow, server: Record): boolean { + const base = JSON.parse(pending.base) as Record; + const patch = JSON.parse(pending.patch) as Record; + return Object.keys(patch).some((key) => { + const now = stableJson(valueAtPointer(server, key)); + return now !== stableJson(base[key]) && now !== stableJson(patch[key]); + }); +} + +/** + * Puts a write on the conflict list and takes it off the event, which goes back + * to the server's copy. A refused create leaves nothing behind. + */ +export function recordConflict( + ctx: StoreContext, + pending: PendingRow, + reason: ConflictReason, + error?: { type?: string; description?: string }, +): void { + const { db } = ctx; + const row = eventRow(db, pending.jmap, pending.record_id); + const shown = row ? JSON.parse(row.json) as Record : null; + const known = row && pending.kind !== 'create' && !(pending.kind === 'destroy' && pending.uid); + const server = known && reason !== 'destroyedOnServer' ? serverCopy(row) : null; + db.run( + `INSERT INTO calendar_conflict (jmap, record_id, kind, reason, error_type, description, title, local, server, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + pending.jmap, pending.record_id, pending.kind, reason, error?.type ?? null, error?.description ?? null, + typeof shown?.title === 'string' ? shown.title : null, pending.patch, server ? JSON.stringify(server) : null, ctx.now(), + ], + ); + db.run('DELETE FROM calendar_pending WHERE seq = ?', [pending.seq]); + if (row) { + if (server) writeEvent(db, pending.jmap, { shown: server, gen: row.gen }); + else deleteEventRow(db, pending.jmap, pending.record_id); + } + changed(ctx, 'CalendarEvent', 'CalendarConflict'); +} + +/** The server's copy of an event arriving during sync. Any pending write goes back on top, or to the conflict list. */ +export function applyServerEvent(ctx: StoreContext, jmap: string, event: CalendarObject, gen: number): void { + const { db } = ctx; + const pending = pendingFor(db, jmap, event.id); + if (!pending) { + writeEvent(db, jmap, { shown: event, gen }); + return; + } + const row = eventRow(db, jmap, event.id); + if (pending.kind === 'destroy') { + const previous = row ? stableJson(serverCopy(row)) : null; + if (previous !== null && previous !== stableJson(event)) { + // Deleting what someone just changed is the one write that can't be taken + // back, so the change wins and the user decides. + writeEvent(db, jmap, { shown: JSON.parse(row!.json) as CalendarObject, server: event, hidden: true, gen }); + recordConflict(ctx, pending, 'changedOnServer'); + return; + } + writeEvent(db, jmap, { shown: event, server: event, hidden: true, gen }); + return; + } + writeEvent(db, jmap, { shown: applyPatchObject(event, JSON.parse(pending.patch) as Record), server: event, gen }); + if (collides(pending, event)) recordConflict(ctx, pending, 'changedOnServer'); +} + +/** The server no longer has this event. A pending delete is done; a pending edit has nothing left to change. */ +export function applyServerDestroy(ctx: StoreContext, jmap: string, id: string): void { + const pending = pendingFor(ctx.db, jmap, id); + if (pending?.kind === 'update') { + recordConflict(ctx, pending, 'destroyedOnServer'); + return; + } + if (pending) ctx.db.run('DELETE FROM calendar_pending WHERE seq = ?', [pending.seq]); + deleteEventRow(ctx.db, jmap, id); +} + +export function listConflicts(db: StorageAdapter): CalendarConflict[] { + const rows = db.all<{ + id: number; jmap: string; record_id: string; kind: PendingRow['kind']; reason: ConflictReason; error_type: string | null; + description: string | null; title: string | null; local: string; server: string | null; created_at: number; + }>('SELECT * FROM calendar_conflict ORDER BY id'); + return rows.map((row) => ({ + id: Number(row.id), + jmap: row.jmap, + recordId: row.record_id, + kind: row.kind, + reason: row.reason, + errorType: row.error_type, + description: row.description, + title: row.title, + local: JSON.parse(row.local) as Record, + server: row.server ? JSON.parse(row.server) as Record : null, + createdAt: Number(row.created_at), + })); +} diff --git a/lib/store/engine/account-engine.ts b/lib/store/engine/account-engine.ts index 6281afc71..ab3a2b9b5 100644 --- a/lib/store/engine/account-engine.ts +++ b/lib/store/engine/account-engine.ts @@ -217,7 +217,7 @@ export class AccountEngine { async areaCall(name: string, params: unknown): Promise { const call = this.calls.get(name); if (!call) throw new Error(`Unknown store call ${name}`); - return call(this.storeContext(), params as never); + return call(this.syncContext(), params as never); } /** An answer from the device. Null when nothing in the request could be answered. */ diff --git a/stores/__tests__/calendar-store-offline-occurrence.test.ts b/stores/__tests__/calendar-store-offline-occurrence.test.ts new file mode 100644 index 000000000..4cccb256a --- /dev/null +++ b/stores/__tests__/calendar-store-offline-occurrence.test.ts @@ -0,0 +1,117 @@ +// @vitest-environment node +import { afterEach, describe, expect, it } from 'vitest'; +import { calendarArea } from '@/lib/store/calendar'; +import type { CalendarConflict } from '@/lib/store/calendar/writes'; +import { FakeCalendarJmap } from '@/lib/store/__tests__/helpers/fake-calendar'; +import { wire, type Wire } from '@/lib/store/__tests__/helpers/client-wire'; +import { isBrowserExpandedOccurrence, isServerRecurrenceInstance } from '@/lib/recurrence-instances'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import { useCalendarStore } from '../calendar-store'; + +const NOW = Date.UTC(2026, 10, 3, 12); +const START = '2026-11-02T00:00:00'; +const END = '2026-11-23T00:00:00'; +const ME = { '@type': 'Participant', calendarAddress: 'mailto:a@example.org', participationStatus: 'needs-action', roles: { attendee: true } }; +const ORGANIZER = { '@type': 'Participant', calendarAddress: 'mailto:boss@example.org', participationStatus: 'accepted', roles: { owner: true } }; + +let current: Wire | null = null; + +afterEach(() => { + current?.restore(); + current = null; + useCalendarStore.setState({ events: [], calendars: [] }); +}); + +async function online() { + const server = new FakeCalendarJmap('1.0'); + server.addEvent({ + id: 'series', + title: 'Weekly', + start: '2026-11-02T10:00:00', + recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'weekly' }, + organizerCalendarAddress: 'mailto:boss@example.org', + participants: { me: ME, boss: ORGANIZER }, + }); + const w = wire(server, [calendarArea], NOW); + current = w; + await w.engine.requestSync(); + const client = await w.newClient() as unknown as IJMAPClient; + await client.getCalendars(); + await useCalendarStore.getState().fetchEvents(client, START, END); + return { w, client }; +} + +const overrides = (w: Wire) => w.server.events.get('series')?.recurrenceOverrides as Record> | undefined; +const flush = (w: Wire) => w.engine.areaCall('calendar.flush', undefined) as Promise<{ sent: number; conflicts: number; pending: number }>; +const conflicts = (w: Wire) => w.engine.areaCall('calendar.conflicts', undefined) as Promise; +const occurrenceOn = (day: string) => useCalendarStore.getState().events.find((event) => event.start.startsWith(day)); + +describe('an occurrence on screen from before the connection went', () => { + it('RSVP: goes in as an override on the master, shows after the refetch, reaches the server', async () => { + const { w, client } = await online(); + const instance = occurrenceOn('2026-11-09'); + expect(instance && isServerRecurrenceInstance(instance)).toBe(true); + w.online = false; + + await useCalendarStore.getState().rsvpEvent(client, instance!.id, 'me', 'accepted', undefined, 'occurrence'); + expect(overrides(w)).toBeUndefined(); + + // The refetch after the answer came from the store, with occurrences of its own. + const again = occurrenceOn('2026-11-09'); + expect(again && isBrowserExpandedOccurrence(again)).toBe(true); + expect(again?.participants?.me.participationStatus).toBe('accepted'); + expect(occurrenceOn('2026-11-16')?.participants?.me.participationStatus).toBe('needs-action'); + + w.online = true; + expect(await flush(w)).toMatchObject({ sent: 1, pending: 0 }); + expect(await conflicts(w)).toEqual([]); + const entry = overrides(w)?.['2026-11-09T10:00:00'] as { participants?: Record }; + expect(entry.participants?.me.participationStatus).toBe('accepted'); + }); + + it('RSVP for the whole series: a pointer patch on the master, every occurrence shows it', async () => { + const { w, client } = await online(); + const instance = occurrenceOn('2026-11-09'); + w.online = false; + await useCalendarStore.getState().rsvpEvent(client, instance!.id, 'me', 'accepted'); + expect(occurrenceOn('2026-11-16')?.participants?.me.participationStatus).toBe('accepted'); + w.online = true; + expect(await flush(w)).toMatchObject({ sent: 1, pending: 0 }); + const master = w.server.events.get('series') as unknown as { participants: Record }; + expect(master.participants.me.participationStatus).toBe('accepted'); + expect(master.participants.boss.participationStatus).toBe('accepted'); + }); + + it('delete of one occurrence: excluded on the master, gone from the grid', async () => { + const { w, client } = await online(); + const instance = occurrenceOn('2026-11-16'); + w.online = false; + await useCalendarStore.getState().deleteEvent(client, instance!.id); + expect(occurrenceOn('2026-11-16')).toBeUndefined(); + expect(occurrenceOn('2026-11-09')).toBeTruthy(); + w.online = true; + await flush(w); + expect(overrides(w)?.['2026-11-16T10:00:00']).toMatchObject({ excluded: true }); + }); + + it('edit of one occurrence, then another, of the same series: both reach the server in one write', async () => { + const { w, client } = await online(); + w.online = false; + await useCalendarStore.getState().updateEvent(client, occurrenceOn('2026-11-09')!.id, { title: 'First change' }); + expect(occurrenceOn('2026-11-09')?.title).toBe('First change'); + await useCalendarStore.getState().updateEvent(client, occurrenceOn('2026-11-16')!.id, { title: 'Second change' }); + expect(occurrenceOn('2026-11-09')?.title).toBe('First change'); + expect(occurrenceOn('2026-11-16')?.title).toBe('Second change'); + expect(occurrenceOn('2026-11-02')?.title).toBe('Weekly'); + + w.online = true; + const before = w.server.calls.length; + await flush(w); + const writes = w.server.calls.slice(before).filter(([name]) => name === 'CalendarEvent/set'); + expect(writes).toHaveLength(2); // series keys refused once by 1.0, then without them + expect(await conflicts(w)).toEqual([]); + expect(overrides(w)?.['2026-11-09T10:00:00']).toMatchObject({ title: 'First change' }); + expect(overrides(w)?.['2026-11-16T10:00:00']).toMatchObject({ title: 'Second change' }); + expect(overrides(w)?.['2026-11-09T10:00:00']).not.toHaveProperty('organizerCalendarAddress'); + }); +}); From 4ee918a98fbd0b355fd7a19081893a479f76ae87 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 05:46:55 +0100 Subject: [PATCH 11/16] fix: list a large folder off its index and sync message bodies in bigger batches --- lib/store/__tests__/mail-sync.test.ts | 46 +++++++++++++++++++ lib/store/adapters/wasm.ts | 2 + lib/store/mail/methods.ts | 29 +++++++++--- lib/store/mail/schema.ts | 4 +- lib/store/mail/sync.ts | 63 +++++++++++++++++---------- lib/store/storage.ts | 8 +++- 6 files changed, 120 insertions(+), 32 deletions(-) diff --git a/lib/store/__tests__/mail-sync.test.ts b/lib/store/__tests__/mail-sync.test.ts index 80e1c69fe..a5ddae149 100644 --- a/lib/store/__tests__/mail-sync.test.ts +++ b/lib/store/__tests__/mail-sync.test.ts @@ -6,6 +6,8 @@ import path from 'node:path'; import { DEFAULT_SCOPE, type StoreContext, type StoreEvent } from '../area'; import { AccountEngine } from '../engine/account-engine'; import { runChangesLoop } from '../engine/changes'; +import { answerRequest, buildMethodTable } from '../engine/responder'; +import type { SqlValue } from '../storage'; import { TransportError, type MethodCall } from '../engine/transport'; import { mailArea } from '../mail'; import { FAKE_SESSION, FakeJmap, JMAP_ACCOUNT } from './helpers/fake-jmap'; @@ -320,6 +322,50 @@ describe('when the server is busy or gone', () => { }); }); +describe('query plans', () => { + it('reads a newest-first folder page off the index, without sorting the folder', async () => { + const server = new FakeJmap(); + for (let i = 0; i < 30; i += 1) server.addEmail(); + const db = openMemoryStore(); + const engine = engineFor(server, db); + await engine.requestSync(); + const seen: string[] = []; + const spied = { + ...db, + all: (sql: string, params?: readonly SqlValue[]) => { + seen.push(sql); + return db.all(sql, params); + }, + }; + const answer = await answerRequest( + { account: 'a', db: spied as typeof db, session: FAKE_SESSION, scope: EVERYTHING, now: Date.now, emit: () => {}, defaultJmap: JMAP_ACCOUNT, invertedKeywordSort: false }, + buildMethodTable([mailArea]), + [['Email/query', { filter: INBOX, sort: [{ property: 'receivedAt', isAscending: false }], limit: 5 }, 'q']], + ); + expect(answer?.methodResponses[0][1].ids).toHaveLength(5); + const [sql] = seen; + const plan = db.all<{ detail: string }>(`EXPLAIN QUERY PLAN ${sql}`, [JMAP_ACCOUNT, 'inbox', 5, 0]).map((row) => row.detail).join('; '); + expect(plan).not.toContain('TEMP B-TREE'); + expect(plan).toContain('email_mailbox_list'); + // The folder condition isn't looked up again for every row. + expect(sql).not.toContain('m.mailbox_id'); + }); +}); + +describe('a failure inside a transaction', () => { + it('reports the real error even when SQLite has already rolled back', () => { + const db = openMemoryStore(); + db.exec('CREATE TABLE t (id INTEGER PRIMARY KEY)'); + expect(() => db.transaction(() => { + db.exec('ROLLBACK'); + throw new Error('disk is full'); + })).toThrow('disk is full'); + // And the connection still works. + db.transaction(() => db.run('INSERT INTO t (id) VALUES (1)')); + expect(db.all('SELECT id FROM t')).toHaveLength(1); + }); +}); + describe('calls into an area', () => { const noteArea = { name: 'note', diff --git a/lib/store/adapters/wasm.ts b/lib/store/adapters/wasm.ts index e85bf14f4..2af1373fe 100644 --- a/lib/store/adapters/wasm.ts +++ b/lib/store/adapters/wasm.ts @@ -165,6 +165,8 @@ export class WasmStorage { const pageSize = Number(db.selectValue('PRAGMA page_size')); if (pageSize !== REQUIRED_PAGE_SIZE) throw new Error(`Refusing an unsafe page size: ${pageSize}`); db.exec('PRAGMA temp_store = MEMORY'); + // Every page read is a decryption, so a miss costs more than usual. + db.exec('PRAGMA cache_size = -65536'); db.exec('PRAGMA foreign_keys = OFF'); db.exec('PRAGMA synchronous = NORMAL'); } catch (error) { diff --git a/lib/store/mail/methods.ts b/lib/store/mail/methods.ts index e91bdd45c..65493b7ef 100644 --- a/lib/store/mail/methods.ts +++ b/lib/store/mail/methods.ts @@ -15,10 +15,14 @@ function jmapOf(ctx: MethodContext, args: Record): string { return (args.accountId as string | undefined) ?? ctx.defaultJmap; } -function filterSql(filter: Filter, jmap: string): Sql { +/** + * `drivenBy` is the folder the query already starts from: repeating that + * condition would look the membership up again for every row. + */ +function filterSql(filter: Filter, jmap: string, drivenBy: string | null = null): Sql { if (typeof filter.operator === 'string') { const conditions = Array.isArray(filter.conditions) ? filter.conditions as Filter[] : []; - const parts = conditions.map((c) => filterSql(c, jmap)); + const parts = conditions.map((c) => filterSql(c, jmap, filter.operator === 'AND' ? drivenBy : null)); 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 }; @@ -31,6 +35,7 @@ function filterSql(filter: Filter, jmap: string): Sql { for (const [key, value] of Object.entries(filter)) { switch (key) { case 'inMailbox': + if (value === drivenBy) break; conditions.push('EXISTS (SELECT 1 FROM email_mailbox m WHERE m.email_rowid = e.rowid AND m.mailbox_id = ?)'); params.push(String(value)); break; @@ -87,6 +92,15 @@ function filterSql(filter: Filter, jmap: string): Sql { interface Comparator { property?: string; isAscending?: boolean; keyword?: string } +/** + * Newest first reads straight off an index. Any other order has to look at + * every row of the folder, which in a large mailbox takes seconds. + */ +function isNewestFirst(sort: Comparator[]): boolean { + if (sort.length === 0) return true; + return sort.length === 1 && sort[0].property === 'receivedAt' && sort[0].isAscending === false; +} + function orderSql(ctx: MethodContext, sort: Comparator[]): Sql { const levels = sort.length > 0 ? sort : [{ property: 'receivedAt', isAscending: false }]; const parts: string[] = []; @@ -134,9 +148,12 @@ const emailQuery: MethodHandler = (ctx, args) => { const jmap = jmapOf(ctx, args); const filter = (args.filter ?? undefined) as Filter | undefined; if (args.anchor !== undefined) throw new MethodError('unsupportedFilter', 'anchor needs the server'); - const where = filter ? filterSql(filter, jmap) : { sql: '1', params: [] }; - const order = orderSql(ctx, (args.sort as Comparator[]) ?? []); const box = drivingMailbox(filter); + const where = filter ? filterSql(filter, jmap, box) : { sql: '1', params: [] }; + const sort = (args.sort as Comparator[]) ?? []; + const order: Sql = !isNewestFirst(sort) + ? orderSql(ctx, sort) + : { sql: box ? 'd.received_at DESC, d.email_rowid DESC' : 'e.received_at DESC, e.rowid DESC', params: [] }; // Starting from the folder's own index keeps small folders fast in a large account. const from = box ? 'email_mailbox d INDEXED BY email_mailbox_list CROSS JOIN email e ON e.rowid = d.email_rowid' @@ -162,8 +179,10 @@ const emailQuery: MethodHandler = (ctx, args) => { }; if (args.calculateTotal) { const count = collapse ? 'COUNT(DISTINCT COALESCE(e.thread_id, e.id))' : 'COUNT(*)'; + // A plain folder counts from its index alone, without reading a message. + const countFrom = box && !collapse && where.sql === '1' ? 'email_mailbox d INDEXED BY email_mailbox_list' : from; result.total = Number(ctx.db.get<{ n: number }>( - `SELECT ${count} AS n FROM ${from} WHERE ${base.sql} AND (${where.sql})`, + `SELECT ${count} AS n FROM ${countFrom} WHERE ${base.sql} AND (${where.sql})`, [...base.params, ...where.params], )?.n ?? 0); } diff --git a/lib/store/mail/schema.ts b/lib/store/mail/schema.ts index 52ef16dde..98f29576d 100644 --- a/lib/store/mail/schema.ts +++ b/lib/store/mail/schema.ts @@ -23,7 +23,7 @@ export const MAIL_MIGRATIONS: readonly Migration[] = [ gen INTEGER NOT NULL DEFAULT 0, UNIQUE (jmap, id) ); - CREATE INDEX email_received ON email (jmap, received_at DESC); + CREATE INDEX email_received ON email (jmap, received_at DESC, rowid DESC); CREATE INDEX email_thread ON email (jmap, thread_id); CREATE INDEX email_backfill ON email (jmap, body_state, received_at DESC); CREATE TABLE email_mailbox ( @@ -33,7 +33,7 @@ export const MAIL_MIGRATIONS: readonly Migration[] = [ received_at INTEGER NOT NULL, PRIMARY KEY (email_rowid, mailbox_id) ) WITHOUT ROWID; - CREATE INDEX email_mailbox_list ON email_mailbox (jmap, mailbox_id, received_at DESC); + CREATE INDEX email_mailbox_list ON email_mailbox (jmap, mailbox_id, received_at DESC, email_rowid DESC); CREATE TABLE email_keyword ( email_rowid INTEGER NOT NULL REFERENCES email (rowid) ON DELETE CASCADE, keyword TEXT NOT NULL, diff --git a/lib/store/mail/sync.ts b/lib/store/mail/sync.ts index 21ad9bb0d..30b8c7b7b 100644 --- a/lib/store/mail/sync.ts +++ b/lib/store/mail/sync.ts @@ -26,6 +26,8 @@ const PAGE = 500; const PAIRS_PER_REQUEST = 4; const BODY_BATCH_IDS = 100; const BODY_BATCH_BYTES = 8 * 1024 * 1024; +/** Bodies fetched before one commit. Every commit writes search index segments, so fewer is much cheaper. */ +const BODY_REQUESTS_PER_COMMIT = 5; const MAX_BODY_VALUE_BYTES = 1_000_000; const DELTA_BATCH = 500; @@ -83,11 +85,15 @@ async function syncMailboxes(ctx: SyncContext, jmap: string): Promise { interface ListCursor { position: number; gen: number; captured: string } +/** The generation of the last full listing. One row is enough: every row of a pass has it or an older one. */ +function currentGen(ctx: SyncContext, jmap: string): number { + return Number(ctx.db.get<{ gen: number }>('SELECT gen FROM email WHERE jmap = ? ORDER BY gen DESC LIMIT 1', [jmap])?.gen ?? 0); +} + async function startFullList(ctx: SyncContext, jmap: string): Promise { const response = await ctx.transport.request([['Email/get', { accountId: jmap, ids: [], properties: ['id'] }, 's']], MAIL_USING); const captured = String(responseFor(response, 's')[1].state); - const gen = (ctx.db.get<{ gen: number | null }>('SELECT MAX(gen) AS gen FROM email WHERE jmap = ?', [jmap])?.gen ?? 0) + 1; - const cursor: ListCursor = { position: 0, gen: Number(gen), captured }; + const cursor: ListCursor = { position: 0, gen: currentGen(ctx, jmap) + 1, captured }; ctx.db.transaction(() => ctx.setSyncState(jmap, 'Email', { state: null, phase: 'list', cursor: { ...cursor } })); return cursor; } @@ -171,11 +177,11 @@ async function syncEmailDelta(ctx: SyncContext, jmap: string, since: string, tar const created = new Set(page.created); const newIds: string[] = []; ctx.db.transaction(() => { + const gen = fetched.length > 0 ? currentGen(ctx, jmap) : 0; for (const email of fetched) { const known = ctx.db.get('SELECT 1 AS x FROM email WHERE jmap = ? AND id = ?', [jmap, email.id]); // An old message changing doesn't pull it into the window. if (!known && !inWindow(ctx, email)) continue; - const gen = Number(ctx.db.get<{ gen: number | null }>('SELECT MAX(gen) AS gen FROM email WHERE jmap = ?', [jmap])?.gen ?? 0); upsertEmail(ctx.db, jmap, email, gen); if (!known && created.has(email.id)) newIds.push(email.id); } @@ -254,39 +260,48 @@ async function backfillBodies(ctx: SyncContext, jmap: string): Promise { let done = 0; const totalRow = ctx.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM email WHERE jmap = ? AND body_state = 0', [jmap]); const total = Number(totalRow?.n ?? 0); + if (total === 0) return; for (;;) { if (ctx.stopped()) return; const candidates = ctx.db.all<{ rowid: number; id: string; size: number; json: string }>( 'SELECT rowid, id, size, json FROM email WHERE jmap = ? AND body_state = 0 ORDER BY received_at DESC LIMIT ?', - [jmap, BODY_BATCH_IDS], + [jmap, BODY_BATCH_IDS * BODY_REQUESTS_PER_COMMIT], ); if (candidates.length === 0) return; - let bytes = 0; - const batch: typeof candidates = []; - for (const row of candidates) { - if (batch.length > 0 && bytes + Number(row.size) > BODY_BATCH_BYTES) break; - bytes += Number(row.size); - batch.push(row); + const fetched = new Map(); + const asked: typeof candidates = []; + let next = 0; + for (let request = 0; request < BODY_REQUESTS_PER_COMMIT && next < candidates.length; request += 1) { + let bytes = 0; + const batch: typeof candidates = []; + while (next < candidates.length && batch.length < BODY_BATCH_IDS) { + const row = candidates[next]; + if (batch.length > 0 && bytes + Number(row.size) > BODY_BATCH_BYTES) break; + bytes += Number(row.size); + batch.push(row); + next += 1; + } + const response = await ctx.transport.request([['Email/get', { + accountId: jmap, + ids: batch.map((row) => String(row.id)), + properties: ['id', ...EMAIL_BODY_PROPERTIES], + fetchTextBodyValues: true, + fetchHTMLBodyValues: true, + fetchAllBodyValues: true, + maxBodyValueBytes: MAX_BODY_VALUE_BYTES, + }, 'b']], MAIL_USING); + for (const email of responseFor(response, 'b')[1].list as EmailObject[]) fetched.set(email.id, email); + asked.push(...batch); + if (ctx.stopped()) break; } - const response = await ctx.transport.request([['Email/get', { - accountId: jmap, - ids: batch.map((row) => String(row.id)), - properties: ['id', ...EMAIL_BODY_PROPERTIES], - fetchTextBodyValues: true, - fetchHTMLBodyValues: true, - fetchAllBodyValues: true, - maxBodyValueBytes: MAX_BODY_VALUE_BYTES, - }, 'b']], MAIL_USING); - const list = responseFor(response, 'b')[1].list as EmailObject[]; - const byId = new Map(list.map((email) => [email.id, email])); ctx.db.transaction(() => { - for (const row of batch) { - const full = byId.get(String(row.id)); + for (const row of asked) { + const full = fetched.get(String(row.id)); if (full) storeBody(ctx.db, Number(row.rowid), String(row.json), full); else ctx.db.run('UPDATE email SET body_state = 2 WHERE rowid = ?', [row.rowid]); } }); - done += batch.length; + done += asked.length; ctx.emit({ type: 'progress', account: ctx.account, area: 'mail', phase: 'bodies', done, total }); } } diff --git a/lib/store/storage.ts b/lib/store/storage.ts index e8cde1216..5ca4c29af 100644 --- a/lib/store/storage.ts +++ b/lib/store/storage.ts @@ -39,7 +39,13 @@ export function runTransaction( exec('COMMIT'); return result; } catch (error) { - exec('ROLLBACK'); + // SQLite has already rolled back after some errors (a full disk, a failed + // write). A second failure here must not hide the real one. + try { + exec('ROLLBACK'); + } catch { + // Nothing left to roll back. + } throw error; } finally { state.depth -= 1; From 41987cb5cfcdac68770e0596672e284678186935 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 06:16:44 +0100 Subject: [PATCH 12/16] fix: stop hostile HTML stalling mail sync and index each message only once --- lib/store/__tests__/search.test.ts | 171 ++++++++++++++++++++++++++++- lib/store/mail/records.ts | 114 +++++++++++++++---- lib/store/mail/search-schema.ts | 59 +++++++--- lib/store/mail/search.ts | 29 +++-- 4 files changed, 321 insertions(+), 52 deletions(-) diff --git a/lib/store/__tests__/search.test.ts b/lib/store/__tests__/search.test.ts index 478c277f4..711d48539 100644 --- a/lib/store/__tests__/search.test.ts +++ b/lib/store/__tests__/search.test.ts @@ -5,6 +5,7 @@ import { parseSearchSnippet, snippetFilterFor } from '@/lib/search-snippet'; import { DEFAULT_SCOPE } from '../area'; import { AccountEngine } from '../engine/account-engine'; import { mailArea } from '../mail'; +import { bodyText, deleteEmail } from '../mail/records'; import { FAKE_SESSION, FakeJmap } from './helpers/fake-jmap'; import { openMemoryStore } from './helpers/node-adapter'; @@ -14,12 +15,12 @@ function withBody(value: string): Email { return { bodyValues: { 1: { value, isTruncated: false, isEncodingProblem: false } } }; } -async function storeWith(emails: Email[]) { +async function storeWith(emails: Email[], db = openMemoryStore()) { const server = new FakeJmap(); for (const email of emails) server.addEmail(email); const engine = new AccountEngine({ account: 'a@fake.test', - db: openMemoryStore(), + db, session: FAKE_SESSION, transport: server, areas: [mailArea], @@ -45,7 +46,19 @@ async function storeWith(emails: Email[]) { notFound: string[]; }; }; - return { engine, query, snippets }; + return { engine, query, snippets, db }; +} + +/** A store that records the statements it runs. */ +function recordingStore() { + const db = openMemoryStore(); + const statements: string[] = []; + const run = db.run.bind(db); + db.run = (sql, params) => { + statements.push(sql); + return run(sql, params); + }; + return { db, statements }; } const alice = [{ name: 'Alice Adler', email: 'alice@example.org' }]; @@ -197,6 +210,140 @@ describe('matching', () => { }); }); +describe('the split indexes', () => { + it('writes each index once for a message, with no delete when its body arrives', async () => { + const { db, statements } = recordingStore(); + await storeWith([{ id: 'one', subject: 'Hallo', ...withBody('Inhalt') }], db); + const fts = statements.filter((sql) => sql.includes('email_fts')); + expect(fts.filter((sql) => sql.startsWith('DELETE'))).toEqual([]); + for (const table of ['email_fts_word', 'email_fts_tri', 'email_fts_body_word', 'email_fts_body_tri']) { + expect(fts.filter((sql) => sql.startsWith(`INSERT INTO ${table} `)).length, table).toBe(1); + } + }); + + it('touches the head row again only for a message with Bcc', async () => { + const { db, statements } = recordingStore(); + const { query } = await storeWith([{ id: 'one', subject: 'Hallo', bcc: dave }], db); + const fts = statements.filter((sql) => sql.includes('email_fts')); + expect(fts.filter((sql) => sql.startsWith('DELETE FROM email_fts_word')).length).toBe(1); + expect(fts.filter((sql) => sql.startsWith('DELETE FROM email_fts_body'))).toEqual([]); + // Nothing of the head was lost in writing it again. + expect(await query({ subject: 'hallo' })).toEqual(['one']); + expect(await query({ bcc: 'dave' })).toEqual(['one']); + }); + + it('finds head fields only in the head and body text only in the body', async () => { + const { query } = await storeWith([ + { id: 'in-subject', subject: 'Stromrechnung', ...withBody('nichts') }, + { id: 'in-body', subject: 'Hallo', ...withBody('Die Stromrechnung') }, + { id: 'from-word', subject: 'Hallo', from: [{ name: 'Stromrechnung', email: 'x@example.org' }], ...withBody('nichts') }, + ]); + expect(await query({ text: 'rechnung' })).toEqual(['in-subject', 'in-body', 'from-word']); + expect(await query({ text: 'stromrechnung' })).toEqual(['in-subject', 'in-body', 'from-word']); + expect(await query({ subject: 'rechnung' })).toEqual(['in-subject']); + expect(await query({ body: 'rechnung' })).toEqual(['in-body']); + expect(await query({ from: 'rechnung' })).toEqual(['from-word']); + }); + + it('keeps each word of a search free to match in the head or the body', async () => { + const { query } = await storeWith([{ id: 'one', subject: 'Rechnung', ...withBody('Oktober') }, { id: 'two', subject: 'Rechnung' }]); + expect(await query({ text: 'rechnung oktober' })).toEqual(['one']); + }); + + it('caps the trigrams of a body at 32 KB and leaves its words to 200 KB', async () => { + const filler = 'fuellwort '.repeat(4000); + const { query } = await storeWith([ + { id: 'early', subject: 'x', ...withBody('Stromrechnung am Anfang') }, + { id: 'late', subject: 'x', ...withBody(`${filler} Gasrechnung am Ende`) }, + { id: 'past', subject: 'x', ...withBody(`${filler.repeat(6)} Wasserrechnung`) }, + ]); + expect(await query({ body: 'rechnung' })).toEqual(['early']); + // Past the trigram cap the whole word and its start are still found. + expect(await query({ body: 'gasrechnung' })).toEqual(['late']); + expect(await query({ body: 'gasrech*' })).toEqual(['late']); + // Past the word cap too: not indexed at all. + expect(await query({ body: 'wasserrechnung' })).toEqual([]); + }); + + it('removes a message from all four indexes', async () => { + const { db, query } = await storeWith([{ id: 'one', subject: 'Hallo', ...withBody('Inhalt') }]); + const jmap = String(db.get<{ jmap: string }>('SELECT jmap FROM email')?.jmap); + deleteEmail(db, jmap, 'one'); + for (const table of ['email_fts_word', 'email_fts_tri']) expect(db.all(`SELECT rowid FROM ${table} WHERE ${table} MATCH ?`, ['"hallo"'])).toEqual([]); + for (const table of ['email_fts_body_word', 'email_fts_body_tri']) expect(db.all(`SELECT rowid FROM ${table} WHERE ${table} MATCH ?`, ['"inhalt"'])).toEqual([]); + expect(await query({ text: 'inhalt' })).toEqual([]); + }); + + it('sets the options for bulk writes', async () => { + const { db } = await storeWith([]); + const option = (table: string, key: string) => Number(db.get<{ v: number }>(`SELECT v FROM ${table}_config WHERE k = ?`, [key])?.v); + expect(option('email_fts_body_word', 'hashsize')).toBe(8 * 1024 * 1024); + expect(option('email_fts_body_tri', 'hashsize')).toBe(16 * 1024 * 1024); + for (const table of ['email_fts_word', 'email_fts_tri', 'email_fts_body_word', 'email_fts_body_tri']) { + expect(option(table, 'automerge'), table).toBe(8); + } + }); +}); + +describe('bodyText', () => { + const html = (value: string) => ({ + textBody: [], + htmlBody: [{ partId: '1', type: 'text/html' }], + bodyValues: { 1: { value } }, + }); + const MEGABYTE = 1_000_000; + const HOSTILE: Record = { + 'opening brackets': '<'.repeat(MEGABYTE), + 'comment openers': 'b
kept

text

', + )).trim()).toBe('kept text'); + }); + + it('leaves the text of a block that never closes', () => { + expect(bodyText(html('before