From 0cf561281351ebca4139545a9a0ce42a144704e8 Mon Sep 17 00:00:00 2001 From: Alex Moreton Date: Mon, 6 Jul 2026 10:50:02 +0100 Subject: [PATCH 1/5] UserInbox.handleBackfill with KV skip-cache + Chat.flush KV write + infra/binding --- infra/stacks/chat-ws.ts | 43 +++++-- packages/chat-db/package.json | 6 +- packages/chat-db/src/backfill-cursors.test.ts | 93 ++++++++++++++ packages/chat-db/src/backfill-cursors.ts | 52 ++++++++ packages/chat-db/src/index.ts | 2 + packages/chat-db/src/migrations.ts | 19 +++ packages/chat-db/src/schema.ts | 8 ++ packages/chat-db/vitest.config.ts | 12 ++ packages/chat-transport/src/envelope.ts | 36 ++++++ packages/db-edge/package.json | 6 +- packages/db-edge/src/chat-persist.test.ts | 88 ++++++++++++++ packages/db-edge/src/chat-persist.ts | 82 ++++++++++++- packages/db-edge/src/index.ts | 1 + packages/db-edge/vitest.config.ts | 12 ++ pnpm-lock.yaml | 62 +++++----- services/chat-ws/src/durable/chat.ts | 13 ++ services/chat-ws/src/durable/user-inbox.ts | 115 +++++++++++++++++- services/chat-ws/src/validators.ts | 49 ++++++++ services/chat-ws/worker-configuration.d.ts | 10 ++ services/chat-ws/wrangler.jsonc | 16 ++- 20 files changed, 674 insertions(+), 51 deletions(-) create mode 100644 packages/chat-db/src/backfill-cursors.test.ts create mode 100644 packages/chat-db/src/backfill-cursors.ts create mode 100644 packages/chat-db/vitest.config.ts create mode 100644 packages/db-edge/src/chat-persist.test.ts create mode 100644 packages/db-edge/vitest.config.ts diff --git a/infra/stacks/chat-ws.ts b/infra/stacks/chat-ws.ts index fbc3939..650139d 100644 --- a/infra/stacks/chat-ws.ts +++ b/infra/stacks/chat-ws.ts @@ -39,6 +39,15 @@ import { databaseUrl } from "./secrets"; // separate steps; B1.1 provisions the namespace + binding only. export const sessionCache = new sst.cloudflare.Kv("SessionCache"); +// ── Backfill skip-cache (docs/message-backfill.md) ─────────────────────────── +// Second KV namespace holding per-chat max served serverSerial (`chatmax:{id}`). +// Chat.flush writes it; UserInbox.handleBackfill reads it to skip a Neon +// backfill when the client's cursor already covers the tip. Skip-only cache — +// a stale/missing entry only delays catch-up, never loses a message. Bound raw +// as `env.CHAT_MAX_CACHE` via transform.worker.bindings, same shape as +// SessionCache (B1.1). Binding-only change → empty migration + tag bump. +export const chatMaxCache = new sst.cloudflare.Kv("ChatMaxCache"); + export const chatWsWorker = new sst.cloudflare.Worker("ChatWs", { handler: "services/chat-ws/src/index.ts", url: true, @@ -71,6 +80,10 @@ export const chatWsWorker = new sst.cloudflare.Worker("ChatWs", { // to tally cache hit rate + KV write rate. Keep "0" in prod; flip to "1" // (+ redeploy) only for a load-test run, then flip back. SESSION_CACHE_METRICS: "1", + // Backfill metrics (docs/message-backfill.md). "1" → per-bf "CMC" log for + // `wrangler tail` to tally KV-skip vs Neon-read rate. Keep "0" in prod; flip + // to "1" (+ redeploy) only for the warm-reconnect acceptance run. + CHAT_MAX_CACHE_METRICS: "0", }, transform: { worker: (args) => { @@ -82,7 +95,8 @@ export const chatWsWorker = new sst.cloudflare.Worker("ChatWs", { args.bindings = $resolve({ bindings: args.bindings, sessionCacheId: sessionCache.id, - }).apply(({ bindings, sessionCacheId }) => [ + chatMaxCacheId: chatMaxCache.id, + }).apply(({ bindings, sessionCacheId, chatMaxCacheId }) => [ ...(bindings ?? []), { type: "durable_object_namespace", @@ -108,6 +122,14 @@ export const chatWsWorker = new sst.cloudflare.Worker("ChatWs", { // bypassed through Output.apply). Do not "fix" this back to snake_case. namespaceId: sessionCacheId, }, + { + // Backfill skip-cache (docs/message-backfill.md) → env.CHAT_MAX_CACHE. + // Same raw KV binding shape as SESSION_CACHE — camelCase `namespaceId` + // (see the CF 10021 warning above; do NOT snake_case it). + type: "kv_namespace", + name: "CHAT_MAX_CACHE", + namespaceId: chatMaxCacheId, + }, ]); // Migration shape — Pulumi cloudflare provider takes a single object @@ -135,15 +157,18 @@ export const chatWsWorker = new sst.cloudflare.Worker("ChatWs", { // (error 10079 "got tag X when expected Y" = oldTag ≠ deployed). Do NOT // re-declare Chat/UserInbox in newSqliteClasses (triggers 10074). // - // ⚠ PRECONDITION — deployed tag. This assumes the prior code-only v1→v2 - // deploy (commit 265a35c) actually shipped, i.e. deployed tag == v2. - // VERIFY with `sst diff` before apply. If the v1→v2 deploy never ran - // (e.g. alice/bob was tested on local `wrangler dev`), the deployed tag - // is still v1 — in that case set { oldTag: "v1", newTag: "v2" } here and - // let the KV binding ride that deploy, rather than jumping to v3. + // UPDATE-SHAPE (backfill, docs/message-backfill.md): this deploy adds the + // CHAT_MAX_CACHE KV binding — again a binding-only change, empty migration, + // tag bump only. Tags below assume B1.1's v7→v8 already deployed, so we go + // v8→v9. ⚠ If B1.1 has NOT shipped (deployed tag is still v7), do NOT jump + // to v9 — let CHAT_MAX_CACHE ride B1.1's deploy instead: set + // { oldTag: "v7", newTag: "v8" } and ship both KV bindings together. + // + // ⚠ PRECONDITION — deployed tag. VERIFY with `sst diff` before apply; the + // oldTag MUST equal the currently deployed tag (error 10079 otherwise). args.migrations = { - oldTag: "v7", - newTag: "v8", + oldTag: "v8", + newTag: "v9", }; // Compatibility date with WebSocket auto-reply-to-close behaviour diff --git a/packages/chat-db/package.json b/packages/chat-db/package.json index e252e1b..08d40fb 100644 --- a/packages/chat-db/package.json +++ b/packages/chat-db/package.json @@ -8,7 +8,8 @@ "scripts": { "lint": "eslint .", "check-types": "tsc --noEmit", - "test": "echo \"no tests yet\" && exit 0" + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@op-engineering/op-sqlite": "^14.1.0", @@ -20,7 +21,8 @@ "@repo/typescript-config": "workspace:*", "@types/react": "~19.1.10", "eslint": "^9.39.1", - "typescript": "5.9.2" + "typescript": "5.9.2", + "vitest": "^2.1.9" }, "peerDependencies": { "expo": "*", diff --git a/packages/chat-db/src/backfill-cursors.test.ts b/packages/chat-db/src/backfill-cursors.test.ts new file mode 100644 index 0000000..5551903 --- /dev/null +++ b/packages/chat-db/src/backfill-cursors.test.ts @@ -0,0 +1,93 @@ +import { createRequire } from "node:module"; +import { beforeEach, describe, expect, it } from "vitest"; +import type { DB } from "@op-engineering/op-sqlite"; + +import { getAllCursors, getCursor, setCursor } from "./backfill-cursors"; +import { runMigrations } from "./migrations"; + +// node:sqlite is a Node 22 builtin that Vite's bundled builtins list doesn't +// know — a static import makes Vite strip `node:` and try to bundle `sqlite`. +// Load it through a runtime createRequire (seeded from the CJS __filename, not +// import.meta which the CommonJS tsconfig forbids) so Vite never sees it. +interface Stmt { + all(...params: unknown[]): unknown[]; + run(...params: unknown[]): unknown; +} +const nodeRequire = createRequire(__filename); +const { DatabaseSync } = nodeRequire("node:sqlite") as { + DatabaseSync: new (path: string) => { prepare(sql: string): Stmt }; +}; + +// Thin adapter: wraps node:sqlite to the op-sqlite `DB.execute(sql, params?)` +// → { rows } shape the store helpers + migration runner expect. Lets us exercise +// the REAL SQL — schema migration and the monotonic CAST guard — against a real +// SQLite engine, no logic duplication. Test-only. +function isQuery(sql: string): boolean { + const s = sql.trim().toUpperCase(); + return s.startsWith("SELECT") || (s.startsWith("PRAGMA") && !s.includes("=")); +} + +function makeDb(): DB { + const sqlite = new DatabaseSync(":memory:"); + const execute = (sql: string, params: unknown[] = []) => { + const stmt = sqlite.prepare(sql); + if (isQuery(sql)) { + return Promise.resolve({ rows: stmt.all(...(params as never[])) }); + } + stmt.run(...(params as never[])); + return Promise.resolve({ rows: [] }); + }; + return { execute } as unknown as DB; +} + +describe("backfill_cursors", () => { + let db: DB; + + beforeEach(async () => { + db = makeDb(); + await runMigrations(db); + }); + + it("migration 5 creates the table (schema at LATEST)", async () => { + const res = await db.execute( + `SELECT name FROM sqlite_master WHERE type='table' AND name='backfill_cursors'`, + ); + expect((res.rows ?? []).length).toBe(1); + }); + + it("getCursor returns null for an un-backfilled chat", async () => { + expect(await getCursor(db, "chat-1")).toBeNull(); + }); + + it("setCursor inserts then getCursor reads it back", async () => { + await setCursor(db, "chat-1", "42"); + expect(await getCursor(db, "chat-1")).toBe("42"); + }); + + it("advances monotonically and never regresses", async () => { + await setCursor(db, "chat-1", "42"); + await setCursor(db, "chat-1", "100"); // forward + expect(await getCursor(db, "chat-1")).toBe("100"); + + await setCursor(db, "chat-1", "99"); // stale/out-of-order — must be ignored + expect(await getCursor(db, "chat-1")).toBe("100"); + }); + + it("compares numerically, not lexically (9 < 10)", async () => { + await setCursor(db, "chat-1", "9"); + await setCursor(db, "chat-1", "10"); // lexically "10" < "9"; numerically > + expect(await getCursor(db, "chat-1")).toBe("10"); + }); + + it("handles serials beyond 2^53 as strings", async () => { + const big = "9007199254740993"; // 2^53 + 1 + await setCursor(db, "chat-1", big); + expect(await getCursor(db, "chat-1")).toBe(big); + }); + + it("getAllCursors returns every chat's cursor as a map", async () => { + await setCursor(db, "chat-1", "5"); + await setCursor(db, "chat-2", "12"); + expect(await getAllCursors(db)).toEqual({ "chat-1": "5", "chat-2": "12" }); + }); +}); diff --git a/packages/chat-db/src/backfill-cursors.ts b/packages/chat-db/src/backfill-cursors.ts new file mode 100644 index 0000000..7f2a9b7 --- /dev/null +++ b/packages/chat-db/src/backfill-cursors.ts @@ -0,0 +1,52 @@ +import type { DB } from "@op-engineering/op-sqlite"; +import type { BackfillCursorRow } from "./schema"; + +// Offline backfill cursors (docs/message-backfill.md). Per-chat high-water mark +// of the greatest serverSerial pulled via `bf`. The provider reads these to +// build the batched backfill request and advances them on each `bfd` page. + +// Greatest serverSerial this device has backfilled for `chatId`, or null when +// the chat has never been backfilled (the caller treats null as "0" = full +// history). +export async function getCursor( + db: DB, + chatId: string, +): Promise { + const result = await db.execute( + `SELECT last_serial FROM backfill_cursors WHERE chat_id = ? LIMIT 1`, + [chatId], + ); + const rows = (result.rows ?? []) as unknown as BackfillCursorRow[]; + return rows[0]?.last_serial ?? null; +} + +// All known cursors as { chatId: lastSerial }. One round-trip for the batched +// `bf` frame; chats absent from the map default to "0" at the call site. +export async function getAllCursors(db: DB): Promise> { + const result = await db.execute( + `SELECT chat_id, last_serial FROM backfill_cursors`, + ); + const rows = (result.rows ?? []) as unknown as BackfillCursorRow[]; + const out: Record = {}; + for (const r of rows) out[r.chat_id] = r.last_serial; + return out; +} + +// Advance a chat's cursor to `upTo` (a `bfd.upTo`), monotonically. The guard +// lives in SQL so it is atomic against interleaved backfill passes: the +// conflict-update only fires when the new serial is strictly greater, compared +// as INTEGER (int64) — never lexically, so "9" < "10" holds. An out-of-order or +// stale `bfd` (reconnect replay) can never regress the cursor. +export async function setCursor( + db: DB, + chatId: string, + upTo: string, +): Promise { + await db.execute( + `INSERT INTO backfill_cursors (chat_id, last_serial) VALUES (?, ?) + ON CONFLICT(chat_id) DO UPDATE SET last_serial = excluded.last_serial + WHERE CAST(excluded.last_serial AS INTEGER) + > CAST(backfill_cursors.last_serial AS INTEGER)`, + [chatId, upTo], + ); +} diff --git a/packages/chat-db/src/index.ts b/packages/chat-db/src/index.ts index 7a86d14..455dd8f 100644 --- a/packages/chat-db/src/index.ts +++ b/packages/chat-db/src/index.ts @@ -2,12 +2,14 @@ export { getChatDb, closeChatDb, type ChatDbHandle } from "./client"; export { LATEST_VERSION as SCHEMA_VERSION } from "./migrations"; export * as outbox from "./outbox"; export * as peerKeys from "./peer-keys"; +export * as backfillCursors from "./backfill-cursors"; export type { PeerDeviceInput } from "./peer-keys"; export type { ChatRow, MessageRow, MemberRow, SyncCursorRow, + BackfillCursorRow, PendingOutboxRow, KeyMaterialRow, PeerDeviceRow, diff --git a/packages/chat-db/src/migrations.ts b/packages/chat-db/src/migrations.ts index e2afb45..a0ccceb 100644 --- a/packages/chat-db/src/migrations.ts +++ b/packages/chat-db/src/migrations.ts @@ -137,6 +137,25 @@ const MIGRATIONS: Migration[] = [ `CREATE INDEX idx_messages_client_msg_id ON messages (client_msg_id)`, ], }, + { + version: 5, + up: [ + // Offline backfill cursor (docs/message-backfill.md). Per-chat high-water + // mark of the greatest serverSerial this device has pulled via `bf`. Sent + // back as the `after` cursor on the next backfill; advanced to `bfd.upTo` + // with a monotonic guard (setCursor never regresses it). + // + // NB: distinct from the message-id-based `sync_cursor` (migration 1). This + // one is serial-keyed (the DO's monotonic authority, ADR-012), directly + // comparable to a message's serverSerial. last_serial is TEXT because a + // serverSerial is a BigInt that can exceed JS 2^53; numeric comparisons + // CAST it to INTEGER (int64), never lexical (so "9" < "10" holds). + `CREATE TABLE backfill_cursors ( + chat_id TEXT PRIMARY KEY NOT NULL, + last_serial TEXT NOT NULL + ) WITHOUT ROWID`, + ], + }, ]; const LAST = MIGRATIONS[MIGRATIONS.length - 1]; diff --git a/packages/chat-db/src/schema.ts b/packages/chat-db/src/schema.ts index 5bd093f..e46bc65 100644 --- a/packages/chat-db/src/schema.ts +++ b/packages/chat-db/src/schema.ts @@ -43,6 +43,14 @@ export interface SyncCursorRow { last_synced_at: number; } +// Offline backfill cursor (docs/message-backfill.md). One row per chat this +// device has backfilled. last_serial = greatest serverSerial pulled so far, +// TEXT because a serverSerial (BigInt) can exceed JS 2^53. +export interface BackfillCursorRow { + chat_id: string; + last_serial: string; +} + export interface PendingOutboxRow { id: string; chat_id: string; diff --git a/packages/chat-db/vitest.config.ts b/packages/chat-db/vitest.config.ts new file mode 100644 index 0000000..4ebd562 --- /dev/null +++ b/packages/chat-db/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + // Pure-SQL units: the backfill_cursors migration + store helpers, run + // against an in-memory node:sqlite that adapts to the op-sqlite DB shape + // (see backfill-cursors.test.ts). Native op-sqlite / SQLCipher paths are out + // of scope here — this exercises the SQL, not the RN binding. + include: ["src/**/*.test.ts"], + environment: "node", + }, +}); diff --git a/packages/chat-transport/src/envelope.ts b/packages/chat-transport/src/envelope.ts index 5713e7d..0858e4f 100644 --- a/packages/chat-transport/src/envelope.ts +++ b/packages/chat-transport/src/envelope.ts @@ -37,6 +37,19 @@ export type ClientToServer = // to ChatMember.lastReadSerial and fans out. Gated client-side by the // symmetric read-receipts privacy toggle (never emitted when off). | { t: "read"; chatId: string; upto: string } + // Backfill request — catch up on messages missed while offline. One frame + // carries every subscribed chat's cursor so a reconnect is a single WS frame + // → N in-DO KV reads, 0 Chat DO wakeups (docs/message-backfill.md). + // + // `after` = the client's per-chat cursor (greatest serverSerial already held; + // "0" = full history). Server returns rows with serverSerial > after. + // `force: true` = ignore the KV skip-cache and hit Neon. The client sets it + // the first time it backfills a given chat each app launch (fresh-login + // correctness); warm same-session reconnects omit it and take the KV skip. + | { + t: "bf"; + chats: Array<{ chatId: string; after: string; force?: boolean }>; + } // Heartbeat — answered with `pong`. Auto-handled when supported by the // platform; manual fallback for clients without auto-ping. | { t: "ping" }; @@ -76,6 +89,29 @@ export type ServerToClient = // `chatId`. Receiver advances that member's watermark; the sender's own // outgoing bubbles flip sent → read once a peer's `upto` covers their serial. | { t: "read"; chatId: string; userId: string; upto: string } + // Backfill done — one page of missed messages for a single chat, in ascending + // serverSerial order. The server emits one `bfd` per chat in the originating + // `bf` batch (docs/message-backfill.md). Backfill stays distinct from the live + // `msg` path so the store can merge-sort a page in one pass. + // + // `upTo` advances the client cursor even when rows are undecryptable (v=1 + // sealed to other devices, own sends) → no refetch-loop wedge; it is the + // greatest serverSerial served, or the request's `after` when the page is + // empty. `more: true` → another page exists; the client re-requests with + // `after = upTo`. + | { + t: "bfd"; + chatId: string; + messages: Array<{ + serverMsgId: string; + senderId: string; + ciphertext: Uint8Array; + nonce: Uint8Array; + ts: number; + }>; + upTo: string; + more: boolean; + } // Soft error — connection stays open. Use error.code for routing. | { t: "err"; code: ChatErrorCode; msg?: string } | { t: "pong" }; diff --git a/packages/db-edge/package.json b/packages/db-edge/package.json index 006d445..2e7e59f 100644 --- a/packages/db-edge/package.json +++ b/packages/db-edge/package.json @@ -14,7 +14,8 @@ "scripts": { "lint": "eslint .", "check-types": "tsc --noEmit", - "test": "echo \"no tests yet\" && exit 0" + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@neondatabase/serverless": "0.10.4" @@ -23,6 +24,7 @@ "@repo/eslint-config": "workspace:*", "@repo/typescript-config": "workspace:*", "eslint": "^9.39.1", - "typescript": "5.9.2" + "typescript": "5.9.2", + "vitest": "^2.1.9" } } diff --git a/packages/db-edge/src/chat-persist.test.ts b/packages/db-edge/src/chat-persist.test.ts new file mode 100644 index 0000000..ff0722d --- /dev/null +++ b/packages/db-edge/src/chat-persist.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import type { NeonQueryFunction } from "@neondatabase/serverless"; + +import { ChatPersistClient } from "./chat-persist"; + +// Fake tagged-template executor: captures the SQL text + bound params of the +// last call and returns a canned result. Lets us assert the membership gate is +// in the query and that row mapping is correct — no Neon round-trip. +function fakeSql(rows: unknown[]) { + const calls: Array<{ text: string; params: unknown[] }> = []; + const fn = (strings: TemplateStringsArray, ...params: unknown[]) => { + calls.push({ text: strings.join("?"), params }); + return Promise.resolve(rows); + }; + return { fn: fn as unknown as NeonQueryFunction, calls }; +} + +describe("messagesSince", () => { + it("enforces membership inside the query and binds params in order", async () => { + const { fn, calls } = fakeSql([]); + const client = new ChatPersistClient(fn); + + await client.messagesSince("chat-1", "user-A", 41n, 201); + + const { text, params } = calls[0]!; + // The EXISTS ChatMember sub-select IS the authorization boundary. + expect(text).toContain("EXISTS"); + expect(text).toContain('"ChatMember"'); + expect(text).toContain('"serverSerial" > '); + expect(text).toContain('ORDER BY "serverSerial" ASC'); + expect(text).toContain("LIMIT"); + // chatId (range), after, chatId (EXISTS), userId, limit. + expect(params).toEqual(["chat-1", 41n, "chat-1", "user-A", 201]); + }); + + it("returns [] for a non-member (query yields no rows)", async () => { + const { fn } = fakeSql([]); + const client = new ChatPersistClient(fn); + + const out = await client.messagesSince("chat-1", "stranger", 0n, 200); + + expect(out).toEqual([]); + }); + + it("maps Uint8Array bytea + createdAt Date rows", async () => { + const ct = new Uint8Array([1, 2, 3]); + const nonce = new Uint8Array([9, 9]); + const when = new Date("2026-07-05T00:00:00.000Z"); + const { fn } = fakeSql([ + { + serverSerial: "42", + senderId: "user-B", + ciphertext: ct, + nonce, + createdAt: when, + }, + ]); + const client = new ChatPersistClient(fn); + + const [row] = await client.messagesSince("chat-1", "user-A", 0n, 200); + + expect(row!.serverSerial).toBe(42n); + expect(row!.senderId).toBe("user-B"); + expect(row!.ciphertext).toEqual(ct); + expect(row!.nonce).toEqual(nonce); + expect(row!.createdAt).toEqual(when); + }); + + it("normalises Postgres hex-string bytea and string timestamps", async () => { + const { fn } = fakeSql([ + { + serverSerial: 7, + senderId: "user-B", + ciphertext: "\\x010203", + nonce: "\\xff", + createdAt: "2026-07-05T12:00:00.000Z", + }, + ]); + const client = new ChatPersistClient(fn); + + const [row] = await client.messagesSince("chat-1", "user-A", 0n, 200); + + expect(row!.serverSerial).toBe(7n); + expect(row!.ciphertext).toEqual(new Uint8Array([1, 2, 3])); + expect(row!.nonce).toEqual(new Uint8Array([255])); + expect(row!.createdAt).toEqual(new Date("2026-07-05T12:00:00.000Z")); + }); +}); diff --git a/packages/db-edge/src/chat-persist.ts b/packages/db-edge/src/chat-persist.ts index 3ec3069..a8b9764 100644 --- a/packages/db-edge/src/chat-persist.ts +++ b/packages/db-edge/src/chat-persist.ts @@ -21,6 +21,35 @@ export interface PersistedMessageRow { createdAt: Date; } +// Row shape returned by messagesSince (offline backfill, docs/message-backfill.md). +// Raw persisted columns — the wire mapping to a `bfd` frame (serverMsgId, ts) +// happens in UserInbox, which owns the transport shape. +export interface BackfilledMessageRow { + serverSerial: bigint; + senderId: string; + ciphertext: Uint8Array; + nonce: Uint8Array; + createdAt: Date; +} + +// bytea round-trips as a Buffer / Uint8Array on some driver modes and as a +// Postgres `\x`-hex string on others. Normalise to Uint8Array so the transport +// layer never has to care which the Neon HTTP driver handed back. +function toUint8Array(value: unknown): Uint8Array { + if (value instanceof Uint8Array) return value; + if (value instanceof ArrayBuffer) return new Uint8Array(value); + if (Array.isArray(value)) return Uint8Array.from(value as number[]); + if (typeof value === "string") { + const hex = value.startsWith("\\x") ? value.slice(2) : value; + const out = new Uint8Array(hex.length / 2); + for (let i = 0; i < out.length; i++) { + out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + return out; + } + throw new Error(`bytea column not decodable to bytes: ${typeof value}`); +} + // Thin wrapper around @neondatabase/serverless. One instance per Worker // invocation (or cached on the DO if the DO is hot). The neon() factory is // cheap — no connection is opened until the first query, and queries are @@ -28,11 +57,17 @@ export interface PersistedMessageRow { export class ChatPersistClient { private readonly sql: NeonQueryFunction; - constructor(connectionString: string) { - if (!connectionString) { - throw new Error("ChatPersistClient: connectionString is required"); + // Accepts a connection string (production) or a pre-built query function + // (tests inject a fake tagged-template executor — no Neon round-trip). + constructor(connection: string | NeonQueryFunction) { + if (typeof connection === "string") { + if (!connection) { + throw new Error("ChatPersistClient: connectionString is required"); + } + this.sql = neon(connection); + } else { + this.sql = connection; } - this.sql = neon(connectionString); } // Insert a single message row. Uses ON CONFLICT on the PK so a counter @@ -67,6 +102,45 @@ export class ChatPersistClient { return BigInt(rows[0]!.max); } + // Offline backfill (docs/message-backfill.md): rows with serverSerial > after, + // ascending, capped at `limit`. Membership is enforced INSIDE the query — the + // EXISTS gate is the authorization boundary, so a non-member (or a stranger + // guessing a chatId) gets an empty result with zero metadata leak and no + // separate check / Chat DO wakeup. Neon-authoritative read; the caller passes + // limit+1 to detect whether another page exists. + async messagesSince( + chatId: string, + userId: string, + after: bigint, + limit: number, + ): Promise { + const rows = (await this.sql` + SELECT "serverSerial", "senderId", "ciphertext", "nonce", "createdAt" + FROM "ChatMessage" + WHERE "chatId" = ${chatId} AND "serverSerial" > ${after} + AND EXISTS ( + SELECT 1 FROM "ChatMember" + WHERE "chatId" = ${chatId} AND "userId" = ${userId} + ) + ORDER BY "serverSerial" ASC + LIMIT ${limit} + `) as Array<{ + serverSerial: string | number | bigint; + senderId: string; + ciphertext: unknown; + nonce: unknown; + createdAt: string | Date; + }>; + return rows.map((r) => ({ + serverSerial: BigInt(r.serverSerial), + senderId: r.senderId, + ciphertext: toUint8Array(r.ciphertext), + nonce: toUint8Array(r.nonce), + createdAt: + r.createdAt instanceof Date ? r.createdAt : new Date(r.createdAt), + })); + } + // All chat members. Caller filters out the sender(s) per message — one // round-trip per flush regardless of batch size. async memberIds(chatId: string): Promise { diff --git a/packages/db-edge/src/index.ts b/packages/db-edge/src/index.ts index 3623889..806c08c 100644 --- a/packages/db-edge/src/index.ts +++ b/packages/db-edge/src/index.ts @@ -14,4 +14,5 @@ export type { ChatPersistClient as ChatPersistClientType, PersistMessageInput, PersistedMessageRow, + BackfilledMessageRow, } from "./chat-persist"; diff --git a/packages/db-edge/vitest.config.ts b/packages/db-edge/vitest.config.ts new file mode 100644 index 0000000..2c64411 --- /dev/null +++ b/packages/db-edge/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + // Pure-logic units only: messagesSince query construction + bytea/serial + // row mapping, exercised through a fake tagged-template sql executor (no + // Neon round-trip). The membership EXISTS gate is asserted as a query + // property here; end-to-end enforcement is covered by the Maestro flow. + include: ["src/**/*.test.ts"], + environment: "node", + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 037e0e6..1392d5a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -393,6 +393,9 @@ importers: typescript: specifier: 5.9.2 version: 5.9.2 + vitest: + specifier: ^2.1.9 + version: 2.1.9(@types/node@22.19.18)(lightningcss@1.32.0)(terser@5.47.1) packages/chat-mls-core: dependencies: @@ -508,6 +511,9 @@ importers: typescript: specifier: 5.9.2 version: 5.9.2 + vitest: + specifier: ^2.1.9 + version: 2.1.9(@types/node@22.19.18)(lightningcss@1.32.0)(terser@5.47.1) packages/eslint-config: devDependencies: @@ -10215,7 +10221,7 @@ snapshots: '@bcoe/v8-coverage@0.2.3': optional: true - '@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0)': + '@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0)': dependencies: '@better-auth/utils': 0.4.0 '@better-fetch/fetch': 1.1.21 @@ -10229,39 +10235,39 @@ snapshots: optionalDependencies: '@cloudflare/workers-types': 4.20260509.1 - '@better-auth/drizzle-adapter@1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)': + '@better-auth/drizzle-adapter@1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)': dependencies: - '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/utils': 0.4.0 - '@better-auth/kysely-adapter@1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(kysely@0.28.17)': + '@better-auth/kysely-adapter@1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(kysely@0.28.17)': dependencies: - '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/utils': 0.4.0 optionalDependencies: kysely: 0.28.17 - '@better-auth/memory-adapter@1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)': + '@better-auth/memory-adapter@1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)': dependencies: - '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/utils': 0.4.0 - '@better-auth/mongo-adapter@1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)': + '@better-auth/mongo-adapter@1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)': dependencies: - '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/utils': 0.4.0 - '@better-auth/prisma-adapter@1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.2))(typescript@5.9.2))(prisma@6.19.3(typescript@5.9.2))': + '@better-auth/prisma-adapter@1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.2))(typescript@5.9.2))(prisma@6.19.3(typescript@5.9.2))': dependencies: - '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/utils': 0.4.0 optionalDependencies: '@prisma/client': 6.19.3(prisma@6.19.3(typescript@5.9.2))(typescript@5.9.2) prisma: 6.19.3(typescript@5.9.2) - '@better-auth/telemetry@1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)': + '@better-auth/telemetry@1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)': dependencies: - '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/utils': 0.4.0 '@better-fetch/fetch': 1.1.21 @@ -14747,13 +14753,13 @@ snapshots: better-auth@1.6.9(@cloudflare/workers-types@4.20260509.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.2))(typescript@5.9.2))(next@16.1.0(@babel/core@7.29.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(prisma@6.19.3(typescript@5.9.2))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@2.1.9(@types/node@22.19.18)(lightningcss@1.32.0)(terser@5.47.1)): dependencies: - '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) - '@better-auth/drizzle-adapter': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0) - '@better-auth/kysely-adapter': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(kysely@0.28.17) - '@better-auth/memory-adapter': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0) - '@better-auth/mongo-adapter': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0) - '@better-auth/prisma-adapter': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.2))(typescript@5.9.2))(prisma@6.19.3(typescript@5.9.2)) - '@better-auth/telemetry': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21) + '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/drizzle-adapter': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0) + '@better-auth/kysely-adapter': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(kysely@0.28.17) + '@better-auth/memory-adapter': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0) + '@better-auth/mongo-adapter': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0) + '@better-auth/prisma-adapter': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.2))(typescript@5.9.2))(prisma@6.19.3(typescript@5.9.2)) + '@better-auth/telemetry': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21) '@better-auth/utils': 0.4.0 '@better-fetch/fetch': 1.1.21 '@noble/ciphers': 2.2.0 @@ -15535,9 +15541,9 @@ snapshots: '@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.2))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.2) '@typescript-eslint/parser': 8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.2) eslint: 9.39.4(jiti@2.7.0) - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.2))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-expo: 1.0.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.2) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.2))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-react-hooks: 5.2.0(eslint@9.39.4(jiti@2.7.0)) globals: 16.5.0 @@ -15559,7 +15565,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.2))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -15570,18 +15576,18 @@ snapshots: tinyglobby: 0.2.16 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.2))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.2))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.2))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.2))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.2) eslint: 9.39.4(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.2))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)) transitivePeerDependencies: - supports-color @@ -15594,7 +15600,7 @@ snapshots: - supports-color - typescript - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.2))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -15605,7 +15611,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.4(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.2))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.2))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.2))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)) hasown: 2.0.3 is-core-module: 2.16.2 is-glob: 4.0.3 diff --git a/services/chat-ws/src/durable/chat.ts b/services/chat-ws/src/durable/chat.ts index 3c61bc5..86f09e2 100644 --- a/services/chat-ws/src/durable/chat.ts +++ b/services/chat-ws/src/durable/chat.ts @@ -207,6 +207,19 @@ export class Chat extends DurableObject { }); this.buffer = []; + // Backfill skip-cache (docs/message-backfill.md): publish this chat's max + // served serial so a warm reconnect can skip the Neon backfill read. + // Best-effort — mirrors the ADR-017 session-cache write. A dropped write is + // absorbed by the backfill invariant (full-range query rewritten by the next + // send), so it is non-fatal and deliberately not awaited. No TTL: a + // long-lived entry keeps warm skips valid; correctness never depends on it. + if (this.env.CHAT_MAX_CACHE) { + void this.env.CHAT_MAX_CACHE.put( + `chatmax:${chatId}`, + (this.nextSerial! - 1n).toString(), + ).catch(() => {}); + } + // ── Fanout (ADR-010, Track C batched) ────────────────────────────────── // Collapse N×M individual RPCs into one deliverBatch per recipient. const attached = await this.loadAttached(); diff --git a/services/chat-ws/src/durable/user-inbox.ts b/services/chat-ws/src/durable/user-inbox.ts index 70aecd7..fba1b83 100644 --- a/services/chat-ws/src/durable/user-inbox.ts +++ b/services/chat-ws/src/durable/user-inbox.ts @@ -7,7 +7,8 @@ import { type ChatErrorCode, } from "@repo/chat-transport"; -import { validateSendFrame } from "../validators"; +import { getPersistClient } from "../persist"; +import { validateBackfillFrame, validateSendFrame } from "../validators"; // Per-user inbox DO. Holds 1..N device WebSocket connections for a single user // and acts as the routing point between devices and Chat DOs the user is a @@ -36,6 +37,10 @@ interface SocketAttachment { const SUB_KEY = "subscriptions"; +// Backfill page size (docs/message-backfill.md). Server fetches PAGE_SIZE+1 to +// detect whether another page exists; drop to 50–100 if ciphertexts grow large. +const BACKFILL_PAGE_SIZE = 200; + type MsgFrame = Extract; type AckFrame = Extract; type MsgPayload = Omit; @@ -124,6 +129,9 @@ export class UserInbox extends DurableObject { case "read": await this.handleRead(att.userId, env); return; + case "bf": + await this.handleBackfill(ws, att.userId, env); + return; case "ping": ws.send(encodeFrame({ t: "pong" } satisfies ServerToClient)); return; @@ -219,6 +227,111 @@ export class UserInbox extends DurableObject { await stub.acceptRead({ userId, upto: env.upto }); } + // ── Offline backfill (docs/message-backfill.md) ─────────────────────────── + // One `bf` frame batches every subscribed chat's cursor. Per chat: try the KV + // skip-cache, else read Neon membership-gated (never a Chat DO wakeup). Replies + // with one `bfd` per chat, sent only to the requesting socket. Membership is + // enforced inside messagesSince — a non-member (or stranger guessing a chatId) + // gets an empty page, no metadata leak. + private async handleBackfill( + ws: WebSocket, + userId: string, + env: Extract, + ): Promise { + const verdict = validateBackfillFrame(env); + if (!verdict.ok) { + this.sendErr(ws, "BAD_FRAME", verdict.reason); + return; + } + + const db = getPersistClient(this.env); + const frames = await Promise.all( + env.chats.map((c) => this.backfillChat(db, userId, c)), + ); + for (const frame of frames) { + try { + ws.send(encodeFrame(frame)); + } catch { + // Socket died mid-backfill; close handler cleans up. Client re-requests + // on the next reconnect (cursor unchanged for undelivered pages). + } + } + } + + // Resolve a single chat's backfill page into a `bfd` frame. Pure of socket + // I/O so handleBackfill can compute the batch in parallel then fan the sends. + private async backfillChat( + db: ReturnType, + userId: string, + c: { chatId: string; after: string; force?: boolean }, + ): Promise> { + const after = BigInt(c.after); + + // KV skip: the client's cursor already covers the chat's max serial → no + // Neon, no rows. Safe by the core invariant — a stale-low/missing KV entry + // only delays catch-up (the next send rewrites chatmax and the full-range + // query returns the gap). A fresh login sets force:true to bypass it. + let skipped = false; + if (!c.force && this.env.CHAT_MAX_CACHE) { + try { + const kvMax = await this.env.CHAT_MAX_CACHE.get(`chatmax:${c.chatId}`); + if (kvMax !== null && BigInt(kvMax) <= after) skipped = true; + } catch { + // KV read failed — fall through to Neon (never skip on uncertainty). + } + } + if (skipped) { + this.logBackfill(c.chatId, true, 0); + return { + t: "bfd", + chatId: c.chatId, + messages: [], + upTo: c.after, + more: false, + }; + } + + // Membership-gated read. Fetch PAGE+1 to detect a further page. + const rows = await db.messagesSince( + c.chatId, + userId, + after, + BACKFILL_PAGE_SIZE + 1, + ); + const more = rows.length > BACKFILL_PAGE_SIZE; + const served = more ? rows.slice(0, BACKFILL_PAGE_SIZE) : rows; + // upTo advances the cursor even on an empty page (or undecryptable rows on + // the client) → no refetch-loop wedge. + const upTo = + served.length > 0 + ? served[served.length - 1]!.serverSerial.toString() + : c.after; + this.logBackfill(c.chatId, false, served.length); + + return { + t: "bfd", + chatId: c.chatId, + messages: served.map((r) => ({ + serverMsgId: r.serverSerial.toString(), + senderId: r.senderId, + ciphertext: r.ciphertext, + nonce: r.nonce, + ts: r.createdAt.getTime(), + })), + upTo, + more, + }; + } + + // Per-chat backfill tally for a wrangler-tail run (acceptance: warm reconnect + // = zero Neon reads). Gated so prod carries no per-bf log volume. + private logBackfill(chatId: string, skipped: boolean, rows: number): void { + if (this.env.CHAT_MAX_CACHE_METRICS !== "1") return; + console.log( + `CMC ${JSON.stringify({ chatId, skip: skipped, neon: !skipped, rows })}`, + ); + } + // ── Presence RPC (called by Chat DO before push fanout) ────────────────── // Returns the set of deviceIds with an open WS attached to this user. The // empty string is filtered out — pre-M6 clients connect without a `did` diff --git a/services/chat-ws/src/validators.ts b/services/chat-ws/src/validators.ts index 3571ca3..62de467 100644 --- a/services/chat-ws/src/validators.ts +++ b/services/chat-ws/src/validators.ts @@ -90,3 +90,52 @@ export function validateSendFrame(frame: SendShape): ValidateResult { reason: `unknown frame version byte: 0x${(version ?? 0).toString(16).padStart(2, "0")}`, }; } + +// --- Backfill request (`bf`) ------------------------------------------------ +// +// One `bf` frame batches every subscribed chat's cursor. Validate shape before +// UserInbox.handleBackfill touches KV / Neon: cursors bind straight into the +// `serverSerial > $2` bigint predicate in messagesSince, so a non-numeric +// `after` must be rejected here rather than blowing up the DB. + +/** Cap the batch so one frame can't fan into an unbounded KV/Neon sweep. */ +export const MAX_BACKFILL_CHATS = 500; +/** Cursors are decimal serverSerial strings ("0" = full history). */ +const SERIAL_CURSOR = /^\d+$/; + +export interface BackfillShape { + chats: Array<{ chatId: string; after: string; force?: boolean }>; +} + +export function validateBackfillFrame(frame: { + chats?: unknown; +}): ValidateResult { + if (!Array.isArray(frame.chats)) { + return { ok: false, reason: "chats must be an array" }; + } + if (frame.chats.length === 0) { + return { ok: false, reason: "chats empty" }; + } + if (frame.chats.length > MAX_BACKFILL_CHATS) { + return { + ok: false, + reason: `chats exceeds max ${MAX_BACKFILL_CHATS}, got ${frame.chats.length}`, + }; + } + for (const entry of frame.chats) { + if (typeof entry !== "object" || entry === null) { + return { ok: false, reason: "chat entry must be an object" }; + } + const c = entry as Record; + if (typeof c.chatId !== "string" || c.chatId.length === 0) { + return { ok: false, reason: "chatId must be a non-empty string" }; + } + if (typeof c.after !== "string" || !SERIAL_CURSOR.test(c.after)) { + return { ok: false, reason: "after must be a decimal cursor string" }; + } + if (c.force !== undefined && typeof c.force !== "boolean") { + return { ok: false, reason: "force must be a boolean when present" }; + } + } + return { ok: true }; +} diff --git a/services/chat-ws/worker-configuration.d.ts b/services/chat-ws/worker-configuration.d.ts index 064699a..716d2c5 100644 --- a/services/chat-ws/worker-configuration.d.ts +++ b/services/chat-ws/worker-configuration.d.ts @@ -31,6 +31,16 @@ declare global { // Load-test metrics (B1.7): "1" → emit one "SCM {...}" log per verify for // wrangler-tail tally. Off ("0") in prod to avoid per-connect log volume. SESSION_CACHE_METRICS: string; + // Backfill skip-cache (docs/message-backfill.md). Per-chat max served + // serverSerial, keyed `chatmax:{chatId}`. Chat.flush writes it; UserInbox + // reads it to skip a Neon backfill when the client's cursor already covers + // the tip. Skip-only cache — a stale/missing entry only delays catch-up, + // never loses a message (the backfill query fetches the full range). + CHAT_MAX_CACHE: KVNamespace; + // Backfill metrics: "1" → emit one "CMC {...}" log per bf chat for a + // wrangler-tail tally of KV-skip vs Neon-read rate (acceptance criterion: + // warm reconnect = zero Neon reads). Off ("0") in prod. + CHAT_MAX_CACHE_METRICS: string; // Note: AWS creds for SigV4 publish come via `Resource.X.value` from // the SST `sst` import (linked CF Worker secrets pattern). They are NOT // exposed as env bindings; do not add them here. diff --git a/services/chat-ws/wrangler.jsonc b/services/chat-ws/wrangler.jsonc index 9f341e0..8f325df 100644 --- a/services/chat-ws/wrangler.jsonc +++ b/services/chat-ws/wrangler.jsonc @@ -16,10 +16,14 @@ { "name": "USER_INBOX", "class_name": "UserInbox" } ] }, - // Edge session cache (ADR-0017). Prod namespace_id is minted by SST - // (`sst.cloudflare.Kv` in infra/stacks/chat-ws.ts); this local id is a - // placeholder — `wrangler dev` simulates KV in-process and ignores it. - "kv_namespaces": [{ "binding": "SESSION_CACHE", "id": "session_cache_local" }], + // Edge session cache (ADR-0017) + backfill skip-cache (message-backfill.md). + // Prod namespace_ids are minted by SST (`sst.cloudflare.Kv` in + // infra/stacks/chat-ws.ts); these local ids are placeholders — `wrangler dev` + // simulates KV in-process and ignores them. + "kv_namespaces": [ + { "binding": "SESSION_CACHE", "id": "session_cache_local" }, + { "binding": "CHAT_MAX_CACHE", "id": "chat_max_cache_local" } + ], "migrations": [ { "tag": "v1", @@ -32,7 +36,9 @@ // Edge session cache config (mirrors SST env in chat-ws.ts). "SESSION_CACHE_TTL": "120", "SESSION_CACHE_ENABLED": "1", - "SESSION_CACHE_METRICS": "0" + "SESSION_CACHE_METRICS": "0", + // Backfill metrics (message-backfill.md). "1" → per-bf "CMC" tail log. + "CHAT_MAX_CACHE_METRICS": "0" } // DATABASE_URL must be set in .dev.vars locally (not committed). SST // injects it in deployed stages; see infra/stacks/chat-ws.ts. From af7112093884d14e516dd022837ec7092857a087 Mon Sep 17 00:00:00 2001 From: Alex Moreton Date: Mon, 6 Jul 2026 12:26:02 +0100 Subject: [PATCH 2/5] Add further tests + latest msg persisted to Neon for backfill --- apps/mobile/.maestro/README.md | 61 +++++++ .../.maestro/offline-catch-up/_sign-in.yaml | 28 +++ .../.maestro/offline-catch-up/alice-send.yaml | 66 +++++++ .../offline-catch-up/bob-go-offline.yaml | 34 ++++ .../offline-catch-up/bob-relaunch-assert.yaml | 46 +++++ apps/mobile/.maestro/offline-catch-up/run.sh | 48 ++++++ apps/mobile/lib/chat/store-provider.tsx | 16 ++ packages/chat-transport/src/client.ts | 35 ++++ packages/chat/src/encrypted-transport.ts | 122 +++++++++++-- packages/chat/src/index.ts | 5 + packages/chat/src/provider.tsx | 162 ++++++++++++++++-- packages/chat/src/store.test.ts | 76 ++++++++ packages/chat/src/store.ts | 61 +++++++ 13 files changed, 734 insertions(+), 26 deletions(-) create mode 100644 apps/mobile/.maestro/offline-catch-up/_sign-in.yaml create mode 100644 apps/mobile/.maestro/offline-catch-up/alice-send.yaml create mode 100644 apps/mobile/.maestro/offline-catch-up/bob-go-offline.yaml create mode 100644 apps/mobile/.maestro/offline-catch-up/bob-relaunch-assert.yaml create mode 100755 apps/mobile/.maestro/offline-catch-up/run.sh diff --git a/apps/mobile/.maestro/README.md b/apps/mobile/.maestro/README.md index 84cde78..9c7c8cd 100644 --- a/apps/mobile/.maestro/README.md +++ b/apps/mobile/.maestro/README.md @@ -37,6 +37,66 @@ steps (or seed an authenticated session) for a cold-start CI run. Message text and reaction emoji are asserted by their visible text. +## Offline message backfill (2-simulator) + +`offline-catch-up/` is the 2-client rig for the message-backfill acceptance +(`docs/message-backfill.md`): **kill Bob → Alice sends N → relaunch Bob → all N +render**. It exists because that scenario is impossible to fake on one device — +the whole point is messages Bob's socket never received live, so local hydration +can't stand in for them. + +Maestro drives one app instance per run, so the flow is three sequenced +sub-runs across two sims, wired by `offline-catch-up/run.sh`: + +| Phase | Sim | Flow | What it proves | +| ----- | ----- | -------------------------- | -------------------------------------------------------------------------- | +| 1 | Bob | `bob-go-offline.yaml` | sign in, open chat, `stopApp` — dead process = dead WS = offline | +| 2 | Alice | `alice-send.yaml` | send N, wait until `latest-sent-message` confirms (= persisted to Neon) | +| 3 | Bob | `bob-relaunch-assert.yaml` | relaunch, assert all N visible — they can _only_ have arrived via backfill | + +Phase 1's kill holds for phase 2's whole duration, so Alice's fanout to Bob's +`UserInbox` finds zero sockets and is dropped — phase 3 passing is proof the +`bf`/`bfd` path delivered them, not the live path. + +**Strict ordering** of the merged thread is asserted by the vitest units +(`packages/chat/src/store.test.ts` → `ingestBackfill … sorted by serverSerial`); +the Maestro layer asserts **delivery**. That split keeps the flaky UI layer off +the deterministic invariants (dedupe / serial-order / cursor-monotonicity / +membership-gate are all unit-covered across `packages/{chat,chat-db,db-edge}`). + +### Running + +```sh +export MAESTRO_APP_ID=io.sessions.app # app.json ios/android identifier +export BOB_SIM= ALICE_SIM= # xcrun simctl list devices +apps/mobile/.maestro/offline-catch-up/run.sh +``` + +### One-time chat establishment (precondition — cannot be seeded) + +The flows assume a **direct alice↔bob chat is the top row on both devices**. +Unlike `chat-smoke`, this can't come from `packages/database/prisma/seed.ts`: +that seed builds only the social graph, and an **E2EE chat is not seedable** — +the device key private halves live in each sim's secure store and the MLS group +is provisioned on-device when the chat is created. Establish it once through the +apps (order matters — Bob must publish keys before Alice can add him): + +1. **Bob sim:** launch, sign in as `bob@example.com` → device keys + MLS + KeyPackages auto-publish to the server (`lib/chat/mls-auto-publish.ts`). +2. **Alice sim:** launch, sign in as `alice@example.com` → **New Chat** → + Direct → search `bob` → tap the result. `createNewChat` provisions the MLS + group (adds Bob via his KeyPackage) and opens the thread. +3. **Bob sim:** open the new chat once (consumes the MLS Welcome) so both sides + are live members. Now `run.sh` is repeatable. + +### Why not a headless peer script + +A Node "Alice" that re-implements the libsodium/MLS seal to inject +Bob-decryptable ciphertext was rejected: it duplicates the crypto pipe, needs +out-of-band key coordination, and demonstrates none of the real +device-lifecycle path. Letting the two real apps encrypt is both less fragile +and the honest end-to-end exercise. + ## Follow-ups (tracked, not built) 1. **2-client journey (rig).** Drive user A in Maestro while a second actor @@ -44,6 +104,7 @@ Message text and reaction emoji are asserted by their visible text. B sends → A shows the message + typing pulse; A reacts → pill; B reads → A's tick flips to the primary double-check. Requires B to be a real MLS group member (so its frames decrypt on A). See the cost note in the PR/plan. + (The `offline-catch-up/` rig above is the first instance of this pattern.) 2. **MLS out-of-order regression** (binary-gated). Lives in `chat-mls-core` (needs `node/index.node` via `pnpm run build:node`; the suite auto-skips diff --git a/apps/mobile/.maestro/offline-catch-up/_sign-in.yaml b/apps/mobile/.maestro/offline-catch-up/_sign-in.yaml new file mode 100644 index 0000000..711dbaf --- /dev/null +++ b/apps/mobile/.maestro/offline-catch-up/_sign-in.yaml @@ -0,0 +1,28 @@ +# Reusable sign-in subflow. Invoked via `runFlow: { file: _sign-in.yaml, env: … }` +# from each phase flow so the credential steps live in one place. +# +# Cold-start note (mirrors chat-smoke.yaml): launchApp relaunches the process and +# the Better Auth session does NOT rehydrate, so every phase lands on the auth +# gate. Gated on "Login" being visible → a no-op if a session ever does persist. +# Creds come from the DB seed (packages/database/prisma/seed.ts: SEED_PASSWORD). +appId: ${MAESTRO_APP_ID} +--- +- runFlow: + when: + visible: "Login" + commands: + - tapOn: "Email Address" + - inputText: ${EMAIL} + - tapOn: "Password" + - inputText: ${PASSWORD} + # enterKeyHint="go" + onSubmitEditing → submit via the return key. Avoids + # hideKeyboard (unsupported on the RN input) and a Login-button tap the + # keyboard can overlap. + - pressKey: Enter + +# Block until the conversations list paints — the shared anchor every phase +# needs before it can open the chat. +- extendedWaitUntil: + visible: + id: "chat-row" + timeout: 20000 diff --git a/apps/mobile/.maestro/offline-catch-up/alice-send.yaml b/apps/mobile/.maestro/offline-catch-up/alice-send.yaml new file mode 100644 index 0000000..8feb168 --- /dev/null +++ b/apps/mobile/.maestro/offline-catch-up/alice-send.yaml @@ -0,0 +1,66 @@ +# Phase 2 (Alice's device) — send N messages while Bob is offline. +# +# Runs on the ALICE simulator, between Bob's phase 1 (go offline) and phase 3 +# (relaunch + assert). Each send is confirmed via the `latest-sent-message` +# anchor before moving on: that testID is applied ONLY once the store has the +# server serial (store.ts confirmOptimisticMessage), i.e. the row is persisted +# in Neon. Waiting on it guarantees the messages are on the server — and thus +# backfillable — before phase 3 relaunches Bob. +# +# Texts are fixed + numerically ordered so phase 3 can assert each one arrived. +# Strict serial ORDER of the merged thread is locked by the vitest units +# (packages/chat/src/store.test.ts → "ingestBackfill … sorted by serverSerial"); +# this flow asserts DELIVERY (that offline messages are caught up at all). +appId: ${MAESTRO_APP_ID} +env: + ALICE_EMAIL: alice@example.com + ALICE_PASSWORD: password123 + MSG_1: "catch-up 1 of 3" + MSG_2: "catch-up 2 of 3" + MSG_3: "catch-up 3 of 3" +--- +- launchApp +- runFlow: + file: _sign-in.yaml + env: + EMAIL: ${ALICE_EMAIL} + PASSWORD: ${ALICE_PASSWORD} + +# Open the shared chat with Bob (top row). +- tapOn: + id: "chat-row" + index: 0 + +# ── Send message 1 ── +- tapOn: + id: "composer-input" +- inputText: ${MSG_1} +- tapOn: + id: "composer-send" +- assertVisible: ${MSG_1} + +# ── Send message 2 ── +- tapOn: + id: "composer-input" +- inputText: ${MSG_2} +- tapOn: + id: "composer-send" +- assertVisible: ${MSG_2} + +# ── Send message 3 ── +- tapOn: + id: "composer-input" +- inputText: ${MSG_3} +- tapOn: + id: "composer-send" +- assertVisible: ${MSG_3} + +# Block until the newest send is CONFIRMED (serial assigned → persisted to Neon). +# Serials are monotonic and DO-assigned in send order, so the last one being +# persisted implies msg1 + msg2 already are. Now Bob can backfill all three. +- extendedWaitUntil: + visible: + id: "latest-sent-message" + timeout: 10000 +- waitForAnimationToEnd: + timeout: 4000 diff --git a/apps/mobile/.maestro/offline-catch-up/bob-go-offline.yaml b/apps/mobile/.maestro/offline-catch-up/bob-go-offline.yaml new file mode 100644 index 0000000..9cd74f1 --- /dev/null +++ b/apps/mobile/.maestro/offline-catch-up/bob-go-offline.yaml @@ -0,0 +1,34 @@ +# Phase 1 (Bob's device) — establish a baseline, then go offline. +# +# Runs on the BOB simulator. Signs Bob in, opens the shared alice↔bob chat to +# prove the thread is reachable and to record what he has seen live, then kills +# the app. A killed process = a dead WebSocket = Bob is offline: any message +# Alice sends now is persisted to Neon but its live fanout finds zero sockets on +# Bob's UserInbox and is dropped — the exact gap message-backfill fixes. +# +# Precondition: a direct chat between alice + bob already exists and is Bob's +# top row (see README → "One-time chat establishment"). E2EE chats can't be +# seeded — the MLS group is provisioned on-device at chat-create time. +appId: ${MAESTRO_APP_ID} +env: + BOB_EMAIL: bob@example.com + BOB_PASSWORD: password123 +--- +- launchApp +- runFlow: + file: _sign-in.yaml + env: + EMAIL: ${BOB_EMAIL} + PASSWORD: ${BOB_PASSWORD} + +# Open the shared chat (top row after recent activity) so any already-delivered +# history is hydrated — establishes the "before" state the catch-up adds onto. +- tapOn: + id: "chat-row" + index: 0 +- waitForAnimationToEnd: + timeout: 4000 + +# Go offline. stopApp terminates the process, tearing down the WS. Bob stays +# down while phase 2 (alice-send) runs; phase 3 relaunches him. +- stopApp diff --git a/apps/mobile/.maestro/offline-catch-up/bob-relaunch-assert.yaml b/apps/mobile/.maestro/offline-catch-up/bob-relaunch-assert.yaml new file mode 100644 index 0000000..457b094 --- /dev/null +++ b/apps/mobile/.maestro/offline-catch-up/bob-relaunch-assert.yaml @@ -0,0 +1,46 @@ +# Phase 3 (Bob's device) — relaunch and assert the missed messages caught up. +# +# Runs on the BOB simulator after alice-send. Bob's local chat.sqlite never saw +# these messages (his socket was dead when Alice sent), so they can ONLY appear +# via backfill: on WS open + after bootstrap the provider issues one `bf` with +# Bob's per-chat cursor (force=true on the first pass this launch), the Worker +# reads them membership-gated from Neon, and the store merge-sorts the page in. +# +# NB: no clearState — we WANT Bob's real local store (which lacks these rows) so +# the assertion proves catch-up, not local hydration. If these texts render here +# yet were dropped on the live path in phase 2, backfill is what delivered them. +appId: ${MAESTRO_APP_ID} +env: + BOB_EMAIL: bob@example.com + BOB_PASSWORD: password123 + MSG_1: "catch-up 1 of 3" + MSG_2: "catch-up 2 of 3" + MSG_3: "catch-up 3 of 3" +--- +- launchApp +- runFlow: + file: _sign-in.yaml + env: + EMAIL: ${BOB_EMAIL} + PASSWORD: ${BOB_PASSWORD} + +# Open the shared chat. Opening is not what triggers backfill — WS-open + +# bootstrap already fired it — but it renders the thread so we can assert. +- tapOn: + id: "chat-row" + index: 0 + +# Backfill is a round-trip (bf → Neon read → bfd → decrypt → merge-sort), so the +# messages arrive shortly AFTER the thread paints. extendedWaitUntil polls each +# through that window rather than asserting on the first frame. Incoming bubbles +# have no self-anchor testID (that tags my own confirmed sends), so match on the +# received text — which is exactly the catch-up payload under test. +- extendedWaitUntil: + visible: ${MSG_1} + timeout: 15000 +- extendedWaitUntil: + visible: ${MSG_2} + timeout: 15000 +- extendedWaitUntil: + visible: ${MSG_3} + timeout: 15000 diff --git a/apps/mobile/.maestro/offline-catch-up/run.sh b/apps/mobile/.maestro/offline-catch-up/run.sh new file mode 100755 index 0000000..a4a47a2 --- /dev/null +++ b/apps/mobile/.maestro/offline-catch-up/run.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Two-simulator orchestrator for the offline message-backfill acceptance +# (docs/message-backfill.md). Maestro drives ONE app instance per invocation, so +# a genuine "peer sends while I'm offline" scenario needs two sims and three +# sequenced sub-runs — this script is that sequencer: +# +# 1. BOB → bob-go-offline.yaml (sign in, open chat, kill app = offline) +# 2. ALICE → alice-send.yaml (send N, wait until persisted to Neon) +# 3. BOB → bob-relaunch-assert.yaml (relaunch, assert all N caught up) +# +# Because the WS is dead for step 1's whole duration, Alice's step-2 messages are +# persisted but their live fanout to Bob is dropped — so step 3 passing is proof +# the backfill path (not local hydration) delivered them. +# +# Usage: +# export MAESTRO_APP_ID=io.sessions.app # app.json → ios/android id +# export BOB_SIM= # `xcrun simctl list devices` +# export ALICE_SIM= +# ./run.sh +# +# Precondition: a direct alice↔bob chat already exists as the top row on BOTH +# devices (see README → "One-time chat establishment"). E2EE chats can't be +# seeded; the MLS group is provisioned on-device at chat-create time. +set -euo pipefail + +: "${MAESTRO_APP_ID:?set MAESTRO_APP_ID (matches app.json ios/android identifier)}" +: "${BOB_SIM:?set BOB_SIM to the Bob simulator UDID}" +: "${ALICE_SIM:?set ALICE_SIM to the Alice simulator UDID}" + +if ! command -v maestro >/dev/null 2>&1; then + echo "maestro not found — install from https://maestro.mobile.dev" >&2 + exit 127 +fi + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +banner() { printf '\n\033[1m▶ %s\033[0m\n' "$1"; } + +banner "Phase 1/3 — Bob goes offline (device $BOB_SIM)" +maestro --device "$BOB_SIM" test "$HERE/bob-go-offline.yaml" + +banner "Phase 2/3 — Alice sends while Bob is offline (device $ALICE_SIM)" +maestro --device "$ALICE_SIM" test "$HERE/alice-send.yaml" + +banner "Phase 3/3 — Bob relaunches and catches up (device $BOB_SIM)" +maestro --device "$BOB_SIM" test "$HERE/bob-relaunch-assert.yaml" + +banner "✅ offline catch-up verified: all messages sent while Bob was offline rendered after relaunch" diff --git a/apps/mobile/lib/chat/store-provider.tsx b/apps/mobile/lib/chat/store-provider.tsx index 3e7de25..e999aae 100644 --- a/apps/mobile/lib/chat/store-provider.tsx +++ b/apps/mobile/lib/chat/store-provider.tsx @@ -10,6 +10,7 @@ import { ChatStoreProvider, createOutboxWorker, useChatStore, + type BackfillCursorApi, type BoundOutboxApi, type ChatApi, type MessagePersistApi, @@ -19,6 +20,7 @@ import { import { getChatDb, createBoundMessageStore, + backfillCursors as backfillCursorsDb, mls as mlsStore, outbox as outboxDb, type ChatDbHandle, @@ -85,6 +87,15 @@ function createTrpcChatApi(): ChatApi { }; } +// Curry chat-db's backfill_cursors helpers onto a handle → the injected +// BackfillCursorApi the store provider drives (docs/message-backfill.md). +function bindBackfillCursors(handle: ChatDbHandle): BackfillCursorApi { + return { + getAll: () => backfillCursorsDb.getAllCursors(handle.db), + set: (chatId, upTo) => backfillCursorsDb.setCursor(handle.db, chatId, upTo), + }; +} + // Curry the chat-db outbox namespace onto a single ChatDbHandle so the // shared @repo/chat outbox worker doesn't need to know about op-sqlite. function bindOutbox(handle: ChatDbHandle): BoundOutboxApi { @@ -127,6 +138,9 @@ export function MobileChatStoreProvider({ children }: { children: ReactNode }) { } | undefined >(undefined); + const [backfillApi, setBackfillApi] = useState( + undefined, + ); useEffect(() => { let active = true; @@ -135,6 +149,7 @@ export function MobileChatStoreProvider({ children }: { children: ReactNode }) { const handle: ChatDbHandle = await getChatDb(); if (!active) return; setPersistApi(createBoundMessageStore(handle)); + setBackfillApi(bindBackfillCursors(handle)); const bound = bindOutbox(handle); const store: OutboxWorkerStoreApi = { confirmOptimisticMessage, @@ -182,6 +197,7 @@ export function MobileChatStoreProvider({ children }: { children: ReactNode }) { messagePersist={persistApi} outbox={outboxBindings?.outbox} outboxWorker={outboxBindings?.worker} + backfillCursors={backfillApi} authenticated={authenticated} > {children} diff --git a/packages/chat-transport/src/client.ts b/packages/chat-transport/src/client.ts index ce1c9c5..410a12a 100644 --- a/packages/chat-transport/src/client.ts +++ b/packages/chat-transport/src/client.ts @@ -16,6 +16,16 @@ export type IncomingError = Extract; export type IncomingMlsWelcome = Extract; export type IncomingTyping = Extract; export type IncomingRead = Extract; +export type IncomingBackfill = Extract; + +/** One chat's cursor in a batched backfill request. `after` = greatest + * serverSerial already held ("0" = full history); `force` skips the server's + * KV skip-cache (set on the first backfill of a chat each app launch). */ +export interface BackfillRequestChat { + chatId: string; + after: string; + force?: boolean; +} export interface ChatTransportOptions { // Cloudflare Worker URL. Use ws:// for local wrangler, wss:// in prod. @@ -70,12 +80,18 @@ export interface ChatTransport { * reconciles ChatMember.lastReadSerial on next load. `upto` is a * serverMsgId string. */ sendRead(input: { chatId: string; upto: string }): void; + /** Offline backfill request (docs/message-backfill.md). One frame batches + * every chat's cursor. Fire-and-forget — dropped when the socket isn't + * open; the caller re-issues on the next `open`. */ + sendBackfill(chats: BackfillRequestChat[]): void; onMessage(handler: (msg: IncomingMessage) => void): () => void; onState(handler: (state: ConnectionState) => void): () => void; onError(handler: (err: IncomingError) => void): () => void; onMlsWelcome(handler: (m: IncomingMlsWelcome) => void): () => void; onTyping(handler: (m: IncomingTyping) => void): () => void; onRead(handler: (m: IncomingRead) => void): () => void; + /** One `bfd` page (per chat) in response to a `sendBackfill`. */ + onBackfill(handler: (m: IncomingBackfill) => void): () => void; } interface PendingSend { @@ -113,6 +129,7 @@ export function createChatTransport(opts: ChatTransportOptions): ChatTransport { const mlsWelcomeHandlers = new Set<(m: IncomingMlsWelcome) => void>(); const typingHandlers = new Set<(m: IncomingTyping) => void>(); const readHandlers = new Set<(m: IncomingRead) => void>(); + const backfillHandlers = new Set<(m: IncomingBackfill) => void>(); function setState(next: ConnectionState) { if (state === next) return; @@ -191,6 +208,9 @@ export function createChatTransport(opts: ChatTransportOptions): ChatTransport { case "read": for (const h of readHandlers) h(env); return; + case "bfd": + for (const h of backfillHandlers) h(env); + return; case "mls-welcome": for (const h of mlsWelcomeHandlers) h(env); return; @@ -363,6 +383,14 @@ export function createChatTransport(opts: ChatTransportOptions): ChatTransport { sendFrame({ t: "read", chatId: input.chatId, upto: input.upto }); } + // Best-effort like the ephemeral signals: sendFrame no-ops when the socket + // isn't open. A backfill lost to a disconnect is re-issued on the next + // `open` (the provider re-triggers), so no pending queue is needed. + function sendBackfill(chats: BackfillRequestChat[]) { + if (chats.length === 0) return; + sendFrame({ t: "bf", chats }); + } + function onTyping(handler: (m: IncomingTyping) => void) { typingHandlers.add(handler); return () => typingHandlers.delete(handler); @@ -373,6 +401,11 @@ export function createChatTransport(opts: ChatTransportOptions): ChatTransport { return () => readHandlers.delete(handler); } + function onBackfill(handler: (m: IncomingBackfill) => void) { + backfillHandlers.add(handler); + return () => backfillHandlers.delete(handler); + } + return { get state() { return state; @@ -383,12 +416,14 @@ export function createChatTransport(opts: ChatTransportOptions): ChatTransport { send, sendTyping, sendRead, + sendBackfill, onMessage, onState, onError, onMlsWelcome, onTyping, onRead, + onBackfill, }; } diff --git a/packages/chat/src/encrypted-transport.ts b/packages/chat/src/encrypted-transport.ts index ae1b78b..c5a749b 100644 --- a/packages/chat/src/encrypted-transport.ts +++ b/packages/chat/src/encrypted-transport.ts @@ -1,6 +1,8 @@ import type { + BackfillRequestChat, ChatTransport, ConnectionState, + IncomingBackfill, IncomingError, IncomingMessage, IncomingRead, @@ -13,8 +15,11 @@ import { encryptOutbound, encryptOutboundMls, FRAME_VERSION_V2, + isReactionFrame, type ChatFrame, type ChatFrameBody, + type ChatMsgFrame, + type ChatReactionFrame, type FanoutTarget, type MlsApi, } from "./crypto-pipe"; @@ -64,6 +69,37 @@ export interface EncryptedIncomingMessage { ts: number; } +// One decrypted backfilled row. Split by frame kind so the caller can route +// messages vs reactions without re-narrowing (docs/message-backfill.md §6). +export interface BackfilledMessage { + chatId: string; + serverMsgId: string; + senderId: string; + frame: ChatMsgFrame; + frameVersion: 1 | 2; + ts: number; +} +export interface BackfilledReaction { + chatId: string; + serverMsgId: string; + senderId: string; + frame: ChatReactionFrame; + frameVersion: 1 | 2; + ts: number; +} + +// A decrypted backfill page for one chat. `messages` + `reactions` are in +// ascending serverSerial order (the server pages ascending). Undecryptable rows +// are dropped from both — but `upTo` still advances past them, so the caller +// advances the cursor and never refetch-loops on a permanently-sealed row. +export interface DecryptedBackfill { + chatId: string; + messages: BackfilledMessage[]; + reactions: BackfilledReaction[]; + upTo: string; + more: boolean; +} + export interface EncryptedChatTransport { readonly state: ConnectionState; connect(): void; @@ -75,6 +111,8 @@ export interface EncryptedChatTransport { sendTyping(input: { chatId: string; on: boolean }): void; /** Read watermark — plaintext metadata (a serverMsgId), bypasses crypto. */ sendRead(input: { chatId: string; upto: string }): void; + /** Offline backfill request — plaintext cursors, bypasses crypto. */ + sendBackfill(chats: BackfillRequestChat[]): void; onMessage(handler: (msg: EncryptedIncomingMessage) => void): () => void; onState(handler: (state: ConnectionState) => void): () => void; onError(handler: (err: IncomingError) => void): () => void; @@ -82,6 +120,8 @@ export interface EncryptedChatTransport { onTyping(handler: (m: IncomingTyping) => void): () => void; /** Inbound read fanout (peer advanced their read watermark). */ onRead(handler: (m: IncomingRead) => void): () => void; + /** Decrypted backfill page (one per chat) for a prior sendBackfill. */ + onBackfill(handler: (b: DecryptedBackfill) => void): () => void; /** Server-pushed MLS Welcome wake-up. Caller should call * MlsClient.pollPendingWelcomes() immediately. Best-effort: the 30s * background poll remains the correctness fallback. */ @@ -143,12 +183,19 @@ export function createEncryptedTransport( }); } - async function handleInbound(msg: IncomingMessage) { + // Decrypt a single inbound row (live `msg` or a backfilled `bfd` row — both + // carry the same fields). Returns the decrypted frame + version, or null on + // any failure (empty/unknown/undecryptable), having already reported it via + // onDecryptFailure. Shared by the live path and backfill so both stay in + // lock-step on v=1/v=2 handling. + async function decryptOne( + msg: IncomingMessage, + ): Promise<{ frame: ChatFrame; frameVersion: 1 | 2 } | null> { // Peek the version byte before doing any lookups — v=2 skips the v=1 // peer-pub directory hit entirely. if (msg.ciphertext.length === 0) { opts.onDecryptFailure?.(msg, "empty ciphertext"); - return; + return null; } const version = msg.ciphertext[0]; @@ -158,21 +205,21 @@ export function createEncryptedTransport( msg, "v=2 frame received but no MLS engine configured", ); - return; + return null; } let groupId: Uint8Array | null; try { groupId = await opts.resolveChatGroupId(msg.chatId); } catch (err) { opts.onDecryptFailure?.(msg, `groupId lookup failed: ${String(err)}`); - return; + return null; } if (!groupId) { opts.onDecryptFailure?.( msg, `v=2 frame for chat ${msg.chatId} but no mls_group_id locally`, ); - return; + return null; } try { const result = decryptInbound({ @@ -189,11 +236,11 @@ export function createEncryptedTransport( if (result.frameVersion !== 2) { throw new Error("expected v=2 result for v=2 ciphertext"); } - dispatch(msg, result.frame, 2); + return { frame: result.frame, frameVersion: 2 }; } catch (err) { opts.onDecryptFailure?.(msg, String(err)); + return null; } - return; } // v=1 libsodium path. @@ -204,7 +251,7 @@ export function createEncryptedTransport( candidates = await opts.resolveSenderX25519Pubs(msg.senderId); } catch (err) { opts.onDecryptFailure?.(msg, `pre-decrypt lookup failed: ${String(err)}`); - return; + return null; } try { const result = decryptInbound({ @@ -217,12 +264,61 @@ export function createEncryptedTransport( if (result.frameVersion !== 1) { throw new Error("expected v=1 result for v=1 ciphertext"); } - dispatch(msg, result.frame, 1); + return { frame: result.frame, frameVersion: 1 }; } catch (err) { opts.onDecryptFailure?.(msg, String(err)); + return null; } } + async function handleInbound(msg: IncomingMessage) { + const res = await decryptOne(msg); + if (res) dispatch(msg, res.frame, res.frameVersion); + } + + // Decrypt one `bfd` page: map each row through decryptOne (dropping the ones + // that fail), split messages from reactions. Ordering is preserved from the + // server's ascending-serial page. `upTo`/`more` pass through untouched so the + // caller advances its cursor even past undecryptable rows. + async function decryptBackfill( + bfd: IncomingBackfill, + ): Promise { + const messages: BackfilledMessage[] = []; + const reactions: BackfilledReaction[] = []; + for (const row of bfd.messages) { + const asMsg: IncomingMessage = { + t: "msg", + chatId: bfd.chatId, + serverMsgId: row.serverMsgId, + senderId: row.senderId, + ciphertext: row.ciphertext, + nonce: row.nonce, + ts: row.ts, + }; + const res = await decryptOne(asMsg); + if (!res) continue; + const base = { + chatId: bfd.chatId, + serverMsgId: row.serverMsgId, + senderId: row.senderId, + frameVersion: res.frameVersion, + ts: row.ts, + }; + if (isReactionFrame(res.frame)) { + reactions.push({ ...base, frame: res.frame }); + } else { + messages.push({ ...base, frame: res.frame }); + } + } + return { + chatId: bfd.chatId, + messages, + reactions, + upTo: bfd.upTo, + more: bfd.more, + }; + } + function dispatch(msg: IncomingMessage, frame: ChatFrame, version: 1 | 2) { const decoded: EncryptedIncomingMessage = { chatId: msg.chatId, @@ -342,14 +438,20 @@ export function createEncryptedTransport( close: () => underlying.close(), subscribe: (chatIds) => underlying.subscribe(chatIds), send, - // Ephemeral signals delegate straight through — no encryption. + // Ephemeral signals + backfill cursors delegate straight through — no + // encryption (they carry only plaintext metadata / serial cursors). sendTyping: (input) => underlying.sendTyping(input), sendRead: (input) => underlying.sendRead(input), + sendBackfill: (chats) => underlying.sendBackfill(chats), onMessage, onState: (handler) => underlying.onState(handler), onError: (handler) => underlying.onError(handler), onTyping: (handler) => underlying.onTyping(handler), onRead: (handler) => underlying.onRead(handler), + onBackfill: (handler) => + underlying.onBackfill((bfd) => { + void decryptBackfill(bfd).then(handler); + }), onMlsWelcome: (handler) => underlying.onMlsWelcome((env) => handler(env.ts)), }; diff --git a/packages/chat/src/index.ts b/packages/chat/src/index.ts index 9a00c73..c91f9f3 100644 --- a/packages/chat/src/index.ts +++ b/packages/chat/src/index.ts @@ -32,6 +32,9 @@ export { type EncryptedIncomingMessage, type EncryptedSendInput, type EncryptedSendResult, + type BackfilledMessage, + type BackfilledReaction, + type DecryptedBackfill, } from "./encrypted-transport"; // ── Store + hooks (M4-3) ─────────────────────────────────────────────────── @@ -72,7 +75,9 @@ export { useChatTransport, useOutbox, useOutboxWorker, + useChatBackfill, type ChatStoreProviderProps, + type BackfillCursorApi, } from "./provider"; export type { ChatApi, diff --git a/packages/chat/src/provider.tsx b/packages/chat/src/provider.tsx index 35f8427..97d39b1 100644 --- a/packages/chat/src/provider.tsx +++ b/packages/chat/src/provider.tsx @@ -6,7 +6,14 @@ // like useSendMessage can issue sends without the consumer threading the // transport through every call site. -import { createContext, useContext, useEffect, type ReactNode } from "react"; +import { + createContext, + useCallback, + useContext, + useEffect, + useRef, + type ReactNode, +} from "react"; import { isReactionFrame } from "./crypto-pipe"; import type { EncryptedChatTransport } from "./encrypted-transport"; @@ -14,9 +21,23 @@ import type { BoundOutboxApi, OutboxWorker } from "./outbox-worker"; import { useChatStore } from "./store"; import type { ChatApi, MessagePersistApi } from "./types"; +// Injected persistence for backfill cursors (chat-db backfill_cursors table). +// Kept as an injected port — like MessagePersistApi — so @repo/chat stays free +// of a chat-db dependency. When absent, backfill is disabled (tests, debug). +export interface BackfillCursorApi { + /** { chatId → lastSerial } for every backfilled chat. */ + getAll(): Promise>; + /** Advance a chat's cursor to `upTo` (monotonic guard lives in chat-db). */ + set(chatId: string, upTo: string): Promise; +} + const ChatTransportContext = createContext(null); const OutboxContext = createContext(null); const OutboxWorkerContext = createContext(null); +// Exposes the backfill runner so a platform trigger the package can't own +// (silent-push wake, explicit foreground) can kick a catch-up pass. Null when +// no cursor store is wired. See useChatBackfill. +const BackfillContext = createContext<(() => void) | null>(null); export function useChatTransport(): EncryptedChatTransport { const t = useContext(ChatTransportContext); @@ -37,6 +58,14 @@ export function useOutboxWorker(): OutboxWorker | null { return useContext(OutboxWorkerContext); } +// Trigger an offline-backfill pass on demand. Returns a no-op when no cursor +// store is wired. Intended for the app's silent-push handler / an explicit +// foreground hook — the WS-open trigger (reconnect, incl. foreground-resume) +// is handled inside the provider. +export function useChatBackfill(): () => void { + return useContext(BackfillContext) ?? (() => {}); +} + export interface ChatStoreProviderProps { api: ChatApi; transport: EncryptedChatTransport; @@ -51,6 +80,10 @@ export interface ChatStoreProviderProps { * triggers, stopped on sign-out and unmount. */ outbox?: BoundOutboxApi; outboxWorker?: OutboxWorker; + /** Optional backfill cursor store (chat-db). When supplied, the provider runs + * offline catch-up on WS open + exposes a runner via useChatBackfill. When + * omitted, backfill is disabled. */ + backfillCursors?: BackfillCursorApi; /** True once the user is signed in. Bootstrap waits for this so we don't * fire chatList before auth lands. */ authenticated: boolean; @@ -63,6 +96,7 @@ export function ChatStoreProvider({ messagePersist, outbox, outboxWorker, + backfillCursors, authenticated, children, }: ChatStoreProviderProps) { @@ -73,12 +107,41 @@ export function ChatStoreProvider({ const reset = useChatStore((s) => s.reset); const addIncomingMessage = useChatStore((s) => s.addIncomingMessage); const applyIncomingReaction = useChatStore((s) => s.applyIncomingReaction); + const ingestBackfill = useChatStore((s) => s.ingestBackfill); const setTyping = useChatStore((s) => s.setTyping); const setReadReceipt = useChatStore((s) => s.setReadReceipt); const sweepExpiredTyping = useChatStore((s) => s.sweepExpiredTyping); const setPersistApi = useChatStore((s) => s.setPersistApi); const hydrateMessages = useChatStore((s) => s.hydrateMessages); + // Drives the `force` flag: the first backfill of a chat each app launch + // forces Neon (fresh-login correctness); later same-launch reconnects omit it + // and take the server's KV skip (cheap under reconnect storms). + const backfilledThisLaunch = useRef>(new Set()); + + // Build + fire one batched `bf` covering every known chat's cursor. No-op + // without a cursor store or before chats have loaded. + const runBackfill = useCallback(() => { + if (!backfillCursors) return; + const chatIds = Array.from(useChatStore.getState().chats.keys()); + if (chatIds.length === 0) return; + void (async () => { + let cursors: Record = {}; + try { + cursors = await backfillCursors.getAll(); + } catch (err) { + console.warn("[chat] backfill cursor read failed", err); + } + const batch = chatIds.map((chatId) => { + const after = cursors[chatId] ?? "0"; + const forced = !backfilledThisLaunch.current.has(chatId); + backfilledThisLaunch.current.add(chatId); + return forced ? { chatId, after, force: true } : { chatId, after }; + }); + transport.sendBackfill(batch); + })(); + }, [backfillCursors, transport]); + // Plug local persistence into the store while the provider is mounted. // Decoupled from `authenticated` because reset() preserves the persistApi // by design — we want sign-out to clear in-memory state without @@ -95,24 +158,39 @@ export function ChatStoreProvider({ useEffect(() => { if (!authenticated) { reset(); + // New sign-in should re-force backfill from Neon (fresh-login correctness). + backfilledThisLaunch.current.clear(); return; } void (async () => { await bootstrap(api); - if (!messagePersist) return; - const chatIds = Array.from(useChatStore.getState().chats.keys()); - await Promise.all( - chatIds.map(async (chatId) => { - try { - const persisted = await messagePersist.load(chatId); - if (persisted.length > 0) hydrateMessages(chatId, persisted); - } catch (err) { - console.warn("[chat] hydrate failed for", chatId, err); - } - }), - ); + if (messagePersist) { + const chatIds = Array.from(useChatStore.getState().chats.keys()); + await Promise.all( + chatIds.map(async (chatId) => { + try { + const persisted = await messagePersist.load(chatId); + if (persisted.length > 0) hydrateMessages(chatId, persisted); + } catch (err) { + console.warn("[chat] hydrate failed for", chatId, err); + } + }), + ); + } + // Fresh-login catch-up: now that chats are known, request backfill. If the + // socket isn't open yet the frame is dropped and the WS-open trigger below + // re-issues it — belt and suspenders. + runBackfill(); })(); - }, [authenticated, api, bootstrap, hydrateMessages, messagePersist, reset]); + }, [ + authenticated, + api, + bootstrap, + hydrateMessages, + messagePersist, + reset, + runBackfill, + ]); // Refresh on (re)connect — picks up chats created while offline + any // changes another device made. Also kick the outbox worker so any queued @@ -123,9 +201,13 @@ export function ChatStoreProvider({ if (state === "open") { void refresh(api); workerApi?.kick(); + // Catch up on messages missed while the socket was down. Covers cold + // connect, reconnect storms, and foreground-resume (the app reconnects + // on foreground → this fires). + runBackfill(); } }); - }, [authenticated, api, refresh, transport, workerApi]); + }, [authenticated, api, refresh, transport, workerApi, runBackfill]); // Worker lifecycle — tied to auth, not to mount, so sign-out halts // background dispatch even if the provider stays mounted across users. @@ -171,6 +253,50 @@ export function ChatStoreProvider({ }); }, [authenticated, addIncomingMessage, applyIncomingReaction, transport]); + // Backfill ingestion (docs/message-backfill.md). Each `bfd` page is already + // decrypted + split by the transport. Merge messages via the store's + // serial-sorted ingest, fold reactions onto their targets (serial-ascending + // order guarantees a target lands before its reaction), advance the cursor to + // `upTo` (even past undecryptable rows → no refetch loop), and page while more. + useEffect(() => { + if (!authenticated) return; + return transport.onBackfill((page) => { + if (page.messages.length > 0) { + ingestBackfill( + page.chatId, + page.messages.map((m) => ({ + serverMsgId: m.serverMsgId, + senderAuthUserId: m.senderId, + text: m.frame.text, + ts: m.ts, + })), + ); + } + for (const rx of page.reactions) { + applyIncomingReaction({ + chatId: page.chatId, + target: rx.frame.target, + emoji: rx.frame.emoji, + op: rx.frame.op, + senderAuthUserId: rx.senderId, + }); + } + void backfillCursors?.set(page.chatId, page.upTo).catch((err) => { + console.warn("[chat] backfill cursor write failed", err); + }); + // Same-session paging never re-forces — honor the KV skip on follow-ups. + if (page.more) { + transport.sendBackfill([{ chatId: page.chatId, after: page.upTo }]); + } + }); + }, [ + authenticated, + transport, + ingestBackfill, + applyIncomingReaction, + backfillCursors, + ]); + // Live typing + read-receipt ingestion. These are ephemeral/metadata frames // (plaintext, not encrypted) fanned out by the Chat DO. useEffect(() => { @@ -200,7 +326,11 @@ export function ChatStoreProvider({ - {children} + + {children} + diff --git a/packages/chat/src/store.test.ts b/packages/chat/src/store.test.ts index a94d2e9..47fa19c 100644 --- a/packages/chat/src/store.test.ts +++ b/packages/chat/src/store.test.ts @@ -107,6 +107,82 @@ describe("typing reducers", () => { }); }); +describe("ingestBackfill (offline catch-up)", () => { + const bf = (serverMsgId: string, text: string, ts = Number(serverMsgId)) => ({ + serverMsgId, + senderAuthUserId: "u2", + text, + ts, + }); + + it("merges a page sorted by serverSerial (numeric, not lexical)", () => { + // Deliberately out of order + a lexical trap ("9" vs "10"). + s().ingestBackfill(CHAT, [bf("10", "j"), bf("9", "i"), bf("2", "b")]); + const list = s().messages.get(CHAT)!; + expect(list.map((m) => m.serverSerial)).toEqual(["2", "9", "10"]); + expect(list.map((m) => m.text)).toEqual(["b", "i", "j"]); + expect(list.every((m) => m.status === "sent")).toBe(true); + }); + + it("dedupes by serverSerial against existing rows and within the batch", () => { + s().addIncomingMessage({ + chatId: CHAT, + serverMsgId: "5", + senderAuthUserId: "u2", + text: "live-5", + ts: 5, + }); + // "5" already present (live); "7" duplicated within the batch. + s().ingestBackfill(CHAT, [ + bf("5", "dup"), + bf("7", "g"), + bf("7", "g-again"), + ]); + const list = s().messages.get(CHAT)!; + expect(list.map((m) => m.serverSerial)).toEqual(["5", "7"]); + // The existing live row is not overwritten by the backfill dup. + expect(list.find((m) => m.serverSerial === "5")?.text).toBe("live-5"); + }); + + it("interleaves a live send into correct serial order after backfill", () => { + s().ingestBackfill(CHAT, [bf("2", "b"), bf("4", "d")]); + // A live message with a serial BETWEEN the backfilled ones lands sorted. + s().addIncomingMessage({ + chatId: CHAT, + serverMsgId: "3", + senderAuthUserId: "u2", + text: "c", + ts: 3, + }); + // A later backfill page arrives out of order relative to the live insert. + s().ingestBackfill(CHAT, [bf("1", "a"), bf("5", "e")]); + expect( + s() + .messages.get(CHAT)! + .map((m) => m.serverSerial), + ).toEqual(["1", "2", "3", "4", "5"]); + }); + + it("keeps an optimistic (unsent) message at the tail", () => { + s().addOptimisticMessage({ + chatId: CHAT, + clientMsgId: "opt-1", + senderAuthUserId: "me", + text: "typing…", + }); + s().ingestBackfill(CHAT, [bf("8", "h"), bf("6", "f")]); + const list = s().messages.get(CHAT)!; + // Confirmed history sorts by serial; the unsent optimistic row tail-sorts. + expect(list.map((m) => m.serverSerial ?? "opt")).toEqual(["6", "8", "opt"]); + expect(list[list.length - 1]!.status).toBe("sending"); + }); + + it("no-ops on an empty page", () => { + s().ingestBackfill(CHAT, []); + expect(s().messages.get(CHAT)).toBeUndefined(); + }); +}); + describe("read-receipt reducer (monotonic)", () => { it("advances forward and ignores regressions", () => { s().setReadReceipt({ chatId: CHAT, userId: "u2", upto: "10" }); diff --git a/packages/chat/src/store.ts b/packages/chat/src/store.ts index 2879389..b0d28b5 100644 --- a/packages/chat/src/store.ts +++ b/packages/chat/src/store.ts @@ -147,6 +147,20 @@ export interface ChatStoreActions { * and feeds the result here. Idempotent: skips ids already in the * in-memory list. */ hydrateMessages(chatId: string, persisted: Message[]): void; + /** Merge a decrypted offline-backfill page into the per-chat slice + * (docs/message-backfill.md §7). Dedupes by serverSerial (against existing + * + within the batch) and re-sorts the merged list by serverSerial so a + * live send that interleaved lands in correct order. The live append path + * (addIncomingMessage) stays untouched / O(1). */ + ingestBackfill( + chatId: string, + msgs: Array<{ + serverMsgId: string; + senderAuthUserId: string; + text: string; + ts: number; + }>, + ): void; /** Wipe everything. Used on sign-out and from tests. */ reset(): void; } @@ -169,6 +183,22 @@ const emptyState: ChatStoreState = { persistApi: null, }; +// Order the merged thread by serverSerial. Serialized messages sort by their +// serial (numeric via BigInt — "9" < "10", never lexical); an optimistic send +// (no serverSerial yet) tail-sorts after all confirmed rows, by createdAt, so +// the user's just-typed unsent bubble stays at the bottom. +function compareBySerial(a: Message, b: Message): number { + const sa = a.serverSerial; + const sb = b.serverSerial; + if (sa != null && sb != null) { + const d = BigInt(sa) - BigInt(sb); + return d < 0n ? -1 : d > 0n ? 1 : 0; + } + if (sa != null) return -1; + if (sb != null) return 1; + return a.createdAt - b.createdAt; +} + function firePersist(api: MessagePersistApi | null, msg: Message): void { if (!api) return; void api @@ -583,6 +613,37 @@ export const useChatStore = create((set, get) => ({ set({ messages }); }, + ingestBackfill(chatId, msgs) { + if (msgs.length === 0) return; + const messages = new Map(get().messages); + const existing = messages.get(chatId) ?? []; + // Dedupe by serverSerial against what's already in the slice AND within the + // incoming batch (the server pages ascending, but be defensive). + const seen = new Set( + existing.map((m) => m.serverSerial).filter((s): s is string => s != null), + ); + const additions: Message[] = []; + for (const m of msgs) { + if (seen.has(m.serverMsgId)) continue; + seen.add(m.serverMsgId); + additions.push({ + id: m.serverMsgId, + chatId, + senderAuthUserId: m.senderAuthUserId, + text: m.text, + status: "sent", + clientMsgId: m.serverMsgId, + serverSerial: m.serverMsgId, + createdAt: m.ts, + }); + } + if (additions.length === 0) return; + const merged = [...existing, ...additions].sort(compareBySerial); + messages.set(chatId, merged); + set({ messages }); + for (const msg of additions) firePersist(get().persistApi, msg); + }, + reset() { set({ ...emptyState, From a24134c1de95b94c6a10960b74c850a3e82301a9 Mon Sep 17 00:00:00 2001 From: Alex Moreton Date: Mon, 6 Jul 2026 12:31:34 +0100 Subject: [PATCH 3/5] Bump deploy version --- infra/stacks/chat-ws.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/infra/stacks/chat-ws.ts b/infra/stacks/chat-ws.ts index 650139d..b71e579 100644 --- a/infra/stacks/chat-ws.ts +++ b/infra/stacks/chat-ws.ts @@ -167,8 +167,8 @@ export const chatWsWorker = new sst.cloudflare.Worker("ChatWs", { // ⚠ PRECONDITION — deployed tag. VERIFY with `sst diff` before apply; the // oldTag MUST equal the currently deployed tag (error 10079 otherwise). args.migrations = { - oldTag: "v8", - newTag: "v9", + oldTag: "v9", + newTag: "v10", }; // Compatibility date with WebSocket auto-reply-to-close behaviour From d88fc2f01f164e8c0215f12f0735bd75dd5b0a10 Mon Sep 17 00:00:00 2001 From: Alex Moreton Date: Mon, 6 Jul 2026 16:28:38 +0100 Subject: [PATCH 4/5] services/chat-ws handleBackfill tests + decrypt-fail cursor advance tests --- packages/chat/src/backfill-decrypt.test.ts | 136 +++++++++++++ pnpm-lock.yaml | 59 +++--- services/chat-ws/package.json | 4 +- services/chat-ws/src/durable/backfill.test.ts | 187 ++++++++++++++++++ services/chat-ws/src/durable/backfill.ts | 107 ++++++++++ services/chat-ws/src/durable/user-inbox.ts | 69 ++----- services/chat-ws/vitest.config.ts | 12 ++ 7 files changed, 487 insertions(+), 87 deletions(-) create mode 100644 packages/chat/src/backfill-decrypt.test.ts create mode 100644 services/chat-ws/src/durable/backfill.test.ts create mode 100644 services/chat-ws/src/durable/backfill.ts create mode 100644 services/chat-ws/vitest.config.ts diff --git a/packages/chat/src/backfill-decrypt.test.ts b/packages/chat/src/backfill-decrypt.test.ts new file mode 100644 index 0000000..7549960 --- /dev/null +++ b/packages/chat/src/backfill-decrypt.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it, vi } from "vitest"; + +// chat-crypto calls requireNativeModule() at import, which throws in Node. Stub +// it so importing encrypted-transport (→ crypto-pipe → chat-crypto) works. The +// v=2 tests use the passthrough MLS engine and never touch ChatCrypto.box. +vi.mock("@repo/chat-crypto", () => ({ + ChatCrypto: { box: vi.fn(), boxOpen: vi.fn() }, +})); + +import type { + ChatTransport, + IncomingBackfill, +} from "@repo/chat-transport/client"; + +import { + createEncryptedTransport, + type DecryptedBackfill, + type EncryptedChatTransportOptions, +} from "./encrypted-transport"; +import { + encryptOutboundMls, + FRAME_VERSION_V2, + type MlsApi, +} from "./crypto-pipe"; + +// Passthrough MLS engine (as in crypto-pipe.test): round-trips the real v=2 code +// path with zero native crypto. +const fakeMls: MlsApi = { + encryptApp: (_g, plaintext) => plaintext, + processMessage: (_g, bytes) => ({ kind: "application", plaintext: bytes }), +}; +const GID = new Uint8Array([1, 2, 3, 4]); + +// A raw v=2-tagged ciphertext that is NOT a decodable frame → decrypt fails +// deterministically once past the version peek. +function undecryptableV2(): Uint8Array { + return new Uint8Array([FRAME_VERSION_V2, 0xff, 0xff, 0xff]); +} + +function bfdRow(serverMsgId: string, ciphertext: Uint8Array) { + return { + serverMsgId, + senderId: "peer", + ciphertext, + nonce: new Uint8Array(0), + ts: Number(serverMsgId) * 1000, + }; +} + +// Wire an encrypted transport onto a fake underlying whose `bfd` handler we can +// fire by hand, then push one page and await the decrypted result. +function harness(opts: Partial) { + let fire: ((m: IncomingBackfill) => void) | null = null; + const underlying = { + onBackfill: (h: (m: IncomingBackfill) => void) => { + fire = h; + return () => { + fire = null; + }; + }, + } as unknown as ChatTransport; + + const onDecryptFailure = vi.fn(); + const transport = createEncryptedTransport({ + underlying, + getMySeed: async () => new Uint8Array(0), + getMyAccountId: async () => "me", + resolveSenderX25519Pubs: async () => [], + onDecryptFailure, + ...opts, + }); + + const page = new Promise((resolve) => { + transport.onBackfill(resolve); + }); + const push = (bfd: IncomingBackfill) => fire!(bfd); + return { push, page, onDecryptFailure }; +} + +describe("decryptBackfill — undecryptable rows advance the cursor (ADR-0020 §5)", () => { + it("drops every row when the engine can't decrypt but still passes upTo/more through", async () => { + // No mls / resolveChatGroupId → every v=2 row is undecryptable. + const { push, page, onDecryptFailure } = harness({}); + + push({ + t: "bfd", + chatId: "chat-1", + messages: [ + bfdRow("8", undecryptableV2()), + bfdRow("9", undecryptableV2()), + ], + upTo: "9", + more: true, + }); + + const result = await page; + expect(result.messages).toEqual([]); + expect(result.reactions).toEqual([]); + // The cursor advances past the sealed rows → no refetch-loop wedge. + expect(result.upTo).toBe("9"); + expect(result.more).toBe(true); + expect(onDecryptFailure).toHaveBeenCalledTimes(2); + }); + + it("keeps decryptable rows, drops the bad one, and advances past both", async () => { + const { push, page, onDecryptFailure } = harness({ + mls: fakeMls, + resolveChatGroupId: async () => GID, + }); + + const good = encryptOutboundMls({ + body: { text: "hello" }, + groupId: GID, + mls: fakeMls, + }); + + push({ + t: "bfd", + chatId: "chat-1", + messages: [ + bfdRow("8", good.ciphertext), // decryptable + bfdRow("9", new Uint8Array(0)), // empty → drop + ], + upTo: "9", + more: false, + }); + + const result = await page; + expect(result.messages).toHaveLength(1); + expect(result.messages[0]!.serverMsgId).toBe("8"); + expect(result.messages[0]!.frame.text).toBe("hello"); + // upTo still advances past the dropped serial 9. + expect(result.upTo).toBe("9"); + expect(onDecryptFailure).toHaveBeenCalledTimes(1); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1392d5a..a9c67d1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -741,6 +741,9 @@ importers: typescript: specifier: 5.9.2 version: 5.9.2 + vitest: + specifier: ^2.1.9 + version: 2.1.9(@types/node@22.19.18)(lightningcss@1.32.0)(terser@5.47.1) wrangler: specifier: ^4.90.0 version: 4.90.0(@cloudflare/workers-types@4.20260509.1) @@ -10221,7 +10224,7 @@ snapshots: '@bcoe/v8-coverage@0.2.3': optional: true - '@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0)': + '@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0)': dependencies: '@better-auth/utils': 0.4.0 '@better-fetch/fetch': 1.1.21 @@ -10235,39 +10238,39 @@ snapshots: optionalDependencies: '@cloudflare/workers-types': 4.20260509.1 - '@better-auth/drizzle-adapter@1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)': + '@better-auth/drizzle-adapter@1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)': dependencies: - '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/utils': 0.4.0 - '@better-auth/kysely-adapter@1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(kysely@0.28.17)': + '@better-auth/kysely-adapter@1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(kysely@0.28.17)': dependencies: - '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/utils': 0.4.0 optionalDependencies: kysely: 0.28.17 - '@better-auth/memory-adapter@1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)': + '@better-auth/memory-adapter@1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)': dependencies: - '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/utils': 0.4.0 - '@better-auth/mongo-adapter@1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)': + '@better-auth/mongo-adapter@1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)': dependencies: - '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/utils': 0.4.0 - '@better-auth/prisma-adapter@1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.2))(typescript@5.9.2))(prisma@6.19.3(typescript@5.9.2))': + '@better-auth/prisma-adapter@1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.2))(typescript@5.9.2))(prisma@6.19.3(typescript@5.9.2))': dependencies: - '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/utils': 0.4.0 optionalDependencies: '@prisma/client': 6.19.3(prisma@6.19.3(typescript@5.9.2))(typescript@5.9.2) prisma: 6.19.3(typescript@5.9.2) - '@better-auth/telemetry@1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)': + '@better-auth/telemetry@1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)': dependencies: - '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/utils': 0.4.0 '@better-fetch/fetch': 1.1.21 @@ -14753,13 +14756,13 @@ snapshots: better-auth@1.6.9(@cloudflare/workers-types@4.20260509.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.2))(typescript@5.9.2))(next@16.1.0(@babel/core@7.29.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(prisma@6.19.3(typescript@5.9.2))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@2.1.9(@types/node@22.19.18)(lightningcss@1.32.0)(terser@5.47.1)): dependencies: - '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) - '@better-auth/drizzle-adapter': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0) - '@better-auth/kysely-adapter': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(kysely@0.28.17) - '@better-auth/memory-adapter': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0) - '@better-auth/mongo-adapter': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0) - '@better-auth/prisma-adapter': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.2))(typescript@5.9.2))(prisma@6.19.3(typescript@5.9.2)) - '@better-auth/telemetry': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21) + '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/drizzle-adapter': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0) + '@better-auth/kysely-adapter': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(kysely@0.28.17) + '@better-auth/memory-adapter': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0) + '@better-auth/mongo-adapter': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0) + '@better-auth/prisma-adapter': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.2))(typescript@5.9.2))(prisma@6.19.3(typescript@5.9.2)) + '@better-auth/telemetry': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260509.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21) '@better-auth/utils': 0.4.0 '@better-fetch/fetch': 1.1.21 '@noble/ciphers': 2.2.0 @@ -15541,9 +15544,9 @@ snapshots: '@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.2))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.2) '@typescript-eslint/parser': 8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.2) eslint: 9.39.4(jiti@2.7.0) - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.2))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-expo: 1.0.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.2) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.2))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-react-hooks: 5.2.0(eslint@9.39.4(jiti@2.7.0)) globals: 16.5.0 @@ -15565,7 +15568,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.2))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -15576,18 +15579,18 @@ snapshots: tinyglobby: 0.2.16 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.2))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.2))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.2))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.2))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.2) eslint: 9.39.4(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.2))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)) transitivePeerDependencies: - supports-color @@ -15600,7 +15603,7 @@ snapshots: - supports-color - typescript - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.2))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -15611,7 +15614,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.4(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.2))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.2))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.2))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)) hasown: 2.0.3 is-core-module: 2.16.2 is-glob: 4.0.3 diff --git a/services/chat-ws/package.json b/services/chat-ws/package.json index 904d42e..68adda9 100644 --- a/services/chat-ws/package.json +++ b/services/chat-ws/package.json @@ -9,7 +9,8 @@ "dev": "wrangler dev --local --persist-to .wrangler/state", "lint": "eslint . --ignore-pattern .wrangler", "check-types": "tsc --noEmit", - "test": "echo \"no tests yet\" && exit 0", + "test": "vitest run", + "test:watch": "vitest", "smoke:m1": "node scripts/smoke-m1.mjs", "smoke:m2": "node scripts/smoke-m2.mjs" }, @@ -25,6 +26,7 @@ "@repo/typescript-config": "workspace:*", "eslint": "^9.39.1", "typescript": "5.9.2", + "vitest": "^2.1.9", "wrangler": "^4.90.0" } } diff --git a/services/chat-ws/src/durable/backfill.test.ts b/services/chat-ws/src/durable/backfill.test.ts new file mode 100644 index 0000000..b61b256 --- /dev/null +++ b/services/chat-ws/src/durable/backfill.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it, vi } from "vitest"; +import type { BackfilledMessageRow } from "@repo/db-edge"; + +import { + BACKFILL_PAGE_SIZE, + resolveBackfillPage, + type BackfillDeps, +} from "./backfill"; + +// A canned Neon row. serverSerial drives serverMsgId/upTo; createdAt drives ts. +function row(serial: bigint): BackfilledMessageRow { + return { + serverSerial: serial, + senderId: `u-${serial}`, + ciphertext: new Uint8Array([Number(serial % 256n)]), + nonce: new Uint8Array([0]), + createdAt: new Date(Number(serial) * 1000), + }; +} + +// Build deps with a spy messagesSince and an optional kvGet. Lets each test +// assert whether Neon was hit and what the KV read was. +function deps(opts: { + rows?: BackfilledMessageRow[]; + kvGet?: BackfillDeps["kvGet"]; +}) { + const messagesSince = vi.fn(async () => opts.rows ?? []); + const d: BackfillDeps = { + kvGet: opts.kvGet ?? null, + messagesSince, + }; + return { d, messagesSince }; +} + +describe("resolveBackfillPage — KV skip-cache (ADR-0020 §3)", () => { + it("skips Neon when the KV max is at or below the client cursor", async () => { + const kvGet = vi.fn(async () => "10"); + const { d, messagesSince } = deps({ kvGet }); + + const { frame, skipped, rows } = await resolveBackfillPage(d, "user-A", { + chatId: "chat-1", + after: "10", + }); + + expect(kvGet).toHaveBeenCalledWith("chatmax:chat-1"); + expect(messagesSince).not.toHaveBeenCalled(); // zero Neon reads (acceptance) + expect(skipped).toBe(true); + expect(rows).toBe(0); + expect(frame).toEqual({ + t: "bfd", + chatId: "chat-1", + messages: [], + upTo: "10", // cursor unchanged, no refetch loop + more: false, + }); + }); + + it("hits Neon when the KV max is above the client cursor (a real gap)", async () => { + const kvGet = vi.fn(async () => "20"); + const { d, messagesSince } = deps({ rows: [row(11n)], kvGet }); + + const { skipped } = await resolveBackfillPage(d, "user-A", { + chatId: "chat-1", + after: "10", + }); + + expect(skipped).toBe(false); + expect(messagesSince).toHaveBeenCalledOnce(); + }); + + it("force:true bypasses the KV skip entirely (fresh-login correctness, §4)", async () => { + const kvGet = vi.fn(async () => "10"); + const { d, messagesSince } = deps({ rows: [row(5n)], kvGet }); + + await resolveBackfillPage(d, "user-A", { + chatId: "chat-1", + after: "10", + force: true, + }); + + expect(kvGet).not.toHaveBeenCalled(); + expect(messagesSince).toHaveBeenCalledOnce(); + }); + + it("falls through to Neon when the KV read throws (never skip on uncertainty)", async () => { + const kvGet = vi.fn(async () => { + throw new Error("KV unavailable"); + }); + const { d, messagesSince } = deps({ rows: [row(5n)], kvGet }); + + const { skipped } = await resolveBackfillPage(d, "user-A", { + chatId: "chat-1", + after: "0", + }); + + expect(skipped).toBe(false); + expect(messagesSince).toHaveBeenCalledOnce(); + }); + + it("hits Neon when no KV namespace is bound", async () => { + const { d, messagesSince } = deps({ rows: [row(1n)], kvGet: null }); + + await resolveBackfillPage(d, "user-A", { chatId: "chat-1", after: "0" }); + + expect(messagesSince).toHaveBeenCalledOnce(); + }); +}); + +describe("resolveBackfillPage — membership gate + cursor (ADR-0020 §2, §5)", () => { + it("returns an empty page for a non-member and advances upTo to `after`", async () => { + // messagesSince yields [] because the query's EXISTS ChatMember gate + // excludes a non-member — no ciphertext, no metadata leak, no wedge. + const { d } = deps({ rows: [] }); + + const { frame } = await resolveBackfillPage(d, "stranger", { + chatId: "chat-1", + after: "42", + force: true, + }); + + expect(frame.messages).toEqual([]); + expect(frame.upTo).toBe("42"); // cursor holds; a later real membership catches up + expect(frame.more).toBe(false); + }); + + it("maps served rows and advances upTo to the greatest serial", async () => { + const { d } = deps({ rows: [row(7n), row(8n), row(9n)] }); + + const { frame, rows } = await resolveBackfillPage(d, "user-A", { + chatId: "chat-1", + after: "6", + force: true, + }); + + expect(rows).toBe(3); + expect(frame.more).toBe(false); + expect(frame.upTo).toBe("9"); + expect(frame.messages).toEqual([ + { + serverMsgId: "7", + senderId: "u-7", + ciphertext: new Uint8Array([7]), + nonce: new Uint8Array([0]), + ts: 7000, + }, + { + serverMsgId: "8", + senderId: "u-8", + ciphertext: new Uint8Array([8]), + nonce: new Uint8Array([0]), + ts: 8000, + }, + { + serverMsgId: "9", + senderId: "u-9", + ciphertext: new Uint8Array([9]), + nonce: new Uint8Array([0]), + ts: 9000, + }, + ]); + }); + + it("sets more:true and serves exactly PAGE_SIZE when a further page exists", async () => { + // messagesSince is asked for PAGE_SIZE+1; returning that many signals `more`. + const full = Array.from({ length: BACKFILL_PAGE_SIZE + 1 }, (_, i) => + row(BigInt(i + 1)), + ); + const { d, messagesSince } = deps({ rows: full }); + + const { frame } = await resolveBackfillPage(d, "user-A", { + chatId: "chat-1", + after: "0", + force: true, + }); + + expect(messagesSince).toHaveBeenCalledWith( + "chat-1", + "user-A", + 0n, + BACKFILL_PAGE_SIZE + 1, + ); + expect(frame.more).toBe(true); + expect(frame.messages).toHaveLength(BACKFILL_PAGE_SIZE); + // upTo is the last SERVED serial (PAGE_SIZE), not the peeked +1 row. + expect(frame.upTo).toBe(String(BACKFILL_PAGE_SIZE)); + }); +}); diff --git a/services/chat-ws/src/durable/backfill.ts b/services/chat-ws/src/durable/backfill.ts new file mode 100644 index 0000000..c0e00c9 --- /dev/null +++ b/services/chat-ws/src/durable/backfill.ts @@ -0,0 +1,107 @@ +import type { ServerToClient } from "@repo/chat-transport"; +import type { BackfilledMessageRow } from "@repo/db-edge"; + +// Backfill page size (docs/message-backfill.md, ADR-0020). Server fetches +// PAGE_SIZE+1 to detect whether another page exists; drop to 50–100 if +// ciphertexts grow large. +export const BACKFILL_PAGE_SIZE = 200; + +type BfdFrame = Extract; + +// The two side-effecting seams `resolveBackfillPage` needs, injected so the +// per-chat logic is unit-testable without a Durable Object or a Neon round-trip +// (mirrors the db-edge testing approach — fake the seam, Maestro covers e2e). +export interface BackfillDeps { + // env.CHAT_MAX_CACHE.get(key). Null when the KV namespace is unbound (local + // dev / a deploy before the binding lands) → treated as "no skip available". + kvGet: ((key: string) => Promise) | null; + // Membership-gated Neon read (packages/db-edge messagesSince). The EXISTS + // ChatMember gate inside the query is the authorization boundary, so a + // non-member simply yields zero rows here. + messagesSince: ( + chatId: string, + userId: string, + after: bigint, + limit: number, + ) => Promise; +} + +export interface BackfillPageResult { + frame: BfdFrame; + // Whether the KV skip-cache short-circuited the Neon read (for metrics). + skipped: boolean; + // Rows served on this page (for metrics). + rows: number; +} + +// Resolve a single chat's backfill request into a `bfd` frame. Pure of socket +// and env I/O — the DO wraps this and fans the sends. Ordering / cursor rules +// are ADR-0020 §3–§6. +export async function resolveBackfillPage( + deps: BackfillDeps, + userId: string, + c: { chatId: string; after: string; force?: boolean }, +): Promise { + const after = BigInt(c.after); + + // KV skip (§3): the client's cursor already covers the chat's max serial → no + // Neon, no rows. Safe by the core invariant — a stale-low/missing KV entry + // only delays catch-up (the next send rewrites chatmax and the full-range + // query returns the gap). A fresh login sets force:true to bypass it. On any + // KV read error we fall through to Neon: never skip on uncertainty. + let skipped = false; + if (!c.force && deps.kvGet) { + try { + const kvMax = await deps.kvGet(`chatmax:${c.chatId}`); + if (kvMax !== null && BigInt(kvMax) <= after) skipped = true; + } catch { + // fall through to Neon + } + } + if (skipped) { + return { + frame: { + t: "bfd", + chatId: c.chatId, + messages: [], + upTo: c.after, + more: false, + }, + skipped: true, + rows: 0, + }; + } + + // Membership-gated read (§2). Fetch PAGE+1 to detect a further page. + const rows = await deps.messagesSince( + c.chatId, + userId, + after, + BACKFILL_PAGE_SIZE + 1, + ); + const more = rows.length > BACKFILL_PAGE_SIZE; + const served = more ? rows.slice(0, BACKFILL_PAGE_SIZE) : rows; + // upTo advances the cursor even on an empty page (§5) → no refetch-loop wedge. + const upTo = + served.length > 0 + ? served[served.length - 1]!.serverSerial.toString() + : c.after; + + return { + frame: { + t: "bfd", + chatId: c.chatId, + messages: served.map((r) => ({ + serverMsgId: r.serverSerial.toString(), + senderId: r.senderId, + ciphertext: r.ciphertext, + nonce: r.nonce, + ts: r.createdAt.getTime(), + })), + upTo, + more, + }, + skipped: false, + rows: served.length, + }; +} diff --git a/services/chat-ws/src/durable/user-inbox.ts b/services/chat-ws/src/durable/user-inbox.ts index fba1b83..57b25fd 100644 --- a/services/chat-ws/src/durable/user-inbox.ts +++ b/services/chat-ws/src/durable/user-inbox.ts @@ -9,6 +9,7 @@ import { import { getPersistClient } from "../persist"; import { validateBackfillFrame, validateSendFrame } from "../validators"; +import { resolveBackfillPage } from "./backfill"; // Per-user inbox DO. Holds 1..N device WebSocket connections for a single user // and acts as the routing point between devices and Chat DOs the user is a @@ -37,10 +38,6 @@ interface SocketAttachment { const SUB_KEY = "subscriptions"; -// Backfill page size (docs/message-backfill.md). Server fetches PAGE_SIZE+1 to -// detect whether another page exists; drop to 50–100 if ciphertexts grow large. -const BACKFILL_PAGE_SIZE = 200; - type MsgFrame = Extract; type AckFrame = Extract; type MsgPayload = Omit; @@ -265,62 +262,18 @@ export class UserInbox extends DurableObject { userId: string, c: { chatId: string; after: string; force?: boolean }, ): Promise> { - const after = BigInt(c.after); - - // KV skip: the client's cursor already covers the chat's max serial → no - // Neon, no rows. Safe by the core invariant — a stale-low/missing KV entry - // only delays catch-up (the next send rewrites chatmax and the full-range - // query returns the gap). A fresh login sets force:true to bypass it. - let skipped = false; - if (!c.force && this.env.CHAT_MAX_CACHE) { - try { - const kvMax = await this.env.CHAT_MAX_CACHE.get(`chatmax:${c.chatId}`); - if (kvMax !== null && BigInt(kvMax) <= after) skipped = true; - } catch { - // KV read failed — fall through to Neon (never skip on uncertainty). - } - } - if (skipped) { - this.logBackfill(c.chatId, true, 0); - return { - t: "bfd", - chatId: c.chatId, - messages: [], - upTo: c.after, - more: false, - }; - } - - // Membership-gated read. Fetch PAGE+1 to detect a further page. - const rows = await db.messagesSince( - c.chatId, + const cache = this.env.CHAT_MAX_CACHE; + const { frame, skipped, rows } = await resolveBackfillPage( + { + kvGet: cache ? (key) => cache.get(key) : null, + messagesSince: (chatId, uid, after, limit) => + db.messagesSince(chatId, uid, after, limit), + }, userId, - after, - BACKFILL_PAGE_SIZE + 1, + c, ); - const more = rows.length > BACKFILL_PAGE_SIZE; - const served = more ? rows.slice(0, BACKFILL_PAGE_SIZE) : rows; - // upTo advances the cursor even on an empty page (or undecryptable rows on - // the client) → no refetch-loop wedge. - const upTo = - served.length > 0 - ? served[served.length - 1]!.serverSerial.toString() - : c.after; - this.logBackfill(c.chatId, false, served.length); - - return { - t: "bfd", - chatId: c.chatId, - messages: served.map((r) => ({ - serverMsgId: r.serverSerial.toString(), - senderId: r.senderId, - ciphertext: r.ciphertext, - nonce: r.nonce, - ts: r.createdAt.getTime(), - })), - upTo, - more, - }; + this.logBackfill(c.chatId, skipped, rows); + return frame; } // Per-chat backfill tally for a wrangler-tail run (acceptance: warm reconnect diff --git a/services/chat-ws/vitest.config.ts b/services/chat-ws/vitest.config.ts new file mode 100644 index 0000000..d8a8727 --- /dev/null +++ b/services/chat-ws/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + // Pure-logic units only: the backfill KV-skip / paging / cursor rules + // (docs/message-backfill.md, ADR-0020), exercised through injected fakes for + // the KV and Neon seams — no Durable Object runtime, no Neon round-trip. + // End-to-end DO behaviour is covered by the Maestro offline-catch-up flow. + include: ["src/**/*.test.ts"], + environment: "node", + }, +}); From 70b2a6af4563a2e1e91a7762dd86117d5e2a61a4 Mon Sep 17 00:00:00 2001 From: Alex Moreton Date: Mon, 6 Jul 2026 18:08:05 +0100 Subject: [PATCH 5/5] Update apns and fcm + bump deploy version --- apps/mobile/app.json | 3 ++- apps/mobile/google-services.json | 46 ++++++++++++++++++++++++++++++++ infra/stacks/chat-ws.ts | 4 +-- 3 files changed, 50 insertions(+), 3 deletions(-) create mode 100644 apps/mobile/google-services.json diff --git a/apps/mobile/app.json b/apps/mobile/app.json index 1cc69ae..9a300ca 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -28,7 +28,8 @@ "foregroundImage": "./assets/adaptive-icon.png", "backgroundColor": "#000000" }, - "package": "io.sessions.app" + "package": "io.sessions.app", + "googleServicesFile": "./google-services.json" }, "plugins": [ "expo-router", diff --git a/apps/mobile/google-services.json b/apps/mobile/google-services.json new file mode 100644 index 0000000..fe33e32 --- /dev/null +++ b/apps/mobile/google-services.json @@ -0,0 +1,46 @@ +{ + "project_info": { + "project_number": "126132110239", + "project_id": "sessions-dev-96fd0", + "storage_bucket": "sessions-dev-96fd0.firebasestorage.app" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:126132110239:android:fda8152291630d9a8033aa", + "android_client_info": { + "package_name": "io.sessions.app" + } + }, + "oauth_client": [ + { + "client_id": "126132110239-179kfjdtq3b9u5p62sve6bm9agtcnpa7.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyDlQv42C_Ht3opaEvNp6yPSqGDhr34ljGA" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "126132110239-179kfjdtq3b9u5p62sve6bm9agtcnpa7.apps.googleusercontent.com", + "client_type": 3 + }, + { + "client_id": "126132110239-cepnoht7hnhnq3ollbphkf2laccjs32t.apps.googleusercontent.com", + "client_type": 2, + "ios_info": { + "bundle_id": "io.sessions.app" + } + } + ] + } + } + } + ], + "configuration_version": "1" +} diff --git a/infra/stacks/chat-ws.ts b/infra/stacks/chat-ws.ts index b71e579..7d42d87 100644 --- a/infra/stacks/chat-ws.ts +++ b/infra/stacks/chat-ws.ts @@ -167,8 +167,8 @@ export const chatWsWorker = new sst.cloudflare.Worker("ChatWs", { // ⚠ PRECONDITION — deployed tag. VERIFY with `sst diff` before apply; the // oldTag MUST equal the currently deployed tag (error 10079 otherwise). args.migrations = { - oldTag: "v9", - newTag: "v10", + oldTag: "v10", + newTag: "v11", }; // Compatibility date with WebSocket auto-reply-to-close behaviour