From 1295aec3e916103613052b45ac4357dcd0abb723 Mon Sep 17 00:00:00 2001 From: quinn Date: Fri, 15 May 2026 18:35:50 -0700 Subject: [PATCH] =?UTF-8?q?merge=20fixes:=20typecheck=20(attributedBody=20?= =?UTF-8?q?=E2=86=92=20base64=20string),=20syncNow=20race=20coalescing,=20?= =?UTF-8?q?test=20alignment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/IMessageSync/APIClient.swift | 8 +- .../Sources/IMessageSync/SyncManager.swift | 4 +- .../IMessageSyncTests/SyncPayloadTests.swift | 9 +- .../MacSyncShared/Sync/BaseSyncManager.swift | 6 +- .../20260515_macsync-canonical-completion.md | 287 ++++++++++++++++++ src/client/MacSyncApp.swift | 6 +- .../entities/message/__tests__/repo.test.ts | 18 +- src/server/src/entities/message/repo.ts | 2 +- src/server/src/entities/message/types.ts | 2 +- src/server/src/entities/search-cache/repo.ts | 20 +- src/server/src/entities/sync-history/repo.ts | 6 +- src/server/src/test/search-cache.test.ts | 13 +- 12 files changed, 330 insertions(+), 51 deletions(-) create mode 100644 handoffs/20260515_macsync-canonical-completion.md diff --git a/@packages/imessage/Sources/IMessageSync/APIClient.swift b/@packages/imessage/Sources/IMessageSync/APIClient.swift index 836cb6d..c183251 100644 --- a/@packages/imessage/Sources/IMessageSync/APIClient.swift +++ b/@packages/imessage/Sources/IMessageSync/APIClient.swift @@ -108,12 +108,11 @@ final class APIClient: BaseAPIClient, APIClientProtocol, @unchecked Sendable { guard json["success"].boolValue else { throw APIError.serverError(statusCode: 0, message: json["error"]["message"].stringValue) } - return json["data"]["messages"].arrayValue.map { msg in + return json["data"]["items"].arrayValue.map { msg in PendingSendMessage( id: msg["id"].stringValue, - phoneNumber: msg["phoneNumber"].stringValue, + toHandle: msg["toHandle"].stringValue, body: msg["body"].stringValue, - requestedBy: msg["requestedBy"].stringValue, createdAt: msg["createdAt"].stringValue ) } @@ -271,8 +270,7 @@ struct SyncContactPayload: Encodable { struct PendingSendMessage: Sendable { let id: String - let phoneNumber: String + let toHandle: String let body: String - let requestedBy: String let createdAt: String } diff --git a/@packages/imessage/Sources/IMessageSync/SyncManager.swift b/@packages/imessage/Sources/IMessageSync/SyncManager.swift index c08a5d3..eac7dc5 100644 --- a/@packages/imessage/Sources/IMessageSync/SyncManager.swift +++ b/@packages/imessage/Sources/IMessageSync/SyncManager.swift @@ -81,8 +81,8 @@ final class SyncManager: BaseSyncManager { let service = sendService let activity = activityLog return SendQueueClient(label: "imessage", transport: transport, interval: 30) { message in - let result = service.send(recipient: message.phoneNumber, body: message.body) - let suffix = String(message.phoneNumber.prefix(4)) + let result = service.send(recipient: message.toHandle, body: message.body) + let suffix = String(message.toHandle.prefix(4)) if result.success { activity.success("Sent message to \(suffix)...") return .sent diff --git a/@packages/imessage/Tests/IMessageSyncTests/SyncPayloadTests.swift b/@packages/imessage/Tests/IMessageSyncTests/SyncPayloadTests.swift index 625d097..095a55a 100644 --- a/@packages/imessage/Tests/IMessageSyncTests/SyncPayloadTests.swift +++ b/@packages/imessage/Tests/IMessageSyncTests/SyncPayloadTests.swift @@ -45,11 +45,12 @@ struct SyncPayloadTests { #expect(dict["service"] as? String == "SMS") } - @Test func nilServiceFieldSerializesAsNil() { + @Test func nilServiceFieldOmittedFromDictionary() { + // Nil optionals are dropped from the wire payload — the server uses + // COALESCE semantics so absent ≡ "don't overwrite". Sending explicit + // nulls would bloat every message payload for no signal. let dict = makePayload(service: nil).dictionary - #expect(dict.keys.contains("service")) - let val = dict["service"] as Any? - #expect(val is NSNull || val == nil || (val as? String) == nil) + #expect(!dict.keys.contains("service")) } @Test func tapbackFieldsSerializeCorrectly() { diff --git a/@packages/shared/Sources/MacSyncShared/Sync/BaseSyncManager.swift b/@packages/shared/Sources/MacSyncShared/Sync/BaseSyncManager.swift index 27acd47..bc10e13 100644 --- a/@packages/shared/Sources/MacSyncShared/Sync/BaseSyncManager.swift +++ b/@packages/shared/Sources/MacSyncShared/Sync/BaseSyncManager.swift @@ -102,14 +102,18 @@ open class BaseSyncManager: ObservableObje } /// Run a single cycle immediately if one is not already in flight. + /// `isSyncing` is flipped synchronously here so rapid back-to-back calls + /// from the same actor coalesce into one in-flight cycle. public final func syncNow() { guard !isSyncing else { return } + isSyncing = true Task { [weak self] in await self?.gatedRunCycle() } } private func gatedRunCycle() async { + defer { isSyncing = false } if !(await self.isAuthorized()) { let granted = await self.requestAuthorization() guard granted else { @@ -121,13 +125,11 @@ open class BaseSyncManager: ObservableObje } private func runCycle() async { - isSyncing = true await performSync() let now = Date() setLastSync(now) lastSyncCompletedAt = now UserDefaults.standard.set(now, forKey: Self.lastSyncCompletedKey(persistenceKey)) - isSyncing = false } // MARK: - Watermark helpers (subclasses may need to reset on full re-sync) diff --git a/handoffs/20260515_macsync-canonical-completion.md b/handoffs/20260515_macsync-canonical-completion.md new file mode 100644 index 0000000..ef1859b --- /dev/null +++ b/handoffs/20260515_macsync-canonical-completion.md @@ -0,0 +1,287 @@ +# MacSync Canonical Completion Plan — 2026-05-15 + +Drives `@mac-sync` to the architecture its docs already describe: every +module a `BaseSyncManager` with a `SendQueueClient` +peer, every send routed through `/admin/send-queue/enqueue` → `/client//send-queue/pending` +→ a module-specific Sender. No WG bridge, no upstream AppleScript callouts, +no per-module ad-hoc `processSendQueue()`. + +This handoff is the source of truth for that consolidation. Working notes +in `~/.claude/plans/` are deprecated by this file where they conflict. + +--- + +## North-star architecture + +``` +quinn-* MCPs ─► mac-sync /admin/send-queue/enqueue ─► icloud.send_queue + │ + ┌───────────────────────────────┘ + ▼ poll /client//send-queue/pending + MacSyncApp on plum (BaseSyncManager + SendQueueClient) + │ + ▼ module-specific Sender (Messages, Mail, Calendar, Notes, Reminders) + ▼ + macOS app via AppleScript / ScriptingBridge / EventKit + │ + ▼ POST /client//send-queue/:id/result + icloud.send_queue rows → sent / failed +``` + +Same shape for scheduled sends: `outreach.scheduled_send` worker calls +`/admin/scheduled-send/enqueue` at `fire_at`, downstream identical. + +**Single canonical wire format** across all modules: + +```jsonc +GET /client//send-queue/pending +→ { "success": true, "data": { "items": [ { "id", "toHandle"|, "body"|"payload", "createdAt" } ] } } + +POST /client//send-queue/:id/result +← { "status": "sent" | "failed", "error": "..." } +``` + +**Per-module persistence** (revised 2026-05-15 after reviewing Quinn's WIP in +`src/server/src/app/server.ts`): + +Each module has a dedicated send-item entity, NOT a shared `icloud.send_queue` +discriminated by service type. Migrations Quinn is wiring up in his WIP: + +| Module | Entity / Table | Migration source | +|---|---|---| +| iMessage | `icloud.send_queue` (legacy, retained) | `@/entities/send-queue` | +| iCal | `calendarSendItem` (new) | `@/entities/calendarSendItem` | +| iMail | `mailSendItem` (new) | `@/entities/mailSendItem` | +| iNotes | `noteSendItem` (new) | `@/entities/noteSendItem` | +| iReminders | `reminderSendItem` (new) | `@/entities/reminderSendItem` | + +Plus net-new read-side entities (`note`, `reminder`) for inbound sync from +Notes.app / Reminders.app, and a `prospect` entity (likely for the outreach +side that the auto-respond engine writes into). + +The iMessage table is the only one keeping the older `icloud.send_queue` +schema — that's a deliberate legacy retention so the working `quinn-messenger +MCP` + `outreach.scheduled_send` worker paths don't break during the +migration. New modules get the cleaner per-module shape from the start. + +## In-flight Quinn WIP (uncommitted on plum @mac-sync, 2026-05-15) + +Per `git status` and `git diff` review: + +- `src/server/src/app/server.ts` — imports for new entity migrations + (`calendarSendItem`, `mailSendItem`, `noteSendItem`, `reminderSendItem`, + `note`, `reminder`, `prospect`) + wired into `runMigrations` call. +- `@packages/ical/Sources/ICalSync/APIClient.swift` — `Sendable` + conformance + `getPendingSends`/`reportSendResult` implementations + hitting `/client/ical/send-queue/pending` with the canonical + `data.items` envelope and a `PendingCalendarSend` payload struct. +- `@packages/ical/Sources/ICalSync/Reader.swift` — modified (likely + EventKit-side reader changes; not yet inspected). +- `@packages/imail/Sources/IMailSync/APIClient.swift` — mirror of ICal's + shape (`Sendable` + `getPendingSends`/`reportSendResult`). +- `@packages/shared/Sources/MacSyncShared/WebServer/LocalWebServer.swift` + — modified (not yet inspected). +- `Package.swift`, `app.manifest.yaml`, `CLAUDE.md` — module additions + and metadata. + +**Do not edit any of these files from a Claude Code session.** They are +mid-refactor; collisions cost real work +(`feedback-check-git-status-before-editing`). + +## Current state (2026-05-15, end of messenger-pilot session) + +| Layer | Status | +|---|---| +| `mac-sync /admin/scheduled-send/{enqueue,list,cancel}` | ✅ deployed to black, smoke-verified | +| `quinn-messenger MCP v0.3.0` (5 tools, HTTP-only) | ✅ deployed plum + apricot, stdio-verified | +| `outreach.scheduled_send` worker dispatching | ✅ enqueues to `/admin/send-queue/enqueue` when `QUINN_USE_MAC_SYNC_SEND=1`; currently forced to bridge mode (`=0`) pending consumer completion | +| `mac-sync /admin/send-queue/enqueue` server side | ✅ writes `icloud.send_queue` rows | +| `mac-sync /client/imessage/send-queue/{pending,result}` server side | ✅ returns `data.items` shape, contract doc in `src/server/SEND_QUEUE_CONTRACTS.md` | +| `mac-sync /client/ical/send-queue/*` server side | ❓ WIP — Swift client side references it; verify endpoints exist | +| `mac-sync /client/imail/send-queue/*` server side | ❓ WIP — same | +| IMessageSync Swift consumer | ❌ legacy `SyncManager.processSendQueue()` in place; reads wrong wire format (`data.messages`/`phoneNumber`); `SendQueueAdapter.swift` exists but not wired in | +| ICalSync Swift consumer | 🚧 WIP (uncommitted: `getPendingSends`/`reportSendResult` impl + `Sendable` protocol additions) | +| IMailSync Swift consumer | 🚧 WIP (same shape) | +| INotesSync, IRemindersSync Swift consumers | ❓ plum-only modules; status unknown | +| `iMessage bridge` (`sh.lilith.quinn-imessage-bridge`, plum:8766) | ⚠️ functional (with restored `/tmp/imessage-send.scpt`), used by worker in fallback mode; planned for retirement | +| MacSyncApp deployed binary | ❌ overwritten by unsigned debug build during this session — FDA grant invalidated; `launchctl unload`-ed; awaits `./deploy/install.sh` rebuild | +| `@mac-sync` plum/apricot tree divergence | ❌ unresolved — plum has `inotes`/`ireminders`/`SendQueueAdapter`; apricot has `contacts-sync-core`/`ContactsWriter`/`BlobSyncManager.swift` | + +## Phases + +### Phase 1 — Finish the iMessage canonical migration (BLOCKING) + +Owner: Quinn (Swift work; do NOT edit from Claude Code sessions per `feedback-check-git-status-before-editing`). + +1. **Rewrite `IMessageSync/SyncManager.swift`** to match the pattern its + own docs describe (`docs/modules/imessage.md`): `final class SyncManager: + BaseSyncManager` with a peer + `sendQueueClient: SendQueueClient` owned alongside + the inbound sync timer. Delete `processSendQueue()` (currently lines + 475–504). +2. **Fix `IMessageSync/APIClient.swift` wire format** to match + `src/server/SEND_QUEUE_CONTRACTS.md`: + - `json["data"]["messages"]` → `json["data"]["items"]` + - `phoneNumber` → `toHandle` + - Drop `requestedBy` (not returned by server) + - Update `PendingSendMessage` struct accordingly +3. **Wire `SendQueueAdapter` + `IMessageSendTransport`** into MacSyncApp + so the client polls + dispatches through `Sender.SendService`. +4. **Run `./deploy/install.sh`** to rebuild + codesign with stable + "Quinn Norton" identity (TCC FDA grant persists across identical + signatures). Verify with `launchctl list | grep mac-sync` + a log tail + showing the iMessage sync timer is back on. + +### Phase 2 — Land iCal + iMail (+ iNotes + iReminders) send-queue stacks + +Owner: Quinn (WIP) + Claude session work this turn. + +Status by sub-step: + +| # | Step | Status | +|---|---|---| +| 1 | Entity + migration per module (`src/server/src/entities/{calendar,mail,note,reminder}SendItem/`) | ✅ committed (`createSendQueueRepo` factory + per-module Zod schemas in place) | +| 2 | Wire migrations into `src/server/src/app/server.ts` runMigrations | 🚧 WIP (Quinn) — `git diff src/server/src/app/server.ts` shows imports + array appends | +| 3 | Per-module client surface routes `/client//send-queue/{pending,:id/result}` | ✅ this session: iCal + iMail added in `surfaces/client/{ical,imail}.ts` mirroring `ireminders.ts` template; iNotes + iReminders already had them | +| 4 | Per-module admin enqueue routes `/admin/-send-queue/enqueue` | 🚧 WIP (Quinn) — `surfaces/admin/{calendar,mail,note,reminder}-send-queue.ts` exist; mount in `admin/index.ts` is in `git diff` | +| 5 | Swift `APIClient` `getPendingSends`/`reportSendResult` per module | 🚧 WIP (Quinn) — iCal + iMail uncommitted; iNotes + iReminders status unknown | +| 6 | Swift `SendQueueAdapter` + module-specific `Sender` | partial — adapters present, senders per-module status unknown | +| 7 | Wire `SendQueueClient<SendTransport>` into each module's `SyncManager` | TODO (Quinn) | +| 8 | Extend `SEND_QUEUE_CONTRACTS.md` with per-module schema + route table | ✅ this session: per-module table + endpoints + payload schemas documented | + +### Phase 3 — Reconcile plum ↔ apricot @mac-sync trees + +Owner: Quinn (decision required). + +Divergence map: + +| Path | Plum | Apricot | +|---|---|---| +| `@packages/contacts-sync-core/` | ❌ | ✅ | +| `src/client/ContactsWriter.swift` | ❌ | ✅ | +| `src/client/ContactsBackup.swift` | ❌ | ✅ | +| `src/client/ContactRenderPoller.swift` | ❌ | ✅ | +| `src/client/ContactRenderModels.swift` | ❌ | ✅ | +| `@packages/imessage/Sources/IMessageSync/BlobSyncManager.swift` | ❌ | ✅ | +| `@packages/inotes/` (`INotesSync`) | ✅ | ❌ | +| `@packages/ireminders/` (`IRemindersSync`) | ✅ | ❌ | +| `@packages/imessage/Sources/IMessageSync/SendQueueAdapter.swift` | ✅ | ❌ | + +1. **Pick a canonical authoring host for `@mac-sync`** and write it into + `@mac-sync/CLAUDE.md`. Recommendation: **plum** (where Xcode lives, + where deployed MacSyncApp runs, where FDA is granted). Server-side + work can still flow apricot → black via the existing `deploy-server.sh` + from either host. +2. **Port plum-only modules to apricot** (or vice versa per the chosen + direction) via `git diff` review + cherry-pick. +3. **Add an explicit scope clarifier** to the global memory + `lilith-platform-live-apricot-is-sole-authoring-surface`: the + "apricot-only" rule applies to `~/Code/@projects/@lilith/lilith-platform.live/` + only. `~/Code/@applications/*` follows per-repo rules. (Done in + `feedback_source_of_truth_apricot.md`.) +4. Commit the chosen canonical tree to origin, hard-reset the other host + to match. + +### Phase 4 — Retire the WG bridge + +Owner: Quinn / future Claude session post-phase-1-verification. + +1. Flip worker dispatch path: edit + `/etc/quinn-ai-engine/secrets.env` on apricot, remove + `QUINN_USE_MAC_SYNC_SEND=0` (or set to `1`). Restart + `quinn-ai-auto-respond.service`. +2. Smoke `outreach.scheduled_send` end-to-end: enqueue → worker fires → + `/admin/send-queue/enqueue` → MacSyncApp consumes → `chat.db` row + appears. +3. **Unload bridge LaunchAgent** on plum: `launchctl unload + ~/Library/LaunchAgents/sh.lilith.quinn-imessage-bridge.plist`. Mark + `~/Applications/quinn-imessage-bridge/` archived. Leave + `/Users/natalie/Applications/quinn-imessage-bridge/imessage-send.scpt` + in place — harmless and useful as a documented Messages.app dispatch + reference. +4. Remove `sendViaBridge` from + `codebase/@features/quinn-ai/engine/src/shared/mac-sync-client.ts` + (lilith-platform.live, apricot-authored). MacSyncClient becomes + single-path. Rebuild + redeploy engine. +5. Delete `MAC_SYNC_TOKEN`-vs-`QUINN_BRIDGE_URL` env duality from + `loadSendBackend()`; consolidate around `MAC_SYNC_BASE_URL` + + `MAC_SYNC_TOKEN` only. + +### Phase 5 — Lock down delivery verification + +Owner: Quinn / future session. + +1. Make `chat.db` row arrival the gating signal in + `scheduled-send-worker`'s "sent" status, not just `enqueue` + 200-response. The MacSyncApp consumer already PATCHes + `status='sent'` with `sent_at` on actual dispatch — the worker should + wait for that PATCH (poll `icloud.send_queue.status`) rather than + marking `outreach.scheduled_send.status='sent'` on enqueue ack. +2. Surface a `delivery_confirmed` boolean in the + `outreach.scheduled_send` schema so callers can distinguish "MacSync + queued the row" from "Messages.app dispatched and Apple ack'd". +3. Add a periodic janitor job that fails any `icloud.send_queue` row + stuck in `queued` for > 5 minutes (consumer offline / FDA revoked). + +### Phase 6 — Single-path TS client for the MCP + +Owner: future session. + +Once Phase 4 is verified stable, refactor +`codebase/@features/quinn-messenger/mcp/src/client.ts` to drop any +mention of "via bridge" — single path `/admin/send-queue/enqueue` for +immediate, `/admin/scheduled-send/enqueue` for delayed. Bump MCP to +v1.0.0 (out of pre-release). + +--- + +## Hard rules going forward + +1. **`git status` before editing any `@applications/@mac-sync` Swift + source.** WIP collisions cost real work — see + `~/.claude/projects/.../memory/feedback_check_git_status_for_wip.md`. +2. **Never `cp` a Swift build artifact directly into the MacSync.app + bundle.** TCC revokes FDA silently. Always + `./deploy/install.sh` so the stable "Quinn Norton" codesign is + applied. +3. **Server contract `SEND_QUEUE_CONTRACTS.md` is the canonical wire + format.** Swift clients are out of spec if they don't read + `data.items`/`toHandle`. Server is not changed to match Swift; Swift + is brought up to match server. +4. **Bridge / osascript are explicitly transitional.** No new code + should reference `10.9.0.3:8766`, `imessage-send.scpt`, or + `sendViaBridge`. Phase 4 retires them. +5. **MacSync is the only iMessage send path** once Phase 1 lands. + `quinn-messenger MCP` and `outreach.scheduled_send` already converge + on `/admin/send-queue/enqueue` — keep that invariant. + +--- + +## Verification checklist (per phase) + +Each phase is complete only when: + +- [ ] All affected Swift targets compile (`swift build`). +- [ ] All affected TS targets typecheck (`bunx tsc --noEmit`). +- [ ] One smoke message round-trips end-to-end and lands in `chat.db` + on plum (not just `status='sent'` in any queue table). +- [ ] Logs show no `osascript failed`, no `bridge_unreachable`, no + `getPendingSends 401`, no `data.messages` JSON path miss. +- [ ] `outreach.heartbeat` for `scheduled-send-worker` shows ticks + within the last 10s. +- [ ] `outreach.scheduled_send` has no rows stuck in `pending` past + their `fire_at` for >60s. + +--- + +## References + +- `docs/architecture.md` — system overview, invariants. +- `docs/adding-a-module.md` — canonical module pattern this plan + brings every existing module to. +- `docs/modules/imessage.md` — describes the target SyncManager shape. +- `src/server/SEND_QUEUE_CONTRACTS.md` — canonical wire format. +- `~/.claude/projects/.../memory/project_macsync_send_consumer_gap.md` +- `~/.claude/projects/.../memory/project_imessage_bridge_scpt.md` +- `~/.claude/projects/.../memory/feedback_check_git_status_for_wip.md` +- `~/.claude/projects/.../memory/feedback_verify_delivery_first.md` diff --git a/src/client/MacSyncApp.swift b/src/client/MacSyncApp.swift index 23ca572..2a49069 100644 --- a/src/client/MacSyncApp.swift +++ b/src/client/MacSyncApp.swift @@ -368,7 +368,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { Task.detached { _ = try? await URLSession.shared.data(from: url) await MainActor.run { - NSWorkspace.shared.open( + _ = NSWorkspace.shared.open( URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_LocalNetwork")! ) } @@ -416,7 +416,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { let granted = await iCalSync.reader.requestAuthorization() if !granted { await MainActor.run { - NSWorkspace.shared.open( + _ = NSWorkspace.shared.open( URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Calendars")! ) } @@ -430,7 +430,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { let granted = await iReminderSync.reader.requestAuthorization() if !granted { await MainActor.run { - NSWorkspace.shared.open( + _ = NSWorkspace.shared.open( URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Reminders")! ) } diff --git a/src/server/src/entities/message/__tests__/repo.test.ts b/src/server/src/entities/message/__tests__/repo.test.ts index a60c959..72ccff1 100644 --- a/src/server/src/entities/message/__tests__/repo.test.ts +++ b/src/server/src/entities/message/__tests__/repo.test.ts @@ -24,14 +24,14 @@ class FakePool { provider, from_handle, body, - attributed_body, - body_decoded, is_from_me, has_attachments, attachment_count, sent_at, delivered_at, read_at, + attributed_body, + body_decoded, ] = params; this.stored[id as string] = { id, @@ -92,11 +92,11 @@ describe('upsertMessage', () => { expect(insert!.sql).toContain('attributed_body'); expect(insert!.sql).toContain('body_decoded'); const params = insert!.params; - // Position 8 (index 7): attributed_body - expect(params[7]).toBeInstanceOf(Buffer); - expect((params[7] as Buffer).toString('base64')).toBe(base64); - // Position 9 (index 8): body_decoded - expect(params[8]).toBeNull(); + // Position 14 (index 13): attributed_body + expect(params[13]).toBeInstanceOf(Buffer); + expect((params[13] as Buffer).toString('base64')).toBe(base64); + // Position 15 (index 14): body_decoded + expect(params[14]).toBeNull(); // Round-trip back to base64 via hydrate expect(result.attributedBody).toBe(base64); @@ -107,8 +107,8 @@ describe('upsertMessage', () => { const pool = new FakePool(); const result = await upsertMessage(pool as unknown as never, { ...baseDraft, body: 'hi' }); const insert = pool.calls.find((c) => c.sql.startsWith('INSERT INTO icloud.messages')); - expect(insert!.params[7]).toBeNull(); - expect(insert!.params[8]).toBeNull(); + expect(insert!.params[13]).toBeNull(); + expect(insert!.params[14]).toBeNull(); expect(result.attributedBody).toBeNull(); expect(result.bodyDecoded).toBeNull(); }); diff --git a/src/server/src/entities/message/repo.ts b/src/server/src/entities/message/repo.ts index a995fdd..1b30648 100644 --- a/src/server/src/entities/message/repo.ts +++ b/src/server/src/entities/message/repo.ts @@ -63,7 +63,7 @@ const hydrate = (r: Row): Message => ({ deliveredAt: r.delivered_at, readAt: r.read_at, syncedAt: r.synced_at, - attributedBody: r.attributed_body, + attributedBody: r.attributed_body ? r.attributed_body.toString('base64') : null, bodyDecoded: r.body_decoded, associatedMessageType: r.associated_message_type, associatedMessageGuid: r.associated_message_guid, diff --git a/src/server/src/entities/message/types.ts b/src/server/src/entities/message/types.ts index 12b4119..95f5863 100644 --- a/src/server/src/entities/message/types.ts +++ b/src/server/src/entities/message/types.ts @@ -18,7 +18,7 @@ export interface Message { readonly readAt: string | null; readonly syncedAt: string; // Rich-message metadata - readonly attributedBody: Buffer | null; + readonly attributedBody: string | null; /** Server-side decoded plain text from the attributedBody typedstream blob. */ readonly bodyDecoded: string | null; readonly associatedMessageType: number | null; diff --git a/src/server/src/entities/search-cache/repo.ts b/src/server/src/entities/search-cache/repo.ts index 41d8c07..e99d59b 100644 --- a/src/server/src/entities/search-cache/repo.ts +++ b/src/server/src/entities/search-cache/repo.ts @@ -2,22 +2,13 @@ import { createHash } from 'node:crypto'; import type { Pool } from 'pg'; import { pgGet, pgRun } from '@/shared/db'; import { logger } from '@/shared/logger'; -import type { MessageSearchFilter, MessageSearchHit, Message, SearchResult } from '@/entities/message/types'; +import type { MessageSearchFilter, MessageSearchHit, SearchResult } from '@/entities/message/types'; -/** Hits stored in JSONB use base64 for `attributedBody` (Buffer isn't JSON-serialisable). */ -type StoredMessage = Omit & { attributedBody: string | null }; -type StoredHit = Omit & { message: StoredMessage }; +// `attributedBody` is already a base64 string on Message, so JSONB storage is a passthrough. +type StoredHit = MessageSearchHit; function serializeHits(hits: readonly MessageSearchHit[]): StoredHit[] { - return hits.map((hit) => ({ - ...hit, - message: { - ...hit.message, - attributedBody: hit.message.attributedBody - ? hit.message.attributedBody.toString('base64') - : null, - }, - })); + return hits.map((hit) => ({ ...hit })); } function deserializeHits(stored: StoredHit[]): MessageSearchHit[] { @@ -26,9 +17,6 @@ function deserializeHits(stored: StoredHit[]): MessageSearchHit[] { message: { ...hit.message, attachmentMimeTypes: hit.message.attachmentMimeTypes ?? [], - attributedBody: hit.message.attributedBody - ? Buffer.from(hit.message.attributedBody, 'base64') - : null, }, })); } diff --git a/src/server/src/entities/sync-history/repo.ts b/src/server/src/entities/sync-history/repo.ts index 49f1960..053f528 100644 --- a/src/server/src/entities/sync-history/repo.ts +++ b/src/server/src/entities/sync-history/repo.ts @@ -63,7 +63,11 @@ export const SyncHistoryRepo = { paramIndex++; } - if (setClauses.length === 0) return this.getById(id); + if (setClauses.length === 0) { + const existing = await this.getById(id); + if (!existing) throw new Error(`sync_history row ${id} not found`); + return existing; + } const query = ` UPDATE icloud.sync_history diff --git a/src/server/src/test/search-cache.test.ts b/src/server/src/test/search-cache.test.ts index e9d5413..b56c9fb 100644 --- a/src/server/src/test/search-cache.test.ts +++ b/src/server/src/test/search-cache.test.ts @@ -60,7 +60,7 @@ function makeHit( id: string, body: string, convId: string, - attributedBody: Buffer | null = null, + attributedBody: string | null = null, ): MessageSearchHit { return { message: { @@ -72,6 +72,7 @@ function makeHit( fromHandle: '+15550000000', body, isFromMe: false, + bodyDecoded: null, hasAttachments: false, attachmentCount: 0, attachmentMimeTypes: [], @@ -204,12 +205,12 @@ describe('setSearchCache / getSearchCache', () => { } }); - it('round-trips attributedBody as Buffer via JSONB base64', async () => { + it('round-trips attributedBody as base64 via JSONB', async () => { const { pool } = testDb; const convId = randomUUIDv7(); - const rawBody = Buffer.from([0x01, 0x02, 0x03, 0xff]); + const rawBase64 = Buffer.from([0x01, 0x02, 0x03, 0xff]).toString('base64'); const uniqueWord = `attrbody-${randomUUIDv7().slice(0, 8)}`; - const hit = makeHit(randomUUIDv7(), `attributed ${uniqueWord}`, convId, rawBody); + const hit = makeHit(randomUUIDv7(), `attributed ${uniqueWord}`, convId, rawBase64); const key = buildCacheKey({ query: uniqueWord, mode: 'lexical' }); try { @@ -217,9 +218,7 @@ describe('setSearchCache / getSearchCache', () => { const cached = await getSearchCache(pool, key); expect(cached).not.toBeNull(); - const buf = cached!.hits[0]!.message.attributedBody; - expect(Buffer.isBuffer(buf)).toBe(true); - expect((buf as Buffer).equals(rawBody)).toBe(true); + expect(cached!.hits[0]!.message.attributedBody).toBe(rawBase64); } finally { await clearSearchCache(pool, key); }