From c9ef65de69e66e12f4d4573229755c88ca0e3da5 Mon Sep 17 00:00:00 2001 From: LordMikkel <125906789+LordMikkel@users.noreply.github.com> Date: Wed, 1 Jul 2026 19:55:58 +0200 Subject: [PATCH 1/2] fix(resolve): correct persistence and cooldown state transitions --- backend/src/db/resolvesRepo.ts | 6 +++--- backend/src/resolve/routes.ts | 3 +-- backend/src/resolve/store.ts | 5 +++++ .../supabase/migrations/0001_fix_resolve_schema.sql | 12 ++++++++++++ backend/supabase/schema.sql | 5 ++--- 5 files changed, 23 insertions(+), 8 deletions(-) create mode 100644 backend/supabase/migrations/0001_fix_resolve_schema.sql diff --git a/backend/src/db/resolvesRepo.ts b/backend/src/db/resolvesRepo.ts index 5755914..0447f4c 100644 --- a/backend/src/db/resolvesRepo.ts +++ b/backend/src/db/resolvesRepo.ts @@ -28,12 +28,11 @@ export const resolvesRepo = { return Boolean(data); }, - async addWitness(targetSosId: string, witness: ResolveWitness, receiptId: string): Promise { + async addWitness(targetSosId: string, witness: ResolveWitness): Promise { if (!isSupabaseConfigured || !supabase) { return resolveStore.recordWitness(targetSosId, witness); } const row = { - receipt_id: receiptId, target_sos_id: targetSosId, device_id: witness.deviceId, pubkey: witness.pubkey, @@ -92,7 +91,7 @@ export const resolvesRepo = { }; const { error } = await supabase .from(RECEIPTS_TABLE) - .upsert(row, { onConflict: "id", ignoreDuplicates: false }); + .upsert(row, { onConflict: "target_sos_id", ignoreDuplicates: false }); if (error) throw error; }, @@ -121,6 +120,7 @@ export const resolvesRepo = { .select("target_sos_id") .eq("target_sos_author", authorDeviceId) .eq("status", "pending") + .not("cooldown_ends_at", "is", null) .neq("target_sos_id", excludeTargetId) .order("created_at", { ascending: false }) .limit(1) diff --git a/backend/src/resolve/routes.ts b/backend/src/resolve/routes.ts index 25eed8f..9e1868d 100644 --- a/backend/src/resolve/routes.ts +++ b/backend/src/resolve/routes.ts @@ -249,9 +249,8 @@ export async function resolveRoutes(fastify: FastifyInstance) { } // Persist each new witness. - const provisionalId = getResolveId(envelope); for (const w of newAccepted) { - await resolvesRepo.addWitness(envelope.targetSosId, w, provisionalId); + await resolvesRepo.addWitness(envelope.targetSosId, w); } const allWitnesses = await resolvesRepo.getWitnesses(envelope.targetSosId); diff --git a/backend/src/resolve/store.ts b/backend/src/resolve/store.ts index 49496ae..be41cc0 100644 --- a/backend/src/resolve/store.ts +++ b/backend/src/resolve/store.ts @@ -64,6 +64,11 @@ export const resolveStore = { upsertReceipt(receipt: ResolveReceipt): void { receiptsByTarget.set(receipt.targetSosId, receipt); receiptsById.set(receipt.id, receipt); + if (receipt.status === "pending" && typeof receipt.cooldownEndsAt === "number") { + this.markPendingClear(receipt.targetSosId, receipt.id, receipt.cooldownEndsAt); + } else { + pendingClears.delete(receipt.targetSosId); + } }, getReceiptById(id: string): ResolveReceipt | undefined { diff --git a/backend/supabase/migrations/0001_fix_resolve_schema.sql b/backend/supabase/migrations/0001_fix_resolve_schema.sql new file mode 100644 index 0000000..1a0476a --- /dev/null +++ b/backend/supabase/migrations/0001_fix_resolve_schema.sql @@ -0,0 +1,12 @@ +begin; + +alter table if exists public.resolve_witnesses + drop constraint if exists resolve_witnesses_receipt_id_fkey; + +alter table if exists public.resolve_witnesses + drop column if exists receipt_id; + +alter table if exists public.resolve_receipts + add constraint resolve_receipts_target_sos_id_unique unique (target_sos_id); + +commit; diff --git a/backend/supabase/schema.sql b/backend/supabase/schema.sql index f3759f6..e5f2b3e 100644 --- a/backend/supabase/schema.sql +++ b/backend/supabase/schema.sql @@ -58,7 +58,7 @@ alter table public.location_points enable row level security; create table if not exists public.resolve_receipts ( id text primary key, -- sha256(canonical + sorted witness deviceIds) - target_sos_id text not null, -- channel_records.id of the original SOS + target_sos_id text not null unique, -- channel_records.id of the original SOS (one receipt per target) target_sos_author text not null, -- "device-" of the originator status text not null, -- "pending" | "cleared" | "disputed" | "rejected" quorum_needed integer not null, @@ -83,8 +83,7 @@ create index if not exists resolve_receipts_author_pending_idx alter table public.resolve_receipts enable row level security; create table if not exists public.resolve_witnesses ( - receipt_id text not null references public.resolve_receipts(id) on delete cascade, - target_sos_id text not null, -- denormalized for the unique constraint + lookup + target_sos_id text not null, -- lookup key device_id text not null, -- "device-" pubkey text not null, -- 32-byte Ed25519 hex lat double precision not null, From 79028d82c623dfd2d77762b496b30324c076d48d Mon Sep 17 00:00:00 2001 From: LordMikkel <125906789+LordMikkel@users.noreply.github.com> Date: Thu, 2 Jul 2026 03:05:00 +0200 Subject: [PATCH 2/2] test(resolve): cover pending-clear store transitions and safer migration --- backend/src/resolve/store.test.ts | 77 +++++++++++++++++++ .../migrations/0001_fix_resolve_schema.sql | 7 ++ 2 files changed, 84 insertions(+) create mode 100644 backend/src/resolve/store.test.ts diff --git a/backend/src/resolve/store.test.ts b/backend/src/resolve/store.test.ts new file mode 100644 index 0000000..cf750ff --- /dev/null +++ b/backend/src/resolve/store.test.ts @@ -0,0 +1,77 @@ +import { describe, test, expect, beforeEach } from "bun:test"; +import type { ResolveReceipt } from "@guacamaya/shared"; +import { resolveStore } from "./store.js"; + +function makeReceipt(overrides: Partial = {}): ResolveReceipt { + return { + id: `receipt-${Math.random().toString(36).slice(2)}`, + targetSosId: "a".repeat(64), + targetSosAuthor: "device-" + "11".repeat(32), + status: "pending", + quorumNeeded: 2, + quorumSeen: 1, + witnessDeviceIds: ["device-" + "11".repeat(32)], + createdAt: Date.now(), + ...overrides, + }; +} + +describe("resolveStore", () => { + beforeEach(() => { + resolveStore.clear(); + }); + + test("upsertReceipt does NOT register pending-clear while accumulating witnesses", () => { + resolveStore.upsertReceipt(makeReceipt({ quorumSeen: 1, cooldownEndsAt: undefined })); + expect(resolveStore.getPendingClear("a".repeat(64))).toBeUndefined(); + }); + + test("upsertReceipt registers pending-clear when cooldown starts", () => { + const cooldownEndsAt = Date.now() + 15 * 60_000; + resolveStore.upsertReceipt(makeReceipt({ quorumSeen: 2, cooldownEndsAt })); + const pc = resolveStore.getPendingClear("a".repeat(64)); + expect(pc).toBeDefined(); + expect(pc!.cooldownEndsAt).toBe(cooldownEndsAt); + expect(pc!.disputed).toBe(false); + }); + + test("promoteExpiredClears promotes expired cooldown to cleared", () => { + const now = Date.now(); + const target = "b".repeat(64); + resolveStore.upsertReceipt(makeReceipt({ targetSosId: target, cooldownEndsAt: now - 1000 })); + const promoted = resolveStore.promoteExpiredClears(now); + expect(promoted.length).toBe(1); + expect(promoted[0].status).toBe("cleared"); + expect(resolveStore.getPendingClear(target)).toBeUndefined(); + }); + + test("autoDispute marks pending-clear and prevents promotion", () => { + const now = Date.now(); + const target = "c".repeat(64); + resolveStore.upsertReceipt(makeReceipt({ targetSosId: target, cooldownEndsAt: now - 1000 })); + const disputed = resolveStore.autoDispute(target, "originator_refire"); + expect(disputed!.status).toBe("disputed"); + expect(resolveStore.getPendingClear(target)!.disputed).toBe(true); + expect(resolveStore.promoteExpiredClears(now).length).toBe(0); + }); + + test("findPendingClearForAuthor only finds receipts in real cooldown", () => { + const author = "device-" + "dd".repeat(32); + const accumulating = "d".repeat(64); + const cooldown = "e".repeat(64); + resolveStore.upsertReceipt(makeReceipt({ targetSosId: accumulating, targetSosAuthor: author, cooldownEndsAt: undefined })); + resolveStore.upsertReceipt(makeReceipt({ targetSosId: cooldown, targetSosAuthor: author, cooldownEndsAt: Date.now() + 60_000 })); + expect(resolveStore.findPendingClearForAuthor(author, "x".repeat(64))).toBe(cooldown); + }); + + test("upserting same target replaces previous receipt", () => { + const target = "ff".repeat(32); + resolveStore.upsertReceipt(makeReceipt({ targetSosId: target, quorumSeen: 1, cooldownEndsAt: undefined })); + expect(resolveStore.getReceiptByTarget(target)!.quorumSeen).toBe(1); + expect(resolveStore.getPendingClear(target)).toBeUndefined(); + + resolveStore.upsertReceipt(makeReceipt({ targetSosId: target, quorumSeen: 2, cooldownEndsAt: Date.now() + 15 * 60_000 })); + expect(resolveStore.getReceiptByTarget(target)!.quorumSeen).toBe(2); + expect(resolveStore.getPendingClear(target)).toBeDefined(); + }); +}); diff --git a/backend/supabase/migrations/0001_fix_resolve_schema.sql b/backend/supabase/migrations/0001_fix_resolve_schema.sql index 1a0476a..3497b43 100644 --- a/backend/supabase/migrations/0001_fix_resolve_schema.sql +++ b/backend/supabase/migrations/0001_fix_resolve_schema.sql @@ -6,6 +6,13 @@ alter table if exists public.resolve_witnesses alter table if exists public.resolve_witnesses drop column if exists receipt_id; +with ranked as ( + select id, row_number() over (partition by target_sos_id order by created_at desc, id desc) as rn + from public.resolve_receipts +) +delete from public.resolve_receipts +where id in (select id from ranked where rn > 1); + alter table if exists public.resolve_receipts add constraint resolve_receipts_target_sos_id_unique unique (target_sos_id);