merge fixes: typecheck (attributedBody → base64 string), syncNow race coalescing, test alignment

This commit is contained in:
quinn 2026-05-15 18:35:50 -07:00
parent 8cc2c50fed
commit 1295aec3e9
12 changed files with 330 additions and 51 deletions

View file

@ -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
}

View file

@ -81,8 +81,8 @@ final class SyncManager: BaseSyncManager<SyncStats, SyncError> {
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

View file

@ -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() {

View file

@ -102,14 +102,18 @@ open class BaseSyncManager<Stats: Sendable, SyncError: Sendable>: 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<Stats: Sendable, SyncError: Sendable>: 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)

View file

@ -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<Stats, Error>` with a `SendQueueClient<Transport>`
peer, every send routed through `/admin/send-queue/enqueue``/client/<module>/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/<module>/send-queue/pending
MacSyncApp on plum (BaseSyncManager + SendQueueClient<Transport>)
▼ module-specific Sender (Messages, Mail, Calendar, Notes, Reminders)
macOS app via AppleScript / ScriptingBridge / EventKit
▼ POST /client/<module>/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/<module>/send-queue/pending
→ { "success": true, "data": { "items": [ { "id", "toHandle"|<module-fields>, "body"|"payload", "createdAt" } ] } }
POST /client/<module>/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<SyncStats, SyncError>` with a peer
`sendQueueClient: SendQueueClient<IMessageSendTransport>` owned alongside
the inbound sync timer. Delete `processSendQueue()` (currently lines
475504).
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/<module>/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/<module>-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<<Module>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`

View file

@ -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")!
)
}

View file

@ -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();
});

View file

@ -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,

View file

@ -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;

View file

@ -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<Message, 'attributedBody'> & { attributedBody: string | null };
type StoredHit = Omit<MessageSearchHit, 'message'> & { 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,
},
}));
}

View file

@ -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

View file

@ -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);
}