From d8f184de7f10bd3c38b70772939f9f8f2a3543c0 Mon Sep 17 00:00:00 2001 From: John Jeong Date: Mon, 7 Sep 2026 23:59:57 +0900 Subject: [PATCH 1/3] Protect recordings from empty captures and stale deletions Discard only confirmed empty scheduled desktop audio. Preserve unseen recording and note content across synced deletions while keeping intentional deletion and mobile recording behavior intact. --- Cargo.lock | 2 + .../src/stt/capture-lifecycle-storage.test.ts | 15 + .../src/stt/capture-lifecycle-storage.ts | 12 + apps/desktop/src/stt/capture-lifecycle.ts | 60 +- .../src/stt/empty-automatic-capture.test.ts | 108 ++ .../src/stt/empty-automatic-capture.ts | 72 ++ .../src/stt/scheduled-session-auto-start.tsx | 5 +- .../desktop/src/stt/useStartListening.test.ts | 70 ++ apps/desktop/src/stt/useStartListening.ts | 8 +- ...0000_session_documents_content_version.sql | 79 ++ ...0907120100_transcripts_content_version.sql | 79 ++ ...907120200_session_content_observations.sql | 22 + ...0260907120300_session_deletion_context.sql | 73 ++ crates/db-app/src/e2ee/replica_apply.rs | 8 + crates/db-app/src/e2ee/tests/mod.rs | 1 + .../db-app/src/e2ee/tests/session_deletion.rs | 313 ++++++ crates/db-app/src/lib.rs | 27 + crates/db-app/src/session_deletion.rs | 172 ++++ crates/fs-sync-core/Cargo.toml | 2 + crates/fs-sync-core/src/audio/activity.rs | 120 +++ crates/fs-sync-core/src/audio/mod.rs | 3 + packages/db/src/schema.ts | 3 + plugins/fs-sync/build.rs | 1 + plugins/fs-sync/js/bindings.gen.ts | 972 ++++++------------ .../commands/audio_has_speech.toml | 13 + .../permissions/autogenerated/reference.md | 27 + plugins/fs-sync/permissions/default.toml | 1 + .../fs-sync/permissions/schemas/schema.json | 16 +- plugins/fs-sync/src/commands.rs | 13 + plugins/fs-sync/src/lib.rs | 1 + 30 files changed, 1650 insertions(+), 648 deletions(-) create mode 100644 apps/desktop/src/stt/empty-automatic-capture.test.ts create mode 100644 apps/desktop/src/stt/empty-automatic-capture.ts create mode 100644 crates/db-app/migrations/20260907120000_session_documents_content_version.sql create mode 100644 crates/db-app/migrations/20260907120100_transcripts_content_version.sql create mode 100644 crates/db-app/migrations/20260907120200_session_content_observations.sql create mode 100644 crates/db-app/migrations/20260907120300_session_deletion_context.sql create mode 100644 crates/db-app/src/e2ee/tests/session_deletion.rs create mode 100644 crates/db-app/src/session_deletion.rs create mode 100644 crates/fs-sync-core/src/audio/activity.rs create mode 100644 plugins/fs-sync/permissions/autogenerated/commands/audio_has_speech.toml diff --git a/Cargo.lock b/Cargo.lock index 9ad24581153..dd998abe23b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6005,6 +6005,7 @@ dependencies = [ "glob", "predicates", "rayon", + "rodio", "serde", "serde_json", "serde_yaml", @@ -6016,6 +6017,7 @@ dependencies = [ "tiptap", "tracing", "uuid", + "vad", ] [[package]] diff --git a/apps/desktop/src/stt/capture-lifecycle-storage.test.ts b/apps/desktop/src/stt/capture-lifecycle-storage.test.ts index f8a6bfcf117..37efcf7e9b1 100644 --- a/apps/desktop/src/stt/capture-lifecycle-storage.test.ts +++ b/apps/desktop/src/stt/capture-lifecycle-storage.test.ts @@ -72,6 +72,21 @@ test("loads a valid capture marker", async () => { ); }); +test("preserves automatic capture provenance and its pre-recording audio state", async () => { + const automaticMarker = { + ...marker, + automatic: true, + preserveExistingAudio: false, + initialTitle: "Standup", + }; + mocks.execute.mockResolvedValue([ + { value_json: JSON.stringify(automaticMarker) }, + ]); + await expect(loadCaptureLifecycleMarker("session-1")).resolves.toEqual( + automaticMarker, + ); +}); + test("loads the exact durable summary recovery mode", async () => { const summaryMarker = { ...marker, diff --git a/apps/desktop/src/stt/capture-lifecycle-storage.ts b/apps/desktop/src/stt/capture-lifecycle-storage.ts index a8caa960101..400f911ccb6 100644 --- a/apps/desktop/src/stt/capture-lifecycle-storage.ts +++ b/apps/desktop/src/stt/capture-lifecycle-storage.ts @@ -12,6 +12,9 @@ export type CaptureLifecycleMarker = { createdAt: string; audioOffsetMs: number; preserveExistingTranscript: boolean; + automatic?: boolean; + preserveExistingAudio?: boolean; + initialTitle?: string; ownerUserId: string; memo: string; provider?: string; @@ -154,6 +157,15 @@ function parseCaptureLifecycleMarker( createdAt: parsed.createdAt, audioOffsetMs: Math.max(0, parsed.audioOffsetMs), preserveExistingTranscript: parsed.preserveExistingTranscript, + ...(typeof parsed.automatic === "boolean" + ? { automatic: parsed.automatic } + : {}), + ...(typeof parsed.preserveExistingAudio === "boolean" + ? { preserveExistingAudio: parsed.preserveExistingAudio } + : {}), + ...(typeof parsed.initialTitle === "string" + ? { initialTitle: parsed.initialTitle } + : {}), ownerUserId: parsed.ownerUserId, memo: parsed.memo, ...(parsed.phase === "capturing" || parsed.phase === "finalizing" diff --git a/apps/desktop/src/stt/capture-lifecycle.ts b/apps/desktop/src/stt/capture-lifecycle.ts index 803994ce129..0b4b6c3eaf0 100644 --- a/apps/desktop/src/stt/capture-lifecycle.ts +++ b/apps/desktop/src/stt/capture-lifecycle.ts @@ -5,6 +5,7 @@ import { commands as fsSyncCommands } from "@anlg/plugin-fs-sync"; import { sonnerToast } from "@anlg/ui/components/ui/toast"; import { useListener } from "./contexts"; +import { discardEmptyAutomaticCapture } from "./empty-automatic-capture"; import { cancelMeetingRecordingDisclosure } from "./meeting-disclosure"; import { persistTranscriptWrite } from "./persist-retry"; import { createTranscriptPersistenceWorker } from "./transcript-persistence-worker"; @@ -204,7 +205,24 @@ export function useCaptureLifecycle(sessionId: string) { ); const createCaptureLifecycle = useCallback( - (recoveredMarker?: CaptureLifecycleMarker) => { + ( + recoveredMarker?: CaptureLifecycleMarker, + startedAutomatically = false, + ) => { + const automatic = recoveredMarker + ? recoveredMarker.automatic === true + : startedAutomatically; + const initialTitle = recoveredMarker + ? recoveredMarker.initialTitle + : session?.title; + const existingAudioPromise = recoveredMarker + ? Promise.resolve(recoveredMarker.preserveExistingAudio ?? true) + : automatic + ? fsSyncCommands + .audioExist(sessionId) + .then((result) => (result.status === "ok" ? result.data : true)) + .catch(() => true) + : Promise.resolve(true); const transcriptId = recoveredMarker?.transcriptId ?? id(); let transcriptCreated: boolean | null = recoveredMarker ? null : false; let transcriptTouched = false; @@ -401,6 +419,9 @@ export function useCaptureLifecycle(sessionId: string) { createdAt, audioOffsetMs: await existingAudioDurationPromise, preserveExistingTranscript, + automatic, + preserveExistingAudio: await existingAudioPromise, + initialTitle, ownerUserId, memo: memoMd, ...(provider ? { provider } : {}), @@ -478,6 +499,32 @@ export function useCaptureLifecycle(sessionId: string) { }; cancelMeetingRecordingDisclosure(sessionId); await stopMeetingChatTasks(); + await transcriptPersistence.flush(); + if ( + details.audioPath && + (await discardEmptyAutomaticCapture({ + sessionId, + automatic, + preserveExistingAudio: await existingAudioPromise, + preserveExistingTranscript, + initialTitle, + transcriptTouched, + transcriptionComplete: + (!details.requestedLiveTranscription || + details.liveTranscriptionActive) && + !details.needsBatchRepair && + !transcriptWriteError, + })) + ) { + await clearCaptureLifecycleMarker(sessionId, transcriptId); + recoveryPending = false; + recoveryStateCleared = true; + return; + } + trackSessionCompletion( + details, + recoveredMarker ? "recovered_capture_stopped" : "capture_stopped", + ); if (details.audioPath) { try { await enqueueSessionAudioOperation(sessionId, () => @@ -487,7 +534,6 @@ export function useCaptureLifecycle(sessionId: string) { console.error("[listener] failed to catalog recorded audio", error); } } - await transcriptPersistence.flush(); transcriptCreated ??= await transcriptExists(transcriptId); const useLocalBatchForSpeakerDiarization = shouldUseLocalBatchForSpeakerDiarization(); @@ -844,10 +890,6 @@ export function useCaptureLifecycle(sessionId: string) { } }; const onStopped: OnStoppedCallback = (_sessionId, details) => { - trackSessionCompletion( - details, - recoveredMarker ? "recovered_capture_stopped" : "capture_stopped", - ); recoveryPending = false; markExpectedPostStopBatch(details); return finalizeStopped(details, true); @@ -867,7 +909,6 @@ export function useCaptureLifecycle(sessionId: string) { } }; const recoverStopped: OnStoppedCallback = (_sessionId, details) => { - trackSessionCompletion(details, "recovered_capture_stopped"); markExpectedPostStopBatch(details); return finalizeStopped(details, false); }; @@ -887,7 +928,10 @@ export function useCaptureLifecycle(sessionId: string) { handlePersist, onStopped, recoverStopped, - ready: existingAudioDurationPromise.then(() => undefined), + ready: Promise.all([ + existingAudioDurationPromise, + existingAudioPromise, + ]).then(() => undefined), persistMarker: async () => { await persistTranscriptWrite(async () => { await saveCaptureLifecycleMarker(await marker()); diff --git a/apps/desktop/src/stt/empty-automatic-capture.test.ts b/apps/desktop/src/stt/empty-automatic-capture.test.ts new file mode 100644 index 00000000000..8e388b2cd93 --- /dev/null +++ b/apps/desktop/src/stt/empty-automatic-capture.test.ts @@ -0,0 +1,108 @@ +import { beforeEach, expect, it, vi } from "vitest"; + +import { discardEmptyAutomaticCapture } from "./empty-automatic-capture"; + +const mocks = vi.hoisted(() => ({ + speech: vi.fn(), + remove: vi.fn(), + execute: vi.fn(), + empty: vi.fn(), + flush: vi.fn(), +})); +vi.mock("@anlg/plugin-fs-sync", () => ({ + commands: { + audioHasSpeech: mocks.speech, + audioDelete: mocks.remove, + }, +})); +vi.mock("~/db", () => ({ liveQueryClient: { execute: mocks.execute } })); +vi.mock("~/session/queries", () => ({ isSessionEmpty: mocks.empty })); +vi.mock("~/session-sharing/editor-activity", () => ({ + flushCanonicalSessionEditorChanges: mocks.flush, +})); +vi.mock("~/session/audio-operations", () => ({ + enqueueSessionAudioOperation: (_: string, operation: () => unknown) => + operation(), +})); + +const input = { + sessionId: "meeting", + automatic: true, + preserveExistingAudio: false, + preserveExistingTranscript: false, + initialTitle: "Standup", + transcriptTouched: false, + transcriptionComplete: true, +}; + +beforeEach(() => { + vi.resetAllMocks(); + mocks.speech.mockResolvedValue({ status: "ok", data: false }); + mocks.remove.mockResolvedValue({ status: "ok", data: true }); + mocks.execute.mockResolvedValue([{ title: "Standup", has_attachments: 0 }]); + mocks.empty.mockResolvedValue(true); + mocks.flush.mockResolvedValue(undefined); +}); + +it("discards only silent automatic audio without deleting the shared note", async () => { + expect(await discardEmptyAutomaticCapture(input)).toBe(true); + expect(mocks.remove).toHaveBeenCalledWith("meeting"); + expect(mocks.execute).toHaveBeenCalledTimes(1); + expect(mocks.execute.mock.calls[0][0]).toMatch(/^SELECT /); +}); + +it.each([ + { automatic: false }, + { preserveExistingAudio: true }, + { preserveExistingTranscript: true }, + { transcriptTouched: true }, + { transcriptionComplete: false }, + { initialTitle: undefined }, +])("keeps captures that cannot safely be discarded: %j", async (override) => { + expect(await discardEmptyAutomaticCapture({ ...input, ...override })).toBe( + false, + ); + expect(mocks.speech).not.toHaveBeenCalled(); + expect(mocks.remove).not.toHaveBeenCalled(); +}); + +it.each([ + { status: "ok", data: true }, + { status: "error", error: "decoder failed" }, +])("keeps speech and uncertain analysis: %j", async (result) => { + mocks.speech.mockResolvedValue(result); + expect(await discardEmptyAutomaticCapture(input)).toBe(false); + expect(mocks.remove).not.toHaveBeenCalled(); +}); + +it("keeps recordings when the user adds notes", async () => { + mocks.empty.mockResolvedValue(false); + expect(await discardEmptyAutomaticCapture(input)).toBe(false); + expect(mocks.remove).not.toHaveBeenCalled(); +}); + +it("keeps audio catalogued before recovery or contributed by another device", async () => { + mocks.execute.mockResolvedValue([{ title: "Standup", has_attachments: 1 }]); + expect(await discardEmptyAutomaticCapture(input)).toBe(false); + expect(mocks.remove).not.toHaveBeenCalled(); +}); + +it("flushes edits made during audio analysis before deciding whether to discard", async () => { + mocks.speech.mockImplementation(async () => { + mocks.flush.mockImplementation(async () => { + mocks.empty.mockResolvedValue(false); + }); + return { status: "ok", data: false }; + }); + expect(await discardEmptyAutomaticCapture(input)).toBe(false); + expect(mocks.remove).not.toHaveBeenCalled(); +}); + +it.each([{ rows: [{ title: "Renamed by the user" }] }, { rows: [] }])( + "keeps renamed or deleted sessions", + async ({ rows }) => { + mocks.execute.mockResolvedValue(rows); + expect(await discardEmptyAutomaticCapture(input)).toBe(false); + expect(mocks.remove).not.toHaveBeenCalled(); + }, +); diff --git a/apps/desktop/src/stt/empty-automatic-capture.ts b/apps/desktop/src/stt/empty-automatic-capture.ts new file mode 100644 index 00000000000..4e4fb6b6903 --- /dev/null +++ b/apps/desktop/src/stt/empty-automatic-capture.ts @@ -0,0 +1,72 @@ +import { commands as fsSyncCommands } from "@anlg/plugin-fs-sync"; + +import { liveQueryClient } from "~/db"; +import { flushCanonicalSessionEditorChanges } from "~/session-sharing/editor-activity"; +import { enqueueSessionAudioOperation } from "~/session/audio-operations"; +import { isSessionEmpty } from "~/session/queries"; + +export async function discardEmptyAutomaticCapture({ + sessionId, + automatic, + preserveExistingAudio, + preserveExistingTranscript, + initialTitle, + transcriptTouched, + transcriptionComplete, +}: { + sessionId: string; + automatic: boolean; + preserveExistingAudio: boolean; + preserveExistingTranscript: boolean; + initialTitle: string | undefined; + transcriptTouched: boolean; + transcriptionComplete: boolean; +}): Promise { + if ( + !automatic || + preserveExistingAudio || + preserveExistingTranscript || + initialTitle === undefined || + transcriptTouched || + !transcriptionComplete + ) { + return false; + } + try { + await flushCanonicalSessionEditorChanges(sessionId); + return await enqueueSessionAudioOperation(sessionId, async () => { + const speech = await fsSyncCommands.audioHasSpeech(sessionId); + if (speech.status !== "ok" || speech.data) return false; + await flushCanonicalSessionEditorChanges(sessionId); + const [session] = await liveQueryClient.execute<{ + title: string; + has_attachments: number; + }>( + `SELECT title, EXISTS ( + SELECT 1 FROM session_attachments + WHERE session_id = sessions.id AND deleted_at IS NULL + ) AS has_attachments + FROM sessions WHERE id = ? AND deleted_at IS NULL`, + [sessionId], + ); + if ( + !session || + session.title !== initialTitle || + session.has_attachments !== 0 || + !(await isSessionEmpty(sessionId)) + ) { + return false; + } + // Only remove this device's un-catalogued audio. The calendar note and + // anything another device has contributed stay in the shared database. + const result = await fsSyncCommands.audioDelete(sessionId); + return result.status === "ok" && result.data; + }); + } catch (error) { + console.warn( + "[listener] keeping automatic capture after an inconclusive activity check", + error, + ); + return false; + } +} diff --git a/apps/desktop/src/stt/scheduled-session-auto-start.tsx b/apps/desktop/src/stt/scheduled-session-auto-start.tsx index b40fdb1466f..849dd148cd7 100644 --- a/apps/desktop/src/stt/scheduled-session-auto-start.tsx +++ b/apps/desktop/src/stt/scheduled-session-auto-start.tsx @@ -65,7 +65,10 @@ function PendingScheduledSessionAutoStart({ } function ReadyScheduledSessionAutoStart({ sessionId }: { sessionId: string }) { - const { connectionReady, startListening } = useStartListeningState(sessionId); + const { connectionReady, startListening } = useStartListeningState( + sessionId, + { automatic: true }, + ); const attemptedRef = useRef(false); useMountEffect(() => { diff --git a/apps/desktop/src/stt/useStartListening.test.ts b/apps/desktop/src/stt/useStartListening.test.ts index aba3e5803f4..1b61d2c49bd 100644 --- a/apps/desktop/src/stt/useStartListening.test.ts +++ b/apps/desktop/src/stt/useStartListening.test.ts @@ -13,6 +13,7 @@ import { sendMeetingRecordingDisclosure, useResumeListeningLifecycle, useStartListening, + useStartListeningState, } from "./useStartListening"; import { enqueueSessionAudioOperation } from "~/session/audio-operations"; @@ -70,7 +71,9 @@ const { flushCanonicalSessionEditorChangesMock, idMock, openNewMock, + emptyCaptureMock, } = vi.hoisted(() => ({ + emptyCaptureMock: vi.fn(), queueAutoEnhanceMock: vi.fn(), queueAutoEnhanceIfSummaryEmptyMock: vi.fn(), resetEnhanceTasksMock: vi.fn(), @@ -156,8 +159,13 @@ vi.mock("./meeting-consent-store", () => ({ persistParticipantConsent: vi.fn(async () => {}), })); +vi.mock("./empty-automatic-capture", () => ({ + discardEmptyAutomaticCapture: emptyCaptureMock, +})); + vi.mock("@anlg/plugin-fs-sync", () => ({ commands: { + audioExist: vi.fn().mockResolvedValue({ status: "ok", data: false }), audioPath: audioPathMock, audioSourceMetadata: audioSourceMetadataMock, }, @@ -438,6 +446,7 @@ describe("getPostCaptureAction", () => { describe("useStartListening", () => { beforeEach(() => { vi.clearAllMocks(); + emptyCaptureMock.mockResolvedValue(false); idMock.mockReturnValue("generated-id"); getEnhancerServiceMock.mockImplementation(() => ({ @@ -900,6 +909,67 @@ describe("useStartListening", () => { expect(saveCaptureLifecycleMarkerMock).toHaveBeenCalledBefore(startMock); }); + test("manual recording stays manual while a scheduled start is waiting for the same note", async () => { + renderHook(() => useStartListeningState("session-1", { automatic: true })); + const { result } = renderHook(() => useStartListening("session-1")); + await act(async () => { + await result.current(); + }); + expect(saveCaptureLifecycleMarkerMock).toHaveBeenCalledWith( + expect.objectContaining({ + automatic: false, + preserveExistingAudio: true, + }), + ); + }); + + test.each([true, false])( + "discards an empty scheduled capture before publishing audio or running batch transcription (live=%s)", + async (live) => { + emptyCaptureMock.mockResolvedValue(true); + useSessionMock.mockReturnValue({ + id: "session-1", + user_id: "user-1", + raw_md: "", + title: "Standup", + }); + const { result } = renderHook( + () => + useStartListeningState("session-1", { automatic: true }) + .startListening, + ); + await act(async () => { + await result.current(); + }); + const onStopped = startMock.mock.calls[0]?.[1]?.onStopped; + await act(async () => { + await onStopped("session-1", { + durationSeconds: 42, + audioPath: "/tmp/session.wav", + requestedLiveTranscription: live, + liveTranscriptionActive: live, + needsBatchRepair: false, + }); + }); + expect(emptyCaptureMock).toHaveBeenCalledWith( + expect.objectContaining({ + automatic: true, + preserveExistingAudio: false, + initialTitle: "Standup", + transcriptionComplete: true, + }), + ); + expect(catalogLocalSessionAudioMock).not.toHaveBeenCalled(); + expect(runBatchMock).not.toHaveBeenCalled(); + expect(requestMainAutoEnhanceMock).not.toHaveBeenCalled(); + expect(clearCaptureLifecycleMarkerMock).toHaveBeenCalledWith( + "session-1", + "generated-id", + ); + expect(endCloudsyncActivityMock).toHaveBeenCalled(); + }, + ); + test("runs batch transcription after record-only capture stops", async () => { const { result } = renderHook(() => useStartListening("session-1")); diff --git a/apps/desktop/src/stt/useStartListening.ts b/apps/desktop/src/stt/useStartListening.ts index a373b62f7a7..659ed3e7b41 100644 --- a/apps/desktop/src/stt/useStartListening.ts +++ b/apps/desktop/src/stt/useStartListening.ts @@ -39,7 +39,10 @@ export function useStartListening(sessionId: string) { return useStartListeningState(sessionId).startListening; } -export function useStartListeningState(sessionId: string) { +export function useStartListeningState( + sessionId: string, + { automatic = false }: { automatic?: boolean } = {}, +) { const { conn, connectionReady, @@ -71,7 +74,7 @@ export function useStartListeningState(sessionId: string) { return; } await stopMeetingChatTasks(); - const lifecycle = createCaptureLifecycle(); + const lifecycle = createCaptureLifecycle(undefined, automatic); // A fresh note or a just-focused window starts listening right as a sync // round begins; waiting for that round to yield made the start feel slow // and sometimes refused to record at all. @@ -266,6 +269,7 @@ export function useStartListeningState(sessionId: string) { : {}), }); }, [ + automatic, aiLanguage, canStartLiveSession, conn, diff --git a/crates/db-app/migrations/20260907120000_session_documents_content_version.sql b/crates/db-app/migrations/20260907120000_session_documents_content_version.sql new file mode 100644 index 00000000000..dcbf76cba95 --- /dev/null +++ b/crates/db-app/migrations/20260907120000_session_documents_content_version.sql @@ -0,0 +1,79 @@ +ALTER TABLE session_documents ADD COLUMN content_version TEXT NOT NULL DEFAULT ''; + +-- This identifies content the user has seen when deleting. Replicated fields +-- keep their originating version; tombstones and transfer metadata do not edit it. +CREATE TRIGGER session_documents_content_version_insert +AFTER INSERT ON session_documents +WHEN NOT EXISTS ( + SELECT 1 FROM e2ee_apply_guard + WHERE workspace_id = NEW.workspace_id AND table_name = 'session_documents' AND row_id = NEW.id +) +BEGIN + UPDATE session_documents SET content_version = lower(hex(randomblob(16))) WHERE id = NEW.id; +END; + +CREATE TRIGGER session_documents_content_version_update +AFTER UPDATE OF body, body_format ON session_documents +WHEN (NEW.body IS NOT OLD.body OR NEW.body_format IS NOT OLD.body_format) + AND NOT EXISTS ( + SELECT 1 FROM e2ee_apply_guard + WHERE workspace_id = NEW.workspace_id AND table_name = 'session_documents' AND row_id = NEW.id +) +BEGIN + UPDATE session_documents SET content_version = lower(hex(randomblob(16))) WHERE id = NEW.id; +END; + + +-- The nested observation write is bookkeeping; the original content write +-- already queues encryption and search exactly once. + +DROP TRIGGER search_index_session_documents_update; +CREATE TRIGGER IF NOT EXISTS search_index_session_documents_update +AFTER UPDATE ON session_documents +WHEN NEW.content_version IS OLD.content_version OR NEW.body IS NOT OLD.body OR NEW.body_format IS NOT OLD.body_format OR NEW.session_id IS NOT OLD.session_id OR NEW.workspace_id IS NOT OLD.workspace_id OR NEW.id IS NOT OLD.id +BEGIN + INSERT INTO search_index_dirty (entity_type, entity_id) + SELECT 'session', OLD.session_id + WHERE OLD.session_id <> '' + ON CONFLICT (entity_type, entity_id) DO UPDATE SET + generation = search_index_dirty.generation + 1, + queued_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'); + + INSERT INTO search_index_dirty (entity_type, entity_id) + SELECT 'session', NEW.session_id + WHERE NEW.session_id <> '' AND NEW.session_id <> OLD.session_id + ON CONFLICT (entity_type, entity_id) DO UPDATE SET + generation = search_index_dirty.generation + 1, + queued_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'); +END; + +DROP TRIGGER e2ee_dirty_session_documents_update; +CREATE TRIGGER IF NOT EXISTS e2ee_dirty_session_documents_update +AFTER UPDATE ON session_documents +WHEN NEW.content_version IS OLD.content_version OR NEW.body IS NOT OLD.body OR NEW.body_format IS NOT OLD.body_format OR NEW.session_id IS NOT OLD.session_id OR NEW.workspace_id IS NOT OLD.workspace_id OR NEW.id IS NOT OLD.id +BEGIN + INSERT INTO e2ee_dirty_rows (workspace_id, table_name, row_id) + SELECT OLD.workspace_id, 'session_documents', OLD.id + WHERE NOT EXISTS ( + SELECT 1 + FROM e2ee_apply_guard + WHERE workspace_id = OLD.workspace_id + AND table_name = 'session_documents' + AND row_id = OLD.id + ) + ON CONFLICT (workspace_id, table_name, row_id) DO UPDATE SET + generation = e2ee_dirty_rows.generation + 1; + + INSERT INTO e2ee_dirty_rows (workspace_id, table_name, row_id) + SELECT NEW.workspace_id, 'session_documents', NEW.id + WHERE (NEW.workspace_id <> OLD.workspace_id OR NEW.id <> OLD.id) + AND NOT EXISTS ( + SELECT 1 + FROM e2ee_apply_guard + WHERE workspace_id = NEW.workspace_id + AND table_name = 'session_documents' + AND row_id = NEW.id + ) + ON CONFLICT (workspace_id, table_name, row_id) DO UPDATE SET + generation = e2ee_dirty_rows.generation + 1; +END; diff --git a/crates/db-app/migrations/20260907120100_transcripts_content_version.sql b/crates/db-app/migrations/20260907120100_transcripts_content_version.sql new file mode 100644 index 00000000000..ec450324bfb --- /dev/null +++ b/crates/db-app/migrations/20260907120100_transcripts_content_version.sql @@ -0,0 +1,79 @@ +ALTER TABLE transcripts ADD COLUMN content_version TEXT NOT NULL DEFAULT ''; + +-- This identifies content the user has seen when deleting. Replicated fields +-- keep their originating version; tombstones and transfer metadata do not edit it. +CREATE TRIGGER transcripts_content_version_insert +AFTER INSERT ON transcripts +WHEN NOT EXISTS ( + SELECT 1 FROM e2ee_apply_guard + WHERE workspace_id = NEW.workspace_id AND table_name = 'transcripts' AND row_id = NEW.id +) +BEGIN + UPDATE transcripts SET content_version = lower(hex(randomblob(16))) WHERE id = NEW.id; +END; + +CREATE TRIGGER transcripts_content_version_update +AFTER UPDATE OF words_json, content_revision ON transcripts +WHEN (NEW.words_json IS NOT OLD.words_json OR NEW.content_revision IS NOT OLD.content_revision) + AND NOT EXISTS ( + SELECT 1 FROM e2ee_apply_guard + WHERE workspace_id = NEW.workspace_id AND table_name = 'transcripts' AND row_id = NEW.id +) +BEGIN + UPDATE transcripts SET content_version = lower(hex(randomblob(16))) WHERE id = NEW.id; +END; + + +-- The nested observation write is bookkeeping; the original content write +-- already queues encryption and search exactly once. + +DROP TRIGGER search_index_transcripts_update; +CREATE TRIGGER IF NOT EXISTS search_index_transcripts_update +AFTER UPDATE ON transcripts +WHEN NEW.content_version IS OLD.content_version OR NEW.words_json IS NOT OLD.words_json OR NEW.content_revision IS NOT OLD.content_revision OR NEW.session_id IS NOT OLD.session_id OR NEW.workspace_id IS NOT OLD.workspace_id OR NEW.id IS NOT OLD.id +BEGIN + INSERT INTO search_index_dirty (entity_type, entity_id) + SELECT 'session', OLD.session_id + WHERE OLD.session_id <> '' + ON CONFLICT (entity_type, entity_id) DO UPDATE SET + generation = search_index_dirty.generation + 1, + queued_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'); + + INSERT INTO search_index_dirty (entity_type, entity_id) + SELECT 'session', NEW.session_id + WHERE NEW.session_id <> '' AND NEW.session_id <> OLD.session_id + ON CONFLICT (entity_type, entity_id) DO UPDATE SET + generation = search_index_dirty.generation + 1, + queued_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'); +END; + +DROP TRIGGER e2ee_dirty_transcripts_update; +CREATE TRIGGER IF NOT EXISTS e2ee_dirty_transcripts_update +AFTER UPDATE ON transcripts +WHEN NEW.content_version IS OLD.content_version OR NEW.words_json IS NOT OLD.words_json OR NEW.content_revision IS NOT OLD.content_revision OR NEW.session_id IS NOT OLD.session_id OR NEW.workspace_id IS NOT OLD.workspace_id OR NEW.id IS NOT OLD.id +BEGIN + INSERT INTO e2ee_dirty_rows (workspace_id, table_name, row_id) + SELECT OLD.workspace_id, 'transcripts', OLD.id + WHERE NOT EXISTS ( + SELECT 1 + FROM e2ee_apply_guard + WHERE workspace_id = OLD.workspace_id + AND table_name = 'transcripts' + AND row_id = OLD.id + ) + ON CONFLICT (workspace_id, table_name, row_id) DO UPDATE SET + generation = e2ee_dirty_rows.generation + 1; + + INSERT INTO e2ee_dirty_rows (workspace_id, table_name, row_id) + SELECT NEW.workspace_id, 'transcripts', NEW.id + WHERE (NEW.workspace_id <> OLD.workspace_id OR NEW.id <> OLD.id) + AND NOT EXISTS ( + SELECT 1 + FROM e2ee_apply_guard + WHERE workspace_id = NEW.workspace_id + AND table_name = 'transcripts' + AND row_id = NEW.id + ) + ON CONFLICT (workspace_id, table_name, row_id) DO UPDATE SET + generation = e2ee_dirty_rows.generation + 1; +END; diff --git a/crates/db-app/migrations/20260907120200_session_content_observations.sql b/crates/db-app/migrations/20260907120200_session_content_observations.sql new file mode 100644 index 00000000000..f5fb1f29a94 --- /dev/null +++ b/crates/db-app/migrations/20260907120200_session_content_observations.sql @@ -0,0 +1,22 @@ +CREATE VIEW session_content_observations AS +SELECT workspace_id, id AS session_id, 'title:' || id AS content_id, + title AS content_version, deleted_at +FROM sessions WHERE trim(title) <> '' +UNION ALL +SELECT workspace_id, session_id, 'document:' || id, content_version, deleted_at +FROM session_documents +WHERE trim(body) <> '' AND ( + body_format <> 'prosemirror_json' OR NOT json_valid(body) OR EXISTS ( + SELECT 1 FROM json_tree(CASE WHEN json_valid(body) THEN body ELSE '{}' END) + WHERE (key = 'text' AND trim(CAST(atom AS TEXT)) <> '') + OR (key = 'type' AND atom NOT IN ('doc', 'paragraph', 'text', 'hardBreak')) + ) +) +UNION ALL +SELECT workspace_id, session_id, 'transcript:' || id, content_version, deleted_at +FROM transcripts +WHERE trim(words_json) NOT IN ('', '[]') +UNION ALL +SELECT workspace_id, session_id, 'attachment:' || id, + json_array(sha256, size_bytes, relative_path, source_type, source_id), deleted_at +FROM session_attachments WHERE size_bytes > 0; diff --git a/crates/db-app/migrations/20260907120300_session_deletion_context.sql b/crates/db-app/migrations/20260907120300_session_deletion_context.sql new file mode 100644 index 00000000000..09cbd77b936 --- /dev/null +++ b/crates/db-app/migrations/20260907120300_session_deletion_context.sql @@ -0,0 +1,73 @@ +ALTER TABLE sessions ADD COLUMN deletion_context TEXT NOT NULL DEFAULT ''; + +-- Capture what this device deleted, including children tombstoned earlier in +-- the same transaction. Incoming deletions carry the sender's observations. +CREATE TRIGGER sessions_deletion_context +AFTER UPDATE OF deleted_at ON sessions +WHEN NEW.deleted_at IS NOT OLD.deleted_at AND NOT EXISTS ( + SELECT 1 FROM e2ee_apply_guard + WHERE workspace_id = NEW.workspace_id AND table_name = 'sessions' AND row_id = NEW.id +) +BEGIN + UPDATE sessions SET deletion_context = CASE WHEN NEW.deleted_at IS NULL THEN '' + ELSE json_object('version', 1, 'deletedAt', NEW.deleted_at, 'observed', json(( + SELECT json_group_object(content_id, content_version) + FROM session_content_observations + WHERE session_id = NEW.id AND workspace_id = NEW.workspace_id + AND (deleted_at IS NULL OR deleted_at = NEW.deleted_at) + ))) END + WHERE id = NEW.id; +END; + +-- The nested observation write is bookkeeping; the original content write +-- already queues encryption and search exactly once. + +DROP TRIGGER search_index_sessions_update; +CREATE TRIGGER IF NOT EXISTS search_index_sessions_update +AFTER UPDATE ON sessions +WHEN NEW.deletion_context IS OLD.deletion_context OR NEW.deleted_at IS NOT OLD.deleted_at OR NEW.title IS NOT OLD.title OR NEW.workspace_id IS NOT OLD.workspace_id OR NEW.id IS NOT OLD.id +BEGIN + INSERT INTO search_index_dirty (entity_type, entity_id) + VALUES ('session', OLD.id) + ON CONFLICT (entity_type, entity_id) DO UPDATE SET + generation = search_index_dirty.generation + 1, + queued_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'); + + INSERT INTO search_index_dirty (entity_type, entity_id) + SELECT 'session', NEW.id + WHERE NEW.id <> OLD.id + ON CONFLICT (entity_type, entity_id) DO UPDATE SET + generation = search_index_dirty.generation + 1, + queued_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'); +END; + +DROP TRIGGER e2ee_dirty_sessions_update; +CREATE TRIGGER IF NOT EXISTS e2ee_dirty_sessions_update +AFTER UPDATE ON sessions +WHEN NEW.deletion_context IS OLD.deletion_context OR NEW.deleted_at IS NOT OLD.deleted_at OR NEW.title IS NOT OLD.title OR NEW.workspace_id IS NOT OLD.workspace_id OR NEW.id IS NOT OLD.id +BEGIN + INSERT INTO e2ee_dirty_rows (workspace_id, table_name, row_id) + SELECT OLD.workspace_id, 'sessions', OLD.id + WHERE NOT EXISTS ( + SELECT 1 + FROM e2ee_apply_guard + WHERE workspace_id = OLD.workspace_id + AND table_name = 'sessions' + AND row_id = OLD.id + ) + ON CONFLICT (workspace_id, table_name, row_id) DO UPDATE SET + generation = e2ee_dirty_rows.generation + 1; + + INSERT INTO e2ee_dirty_rows (workspace_id, table_name, row_id) + SELECT NEW.workspace_id, 'sessions', NEW.id + WHERE (NEW.workspace_id <> OLD.workspace_id OR NEW.id <> OLD.id) + AND NOT EXISTS ( + SELECT 1 + FROM e2ee_apply_guard + WHERE workspace_id = NEW.workspace_id + AND table_name = 'sessions' + AND row_id = NEW.id + ) + ON CONFLICT (workspace_id, table_name, row_id) DO UPDATE SET + generation = e2ee_dirty_rows.generation + 1; +END; diff --git a/crates/db-app/src/e2ee/replica_apply.rs b/crates/db-app/src/e2ee/replica_apply.rs index 8df7bd24633..bc0d82f13f8 100644 --- a/crates/db-app/src/e2ee/replica_apply.rs +++ b/crates/db-app/src/e2ee/replica_apply.rs @@ -680,6 +680,14 @@ pub(super) async fn apply_e2ee_replica_changes_inner( remove_apply_guard(&mut transaction, &workspace_id, &table, &row_id).await?; rollback_if_cancelled!(transaction, is_cancelled); pending.retain(|(record_id, _)| !deferred_pending_ids.contains(record_id)); + crate::session_deletion::reconcile_session_deletion( + &mut transaction, + &workspace_id, + &table, + &row_id, + ) + .await?; + rollback_if_cancelled!(transaction, is_cancelled); delete_reconciled_replica_entries_in_transaction(&mut transaction, &pending).await?; rollback_if_cancelled!(transaction, is_cancelled); commit_e2ee_apply_transaction(transaction, is_cancelled).await?; diff --git a/crates/db-app/src/e2ee/tests/mod.rs b/crates/db-app/src/e2ee/tests/mod.rs index 12a7e121d15..dd198909168 100644 --- a/crates/db-app/src/e2ee/tests/mod.rs +++ b/crates/db-app/src/e2ee/tests/mod.rs @@ -41,5 +41,6 @@ mod dirty_rows; mod replica_apply; mod revision_conflicts; mod roundtrip; +mod session_deletion; mod snapshots; mod witness_queue; diff --git a/crates/db-app/src/e2ee/tests/session_deletion.rs b/crates/db-app/src/e2ee/tests/session_deletion.rs new file mode 100644 index 00000000000..9c7033ab6f1 --- /dev/null +++ b/crates/db-app/src/e2ee/tests/session_deletion.rs @@ -0,0 +1,313 @@ +use super::*; + +async fn seed(pool: &SqlitePool) { + sqlx::raw_sql( + "INSERT INTO sessions (id, workspace_id, title) VALUES ('meeting', 'workspace-a', 'Standup'); + INSERT INTO session_documents (id, workspace_id, session_id) + VALUES ('meeting', 'workspace-a', 'meeting');", + ) + .execute(pool) + .await + .unwrap(); +} + +async fn delete(pool: &SqlitePool, tombstone: &str) { + let mut tx = pool.begin().await.unwrap(); + for table in ["session_documents", "transcripts", "session_attachments"] { + let sql = format!( + "UPDATE {table} SET deleted_at = ?, updated_at = ? + WHERE session_id = 'meeting' AND deleted_at IS NULL" + ); + sqlx::query(sqlx::AssertSqlSafe(sql)) + .bind(tombstone) + .bind(tombstone) + .execute(&mut *tx) + .await + .unwrap(); + } + sqlx::query("UPDATE sessions SET deleted_at = ?, updated_at = ? WHERE id = 'meeting'") + .bind(tombstone) + .bind(tombstone) + .execute(&mut *tx) + .await + .unwrap(); + tx.commit().await.unwrap(); +} + +async fn sync(source: &SqlitePool, target: &SqlitePool) { + let workspace_keys = keys("workspace-a"); + encrypt_e2ee_replica_changes(source, &workspace_keys) + .await + .unwrap(); + copy_replica(source, target).await; + for _ in 0..12 { + encrypt_e2ee_replica_changes(target, &workspace_keys) + .await + .unwrap(); + let stats = apply_e2ee_replica_changes(target, &workspace_keys) + .await + .unwrap(); + if !stats.remaining_replica_changes { + return; + } + } + panic!("replica did not settle"); +} + +async fn assert_deleted(pool: &SqlitePool, deleted: bool) { + let actual: bool = + sqlx::query_scalar("SELECT deleted_at IS NOT NULL FROM sessions WHERE id = 'meeting'") + .fetch_one(pool) + .await + .unwrap(); + assert_eq!(actual, deleted); +} + +#[tokio::test] +async fn unseen_recording_survives_stale_deletion_in_both_sync_orders() { + for delete_arrives_first in [true, false] { + let a = test_db().await; + let b = test_db().await; + seed(a.pool()).await; + sync(a.pool(), b.pool()).await; + sqlx::query( + "INSERT INTO transcripts (id, workspace_id, session_id, words_json) + VALUES ('capture-b', 'workspace-a', 'meeting', '[{\"text\":\"Useful meeting\"}]')", + ) + .execute(b.pool()) + .await + .unwrap(); + delete(a.pool(), "2099-01-01").await; + if delete_arrives_first { + sync(a.pool(), b.pool()).await; + } else { + sync(b.pool(), a.pool()).await; + } + for _ in 0..3 { + sync(a.pool(), b.pool()).await; + sync(b.pool(), a.pool()).await; + } + for db in [&a, &b] { + assert_deleted(db.pool(), false).await; + let words: String = sqlx::query_scalar( + "SELECT words_json FROM transcripts WHERE id = 'capture-b' AND deleted_at IS NULL", + ) + .fetch_one(db.pool()) + .await + .unwrap(); + assert!(words.contains("Useful meeting")); + } + // Once the populated note has been seen, intentional deletion must stick. + delete(a.pool(), "2100-01-01").await; + sync(a.pool(), b.pool()).await; + sync(b.pool(), a.pool()).await; + assert_deleted(a.pool(), true).await; + assert_deleted(b.pool(), true).await; + let fresh = test_db().await; + sync(a.pool(), fresh.pool()).await; + assert_deleted(fresh.pool(), true).await; + } +} + +#[tokio::test] +async fn unseen_note_edits_and_audio_without_transcription_survive() { + for change in [ + "UPDATE session_documents SET body_format = 'markdown', body = 'User notes' WHERE id = 'meeting'", + "INSERT INTO session_attachments (id, workspace_id, session_id, size_bytes, sha256) + VALUES ('audio-b', 'workspace-a', 'meeting', 1234, 'recorded-on-mobile')", + ] { + let a = test_db().await; + let b = test_db().await; + seed(a.pool()).await; + sync(a.pool(), b.pool()).await; + sqlx::raw_sql(sqlx::AssertSqlSafe(change)) + .execute(b.pool()) + .await + .unwrap(); + delete(a.pool(), "2099-01-01").await; + sync(a.pool(), b.pool()).await; + sync(b.pool(), a.pool()).await; + assert_deleted(a.pool(), false).await; + assert_deleted(b.pool(), false).await; + } +} + +#[tokio::test] +async fn empty_rows_and_metadata_do_not_resurrect_a_deleted_note() { + let a = test_db().await; + let b = test_db().await; + seed(a.pool()).await; + sync(a.pool(), b.pool()).await; + sqlx::raw_sql("INSERT INTO transcripts (id, workspace_id, session_id) VALUES ('empty', 'workspace-a', 'meeting'); + UPDATE sessions SET ended_at = '2099-02-01' WHERE id = 'meeting'; + UPDATE session_documents SET body = '{\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\"}]}' WHERE id = 'meeting';") + .execute(b.pool()).await.unwrap(); + delete(a.pool(), "2099-01-01").await; + sync(a.pool(), b.pool()).await; + sync(b.pool(), a.pool()).await; + assert_deleted(a.pool(), true).await; + assert_deleted(b.pool(), true).await; +} + +#[tokio::test] +async fn local_undo_clears_the_deletion_observation() { + let a = test_db().await; + let b = test_db().await; + seed(a.pool()).await; + delete(a.pool(), "2099-01-01").await; + sync(a.pool(), b.pool()).await; + sqlx::query("UPDATE sessions SET deleted_at = NULL WHERE id = 'meeting'") + .execute(a.pool()) + .await + .unwrap(); + sync(a.pool(), b.pool()).await; + assert_deleted(b.pool(), false).await; +} + +#[tokio::test] +async fn replacement_audio_survives_deletion_of_the_older_recording() { + let a = test_db().await; + let b = test_db().await; + seed(a.pool()).await; + sqlx::query( + "INSERT INTO session_attachments (id, workspace_id, session_id, size_bytes, sha256) + VALUES ('session-audio:meeting', 'workspace-a', 'meeting', 100, 'empty-device-a')", + ) + .execute(a.pool()) + .await + .unwrap(); + sync(a.pool(), b.pool()).await; + sqlx::query( + "UPDATE session_attachments SET size_bytes = 12345, sha256 = 'useful-device-b' + WHERE id = 'session-audio:meeting'", + ) + .execute(b.pool()) + .await + .unwrap(); + delete(a.pool(), "2099-01-01").await; + sync(a.pool(), b.pool()).await; + sync(b.pool(), a.pool()).await; + for db in [&a, &b] { + assert_deleted(db.pool(), false).await; + let hash: String = sqlx::query_scalar( + "SELECT sha256 FROM session_attachments + WHERE id = 'session-audio:meeting' AND deleted_at IS NULL", + ) + .fetch_one(db.pool()) + .await + .unwrap(); + assert_eq!(hash, "useful-device-b"); + } +} + +#[tokio::test] +async fn recording_arriving_after_the_deleted_parent_restores_the_note() { + let a = test_db().await; + let b = test_db().await; + let fresh = test_db().await; + seed(a.pool()).await; + sync(a.pool(), b.pool()).await; + delete(a.pool(), "2099-01-01").await; + sync(a.pool(), fresh.pool()).await; + assert_deleted(fresh.pool(), true).await; + sqlx::query( + "INSERT INTO transcripts (id, workspace_id, session_id, words_json) + VALUES ('late', 'workspace-a', 'meeting', '[{\"text\":\"Late offline recording\"}]')", + ) + .execute(b.pool()) + .await + .unwrap(); + sync(b.pool(), fresh.pool()).await; + assert_deleted(fresh.pool(), false).await; + sync(fresh.pool(), a.pool()).await; + assert_deleted(a.pool(), false).await; +} + +#[tokio::test] +async fn late_related_tombstones_follow_the_restored_note() { + let db = test_db().await; + seed(db.pool()).await; + delete(db.pool(), "2099-01-01").await; + sqlx::query( + "INSERT INTO transcripts (id, workspace_id, session_id, words_json) + VALUES ('late', 'workspace-a', 'meeting', '[{\"text\":\"Offline recording\"}]')", + ) + .execute(db.pool()) + .await + .unwrap(); + for (table, insert) in [ + ( + "session_tags", + "INSERT INTO session_tags (id, workspace_id, session_id, deleted_at) + VALUES ('related', 'workspace-a', 'meeting', '2099-01-01')", + ), + ( + "entity_mentions", + "INSERT INTO entity_mentions (id, workspace_id, target_type, target_id, deleted_at) + VALUES ('related', 'workspace-a', 'session', 'meeting', '2099-01-01')", + ), + ] { + let mut tx = db.pool().begin().await.unwrap(); + sqlx::raw_sql(sqlx::AssertSqlSafe(insert)) + .execute(&mut *tx) + .await + .unwrap(); + crate::session_deletion::reconcile_session_deletion( + &mut tx, + "workspace-a", + table, + "related", + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + let live: bool = sqlx::query_scalar(sqlx::AssertSqlSafe(format!( + "SELECT deleted_at IS NULL FROM {table} WHERE id = 'related'" + ))) + .fetch_one(db.pool()) + .await + .unwrap(); + assert!(live); + } + assert_deleted(db.pool(), false).await; +} + +#[tokio::test] +async fn upgrade_preserves_existing_content_and_legacy_deletions() { + let db = anlg_db_core::Db::connect_memory_plain().await.unwrap(); + let index = crate::APP_MIGRATION_STEPS + .iter() + .position(|step| step.id == "20260907120000_session_documents_content_version") + .unwrap(); + anlg_db_migrate::migrate( + &db, + anlg_db_migrate::DbSchema { + steps: &crate::APP_MIGRATION_STEPS[..index], + validate_cloudsync_table: crate::cloudsync_alter_guard_required, + }, + ) + .await + .unwrap(); + seed(db.pool()).await; + sqlx::raw_sql("UPDATE session_documents SET body_format = 'markdown', body = 'Existing note'; + INSERT INTO sessions (id, workspace_id, deleted_at) VALUES ('legacy-deleted', 'workspace-a', 'old');") + .execute(db.pool()).await.unwrap(); + crate::prepare_schema(&db).await.unwrap(); + let content: (String, String) = + sqlx::query_as("SELECT body, content_version FROM session_documents WHERE id = 'meeting'") + .fetch_one(db.pool()) + .await + .unwrap(); + assert_eq!(content, ("Existing note".into(), "".into())); + let legacy: (String, String) = sqlx::query_as( + "SELECT deleted_at, deletion_context FROM sessions WHERE id = 'legacy-deleted'", + ) + .fetch_one(db.pool()) + .await + .unwrap(); + assert_eq!(legacy, ("old".into(), "".into())); + delete(db.pool(), "2099-01-01").await; + let fresh = test_db().await; + sync(db.pool(), fresh.pool()).await; + assert_deleted(fresh.pool(), true).await; +} diff --git a/crates/db-app/src/lib.rs b/crates/db-app/src/lib.rs index 34f213585ef..f9979be423d 100644 --- a/crates/db-app/src/lib.rs +++ b/crates/db-app/src/lib.rs @@ -7,6 +7,7 @@ mod e2ee; mod event_ops; mod event_types; mod legacy_import; +mod session_deletion; mod session_ops; mod session_types; mod template_ops; @@ -424,6 +425,32 @@ pub const APP_MIGRATION_STEPS: &[anlg_db_migrate::MigrationStep] = &[ scope: anlg_db_migrate::MigrationScope::Plain, sql: include_str!("../migrations/20260903120000_voiceprint_exemplars_isolated_mic.sql"), }, + anlg_db_migrate::MigrationStep { + id: "20260907120000_session_documents_content_version", + scope: anlg_db_migrate::MigrationScope::CloudsyncAlter { + table_name: "session_documents", + }, + sql: include_str!("../migrations/20260907120000_session_documents_content_version.sql"), + }, + anlg_db_migrate::MigrationStep { + id: "20260907120100_transcripts_content_version", + scope: anlg_db_migrate::MigrationScope::CloudsyncAlter { + table_name: "transcripts", + }, + sql: include_str!("../migrations/20260907120100_transcripts_content_version.sql"), + }, + anlg_db_migrate::MigrationStep { + id: "20260907120200_session_content_observations", + scope: anlg_db_migrate::MigrationScope::Plain, + sql: include_str!("../migrations/20260907120200_session_content_observations.sql"), + }, + anlg_db_migrate::MigrationStep { + id: "20260907120300_session_deletion_context", + scope: anlg_db_migrate::MigrationScope::CloudsyncAlter { + table_name: "sessions", + }, + sql: include_str!("../migrations/20260907120300_session_deletion_context.sql"), + }, ]; pub fn schema() -> anlg_db_migrate::DbSchema { diff --git a/crates/db-app/src/session_deletion.rs b/crates/db-app/src/session_deletion.rs new file mode 100644 index 00000000000..08719f7482d --- /dev/null +++ b/crates/db-app/src/session_deletion.rs @@ -0,0 +1,172 @@ +use std::collections::BTreeMap; + +use serde::Deserialize; +use sqlx::{Sqlite, Transaction}; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct DeletionContext { + version: u32, + deleted_at: String, + observed: BTreeMap, +} + +pub(crate) async fn reconcile_session_deletion( + transaction: &mut Transaction<'_, Sqlite>, + workspace_id: &str, + table: &str, + row_id: &str, +) -> Result<(), sqlx::Error> { + let session_ids = match table { + "sessions" => vec![row_id.to_owned()], + "session_documents" + | "transcripts" + | "session_attachments" + | "session_participants" + | "session_tags" + | "action_items" => { + let query = format!("SELECT session_id FROM {table} WHERE id = ? AND workspace_id = ?"); + let Some(id) = sqlx::query_scalar::<_, String>(sqlx::AssertSqlSafe(query)) + .bind(row_id) + .bind(workspace_id) + .fetch_optional(&mut **transaction) + .await? + else { + return Ok(()); + }; + vec![id] + } + "entity_mentions" => { + sqlx::query_scalar::<_, String>( + "SELECT source_id FROM entity_mentions + WHERE id = ?1 AND workspace_id = ?2 AND source_type = 'session' + UNION SELECT target_id FROM entity_mentions + WHERE id = ?1 AND workspace_id = ?2 AND target_type = 'session'", + ) + .bind(row_id) + .bind(workspace_id) + .fetch_all(&mut **transaction) + .await? + } + _ => return Ok(()), + }; + for session_id in session_ids { + reconcile_deleted_session(transaction, workspace_id, &session_id).await?; + } + Ok(()) +} + +async fn reconcile_deleted_session( + transaction: &mut Transaction<'_, Sqlite>, + workspace_id: &str, + session_id: &str, +) -> Result<(), sqlx::Error> { + let Some((context, current_deleted_at)) = sqlx::query_as::<_, (String, Option)>( + "SELECT deletion_context, deleted_at FROM sessions + WHERE id = ? AND workspace_id = ? AND deletion_context <> ''", + ) + .bind(session_id) + .bind(workspace_id) + .fetch_optional(&mut **transaction) + .await? + else { + return Ok(()); + }; + let Ok(context) = serde_json::from_str::(&context) else { + return Ok(()); + }; + if context.version != 1 || context.deleted_at.is_empty() { + return Ok(()); + } + + let content: Vec<(String, String)> = sqlx::query_as( + "SELECT content_id, content_version FROM session_content_observations + WHERE session_id = ? AND workspace_id = ? + AND (deleted_at IS NULL OR deleted_at = ?)", + ) + .bind(session_id) + .bind(workspace_id) + .bind(&context.deleted_at) + .fetch_all(&mut **transaction) + .await?; + let unseen_content = content + .iter() + .any(|(id, version)| context.observed.get(id) != Some(version)); + let deleted_at = (!unseen_content).then_some(context.deleted_at.as_str()); + + // Keep the original observation even after restoring the note. Later row + // groups must reach the same result regardless of their delivery order. + sqlx::query( + "INSERT OR IGNORE INTO e2ee_apply_guard (workspace_id, table_name, row_id) + VALUES (?, 'sessions', ?)", + ) + .bind(workspace_id) + .bind(session_id) + .execute(&mut **transaction) + .await?; + if current_deleted_at.as_deref() != deleted_at { + sqlx::query("UPDATE sessions SET deleted_at = ? WHERE id = ? AND workspace_id = ?") + .bind(deleted_at) + .bind(session_id) + .bind(workspace_id) + .execute(&mut **transaction) + .await?; + sqlx::query( + "INSERT INTO e2ee_dirty_rows (workspace_id, table_name, row_id) + VALUES (?, 'sessions', ?) + ON CONFLICT(workspace_id, table_name, row_id) + DO UPDATE SET generation = generation + 1", + ) + .bind(workspace_id) + .bind(session_id) + .execute(&mut **transaction) + .await?; + } + let predicate = if unseen_content { + "deleted_at = ?" + } else { + "deleted_at IS NULL AND ? IS NOT NULL" + }; + for table in [ + "session_documents", + "transcripts", + "session_attachments", + "session_participants", + "session_tags", + "action_items", + ] { + let query = format!( + "UPDATE {table} SET deleted_at = ? + WHERE session_id = ? AND workspace_id = ? AND {predicate}" + ); + sqlx::query(sqlx::AssertSqlSafe(query)) + .bind(deleted_at) + .bind(session_id) + .bind(workspace_id) + .bind(&context.deleted_at) + .execute(&mut **transaction) + .await?; + } + sqlx::query(sqlx::AssertSqlSafe(format!( + "UPDATE entity_mentions SET deleted_at = ? + WHERE workspace_id = ? AND ( + (source_type = 'session' AND source_id = ?) + OR (target_type = 'session' AND target_id = ?) + ) AND {predicate}" + ))) + .bind(deleted_at) + .bind(workspace_id) + .bind(session_id) + .bind(session_id) + .bind(&context.deleted_at) + .execute(&mut **transaction) + .await?; + sqlx::query( + "DELETE FROM e2ee_apply_guard WHERE workspace_id = ? AND table_name = 'sessions' AND row_id = ?", + ) + .bind(workspace_id) + .bind(session_id) + .execute(&mut **transaction) + .await?; + Ok(()) +} diff --git a/crates/fs-sync-core/Cargo.toml b/crates/fs-sync-core/Cargo.toml index b95ea30348c..bfe4da65bfb 100644 --- a/crates/fs-sync-core/Cargo.toml +++ b/crates/fs-sync-core/Cargo.toml @@ -13,6 +13,7 @@ anlg-audio-utils = { workspace = true } anlg-frontmatter = { workspace = true } anlg-fs-format = { workspace = true } anlg-tiptap = { workspace = true } +anlg-vad = { workspace = true, features = ["earshot"] } serde = { workspace = true } serde_json = { workspace = true } @@ -23,6 +24,7 @@ tauri-specta = { workspace = true, optional = true, features = ["derive"] } glob = "0.3" rayon = { workspace = true } +rodio = { workspace = true } chrono = { workspace = true } thiserror = { workspace = true } diff --git a/crates/fs-sync-core/src/audio/activity.rs b/crates/fs-sync-core/src/audio/activity.rs new file mode 100644 index 00000000000..0476bb8fab0 --- /dev/null +++ b/crates/fs-sync-core/src/audio/activity.rs @@ -0,0 +1,120 @@ +use std::{io, path::Path}; + +use anlg_audio_utils::Source; +use anlg_vad::earshot::{FRAME_10MS, VoiceActivityDetector}; + +pub fn has_speech(path: &Path) -> io::Result { + let source = anlg_audio_utils::source_from_path(path).map_err(io::Error::other)?; + source_has_speech(source) +} + +fn source_has_speech(source: impl Source) -> io::Result { + let channels = source.channels(); + let sample_rate = source.sample_rate(); + let expected_frames = source + .total_duration() + .filter(|duration| !duration.is_zero()) + .ok_or_else(|| io::Error::other("audio_duration_unknown"))? + .as_secs_f64() + * 16_000.0; + let mut source = rodio::conversions::SampleRateConverter::new( + source, + sample_rate, + std::num::NonZeroU32::new(16_000).unwrap(), + channels, + ); + let mut detectors = (0..channels.get()) + .map(|_| VoiceActivityDetector::new()) + .collect::>(); + let mut frames = vec![[0_i16; FRAME_10MS]; usize::from(channels.get())]; + let mut read_frames = 0; + loop { + let mut samples = 0; + for index in 0..FRAME_10MS { + for frame in &mut frames { + let Some(sample) = source.next() else { + // A truncated or undecodable recording is not proof of silence. + if (read_frames + samples) as f64 + 160.0 < expected_frames { + return Err(io::Error::other("audio_decode_incomplete")); + } + for (detector, frame) in detectors.iter_mut().zip(&mut frames) { + frame[index..].fill(0); + if detector.predict_16khz(frame).map_err(io::Error::other)? { + return Ok(true); + } + } + return Ok(false); + }; + if !sample.is_finite() { + return Err(io::Error::other("audio_sample_invalid")); + } + frame[index] = (sample * 32768.0).clamp(-32768.0, 32767.0) as i16; + } + samples += 1; + } + read_frames += samples; + // Inspect channels separately: downmixing could cancel opposite-phase speech. + for (detector, frame) in detectors.iter_mut().zip(&frames) { + if detector.predict_16khz(frame).map_err(io::Error::other)? { + return Ok(true); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn speech_is_kept_in_wav_and_compressed_audio() { + for path in [ + anlg_data::english_1::AUDIO_PATH, + anlg_data::english_1::AUDIO_MP3_PATH, + ] { + assert!(has_speech(Path::new(path)).unwrap()); + } + } + + #[test] + fn opposite_phase_channels_do_not_cancel_speech() { + let samples: Vec = + anlg_audio_utils::source_from_path(anlg_data::english_1::AUDIO_PATH) + .unwrap() + .take(160_000) + .collect(); + for right_only in [true, false] { + let stereo = samples + .iter() + .flat_map(|sample| [if right_only { 0.0 } else { -*sample }, *sample]) + .collect::>(); + let source = rodio::buffer::SamplesBuffer::new( + std::num::NonZeroU16::new(2).unwrap(), + std::num::NonZeroU32::new(16_000).unwrap(), + stereo, + ); + assert!(source_has_speech(source).unwrap()); + } + } + + #[test] + fn silence_in_both_channels_is_empty() { + let source = rodio::buffer::SamplesBuffer::new( + std::num::NonZeroU16::new(2).unwrap(), + std::num::NonZeroU32::new(48_000).unwrap(), + vec![0.0; 96_000], + ); + assert!(!source_has_speech(source).unwrap()); + } + + #[test] + fn invalid_and_missing_audio_are_not_classified_as_empty() { + assert!(has_speech(Path::new("/nonexistent/automatic-capture.wav")).is_err()); + let source = rodio::buffer::SamplesBuffer::new( + std::num::NonZeroU16::new(1).unwrap(), + std::num::NonZeroU32::new(16_000).unwrap(), + vec![f32::NAN; 160], + ); + assert!(source_has_speech(source).is_err()); + } +} diff --git a/crates/fs-sync-core/src/audio/mod.rs b/crates/fs-sync-core/src/audio/mod.rs index 5ed38566231..00f90b892a9 100644 --- a/crates/fs-sync-core/src/audio/mod.rs +++ b/crates/fs-sync-core/src/audio/mod.rs @@ -10,6 +10,9 @@ use crate::runtime::{AudioImportEvent, AudioImportRuntime}; use chrono::{DateTime, Utc}; use sha2::{Digest, Sha256}; +mod activity; +pub use activity::has_speech; + const AUDIO_FORMATS: [&str; 3] = ["audio.mp3", "audio.wav", "audio.ogg"]; const AUDIO_ARTIFACTS: [&str; 7] = [ "audio.mp3", diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 449d7abc29d..0b727bdf140 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -172,6 +172,7 @@ export const sessions = sqliteTable( folderPath: text("folder_path").notNull().default(""), slug: text("slug").notNull().default(""), metadataJson: text("metadata_json").notNull().default("{}"), + deletionContext: text("deletion_context").notNull().default(""), deletedAt: text("deleted_at"), }, (table) => [ @@ -201,6 +202,7 @@ export const sessionDocuments = sqliteTable( updatedBy: text("updated_by").notNull().default(""), createdAt: text("created_at").notNull().default(currentTimestamp), updatedAt: text("updated_at").notNull().default(currentTimestamp), + contentVersion: text("content_version").notNull().default(""), deletedAt: text("deleted_at"), }, (table) => [ @@ -311,6 +313,7 @@ export const transcripts = sqliteTable( metadataJson: text("metadata_json").notNull().default("{}"), createdAt: text("created_at").notNull().default(currentTimestamp), updatedAt: text("updated_at").notNull().default(currentTimestamp), + contentVersion: text("content_version").notNull().default(""), deletedAt: text("deleted_at"), }, (table) => [index("idx_transcripts_session_id").on(table.sessionId)], diff --git a/plugins/fs-sync/build.rs b/plugins/fs-sync/build.rs index 4383bd12045..67982558558 100644 --- a/plugins/fs-sync/build.rs +++ b/plugins/fs-sync/build.rs @@ -15,6 +15,7 @@ const COMMANDS: &[&str] = &[ "audio_import", "audio_import_data", "audio_source_metadata", + "audio_has_speech", "audio_path", "audio_copy", "session_dir", diff --git a/plugins/fs-sync/js/bindings.gen.ts b/plugins/fs-sync/js/bindings.gen.ts index 3dea749c69c..9e060fe1c3f 100644 --- a/plugins/fs-sync/js/bindings.gen.ts +++ b/plugins/fs-sync/js/bindings.gen.ts @@ -1,668 +1,370 @@ // @ts-nocheck + // This file was generated by [tauri-specta](https://github.com/oscartbeaumont/tauri-specta). Do not edit this file manually. /** user-defined commands **/ + export const commands = { - async deserialize(input: string): Promise> { - try { - return { - status: "ok", - data: await TAURI_INVOKE("plugin:fs-sync|deserialize", { input }), - }; - } catch (e) { - if (e instanceof Error) throw e; - else return { status: "error", error: e as any }; - } - }, - async writeJsonBatch( - items: [JsonValue, string][], - ): Promise> { - try { - return { - status: "ok", - data: await TAURI_INVOKE("plugin:fs-sync|write_json_batch", { items }), - }; - } catch (e) { - if (e instanceof Error) throw e; - else return { status: "error", error: e as any }; - } - }, - async writeDocumentBatch( - items: [ParsedDocument, string][], - ): Promise> { - try { - return { - status: "ok", - data: await TAURI_INVOKE("plugin:fs-sync|write_document_batch", { - items, - }), - }; - } catch (e) { - if (e instanceof Error) throw e; - else return { status: "error", error: e as any }; - } - }, - async readDocumentBatch( - dirPath: string, - ): Promise, string>> { - try { - return { - status: "ok", - data: await TAURI_INVOKE("plugin:fs-sync|read_document_batch", { - dirPath, - }), - }; - } catch (e) { - if (e instanceof Error) throw e; - else return { status: "error", error: e as any }; - } - }, - async listFolders(): Promise> { - try { - return { - status: "ok", - data: await TAURI_INVOKE("plugin:fs-sync|list_folders"), - }; - } catch (e) { - if (e instanceof Error) throw e; - else return { status: "error", error: e as any }; - } - }, - async moveSession( - sessionId: string, - fromFolderPath: string, - targetFolderPath: string, - ): Promise> { - try { - return { - status: "ok", - data: await TAURI_INVOKE("plugin:fs-sync|move_session", { - sessionId, - fromFolderPath, - targetFolderPath, - }), - }; - } catch (e) { - if (e instanceof Error) throw e; - else return { status: "error", error: e as any }; - } - }, - async createFolder(folderPath: string): Promise> { - try { - return { - status: "ok", - data: await TAURI_INVOKE("plugin:fs-sync|create_folder", { - folderPath, - }), - }; - } catch (e) { - if (e instanceof Error) throw e; - else return { status: "error", error: e as any }; - } - }, - async renameFolder( - oldPath: string, - newPath: string, - ): Promise> { - try { - return { - status: "ok", - data: await TAURI_INVOKE("plugin:fs-sync|rename_folder", { - oldPath, - newPath, - }), - }; - } catch (e) { - if (e instanceof Error) throw e; - else return { status: "error", error: e as any }; - } - }, - async deleteFolder(folderPath: string): Promise> { - try { - return { - status: "ok", - data: await TAURI_INVOKE("plugin:fs-sync|delete_folder", { - folderPath, - }), - }; - } catch (e) { - if (e instanceof Error) throw e; - else return { status: "error", error: e as any }; - } - }, - async audioExist(sessionId: string): Promise> { - try { - return { - status: "ok", - data: await TAURI_INVOKE("plugin:fs-sync|audio_exist", { sessionId }), - }; - } catch (e) { - if (e instanceof Error) throw e; - else return { status: "error", error: e as any }; - } - }, - async audioDelete(sessionId: string): Promise> { - try { - return { - status: "ok", - data: await TAURI_INVOKE("plugin:fs-sync|audio_delete", { sessionId }), - }; - } catch (e) { - if (e instanceof Error) throw e; - else return { status: "error", error: e as any }; - } - }, - async audioMetadata( - sessionId: string, - ): Promise> { - try { - return { - status: "ok", - data: await TAURI_INVOKE("plugin:fs-sync|audio_metadata", { - sessionId, - }), - }; - } catch (e) { - if (e instanceof Error) throw e; - else return { status: "error", error: e as any }; - } - }, - async audioDeleteOrphanedExpired( - knownSessionIds: string[], - retentionMs: number, - nowMs: number, - ): Promise> { - try { - return { - status: "ok", - data: await TAURI_INVOKE( - "plugin:fs-sync|audio_delete_orphaned_expired", - { knownSessionIds, retentionMs, nowMs }, - ), - }; - } catch (e) { - if (e instanceof Error) throw e; - else return { status: "error", error: e as any }; - } - }, - async audioImport( - sessionId: string, - sourcePath: string, - ): Promise> { - try { - return { - status: "ok", - data: await TAURI_INVOKE("plugin:fs-sync|audio_import", { - sessionId, - sourcePath, - }), - }; - } catch (e) { - if (e instanceof Error) throw e; - else return { status: "error", error: e as any }; - } - }, - async audioImportData( - sessionId: string, - data: number[], - filename: string, - contentType: string | null, - ): Promise> { - try { - return { - status: "ok", - data: await TAURI_INVOKE("plugin:fs-sync|audio_import_data", { - sessionId, - data, - filename, - contentType, - }), - }; - } catch (e) { - if (e instanceof Error) throw e; - else return { status: "error", error: e as any }; - } - }, - async audioSourceMetadata( - sourcePath: string, - ): Promise> { - try { - return { - status: "ok", - data: await TAURI_INVOKE("plugin:fs-sync|audio_source_metadata", { - sourcePath, - }), - }; - } catch (e) { - if (e instanceof Error) throw e; - else return { status: "error", error: e as any }; - } - }, - async audioPath(sessionId: string): Promise> { - try { - return { - status: "ok", - data: await TAURI_INVOKE("plugin:fs-sync|audio_path", { sessionId }), - }; - } catch (e) { - if (e instanceof Error) throw e; - else return { status: "error", error: e as any }; - } - }, - async audioCopy( - sourceSessionId: string, - targetSessionId: string, - ): Promise> { - try { - return { - status: "ok", - data: await TAURI_INVOKE("plugin:fs-sync|audio_copy", { - sourceSessionId, - targetSessionId, - }), - }; - } catch (e) { - if (e instanceof Error) throw e; - else return { status: "error", error: e as any }; - } - }, - async sessionDir(sessionId: string): Promise> { - try { - return { - status: "ok", - data: await TAURI_INVOKE("plugin:fs-sync|session_dir", { sessionId }), - }; - } catch (e) { - if (e instanceof Error) throw e; - else return { status: "error", error: e as any }; - } - }, - async loadSessionContent( - sessionId: string, - ): Promise> { - try { - return { - status: "ok", - data: await TAURI_INVOKE("plugin:fs-sync|load_session_content", { - sessionId, - }), - }; - } catch (e) { - if (e instanceof Error) throw e; - else return { status: "error", error: e as any }; - } - }, - async deleteSessionFolder(sessionId: string): Promise> { - try { - return { - status: "ok", - data: await TAURI_INVOKE("plugin:fs-sync|delete_session_folder", { - sessionId, - }), - }; - } catch (e) { - if (e instanceof Error) throw e; - else return { status: "error", error: e as any }; - } - }, - async scanAndRead( - scanDir: string, - filePatterns: string[], - recursive: boolean, - pathFilter: string | null, - ): Promise> { - try { - return { - status: "ok", - data: await TAURI_INVOKE("plugin:fs-sync|scan_and_read", { - scanDir, - filePatterns, - recursive, - pathFilter, - }), - }; - } catch (e) { - if (e instanceof Error) throw e; - else return { status: "error", error: e as any }; - } - }, - async chatDir(chatGroupId: string): Promise> { - try { - return { - status: "ok", - data: await TAURI_INVOKE("plugin:fs-sync|chat_dir", { chatGroupId }), - }; - } catch (e) { - if (e instanceof Error) throw e; - else return { status: "error", error: e as any }; - } - }, - async entityDir(dirName: string): Promise> { - try { - return { - status: "ok", - data: await TAURI_INVOKE("plugin:fs-sync|entity_dir", { dirName }), - }; - } catch (e) { - if (e instanceof Error) throw e; - else return { status: "error", error: e as any }; - } - }, - async attachmentSave( - sessionId: string, - data: number[], - filename: string, - ): Promise> { - try { - return { - status: "ok", - data: await TAURI_INVOKE("plugin:fs-sync|attachment_save", { - sessionId, - data, - filename, - }), - }; - } catch (e) { - if (e instanceof Error) throw e; - else return { status: "error", error: e as any }; - } - }, - async attachmentList( - sessionId: string, - ): Promise> { - try { - return { - status: "ok", - data: await TAURI_INVOKE("plugin:fs-sync|attachment_list", { - sessionId, - }), - }; - } catch (e) { - if (e instanceof Error) throw e; - else return { status: "error", error: e as any }; - } - }, - async attachmentRead( - sessionId: string, - attachmentId: string, - ): Promise> { - try { - return { - status: "ok", - data: await TAURI_INVOKE("plugin:fs-sync|attachment_read", { - sessionId, - attachmentId, - }), - }; - } catch (e) { - if (e instanceof Error) throw e; - else return { status: "error", error: e as any }; - } - }, - async attachmentRemove( - sessionId: string, - attachmentId: string, - ): Promise> { - try { - return { - status: "ok", - data: await TAURI_INVOKE("plugin:fs-sync|attachment_remove", { - sessionId, - attachmentId, - }), - }; - } catch (e) { - if (e instanceof Error) throw e; - else return { status: "error", error: e as any }; - } - }, - async folderAttachmentSave( - folderPath: string, - data: number[], - filename: string, - ): Promise> { - try { - return { - status: "ok", - data: await TAURI_INVOKE("plugin:fs-sync|folder_attachment_save", { - folderPath, - data, - filename, - }), - }; - } catch (e) { - if (e instanceof Error) throw e; - else return { status: "error", error: e as any }; - } - }, - async folderAttachmentList( - folderPath: string, - ): Promise> { - try { - return { - status: "ok", - data: await TAURI_INVOKE("plugin:fs-sync|folder_attachment_list", { - folderPath, - }), - }; - } catch (e) { - if (e instanceof Error) throw e; - else return { status: "error", error: e as any }; - } - }, - async folderAttachmentRead( - folderPath: string, - attachmentId: string, - ): Promise> { - try { - return { - status: "ok", - data: await TAURI_INVOKE("plugin:fs-sync|folder_attachment_read", { - folderPath, - attachmentId, - }), - }; - } catch (e) { - if (e instanceof Error) throw e; - else return { status: "error", error: e as any }; - } - }, - async folderAttachmentRemove( - folderPath: string, - attachmentId: string, - ): Promise> { - try { - return { - status: "ok", - data: await TAURI_INVOKE("plugin:fs-sync|folder_attachment_remove", { - folderPath, - attachmentId, - }), - }; - } catch (e) { - if (e instanceof Error) throw e; - else return { status: "error", error: e as any }; - } - }, -}; +async deserialize(input: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:fs-sync|deserialize", { input }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async writeJsonBatch(items: ([JsonValue, string])[]) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:fs-sync|write_json_batch", { items }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async writeDocumentBatch(items: ([ParsedDocument, string])[]) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:fs-sync|write_document_batch", { items }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async readDocumentBatch(dirPath: string) : Promise, string>> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:fs-sync|read_document_batch", { dirPath }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async listFolders() : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:fs-sync|list_folders") }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async moveSession(sessionId: string, fromFolderPath: string, targetFolderPath: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:fs-sync|move_session", { sessionId, fromFolderPath, targetFolderPath }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async createFolder(folderPath: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:fs-sync|create_folder", { folderPath }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async renameFolder(oldPath: string, newPath: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:fs-sync|rename_folder", { oldPath, newPath }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async deleteFolder(folderPath: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:fs-sync|delete_folder", { folderPath }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async audioExist(sessionId: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:fs-sync|audio_exist", { sessionId }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async audioDelete(sessionId: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:fs-sync|audio_delete", { sessionId }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async audioMetadata(sessionId: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:fs-sync|audio_metadata", { sessionId }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async audioDeleteOrphanedExpired(knownSessionIds: string[], retentionMs: number, nowMs: number) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:fs-sync|audio_delete_orphaned_expired", { knownSessionIds, retentionMs, nowMs }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async audioImport(sessionId: string, sourcePath: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:fs-sync|audio_import", { sessionId, sourcePath }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async audioImportData(sessionId: string, data: number[], filename: string, contentType: string | null) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:fs-sync|audio_import_data", { sessionId, data, filename, contentType }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async audioSourceMetadata(sourcePath: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:fs-sync|audio_source_metadata", { sourcePath }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async audioHasSpeech(sessionId: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:fs-sync|audio_has_speech", { sessionId }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async audioPath(sessionId: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:fs-sync|audio_path", { sessionId }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async audioCopy(sourceSessionId: string, targetSessionId: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:fs-sync|audio_copy", { sourceSessionId, targetSessionId }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async sessionDir(sessionId: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:fs-sync|session_dir", { sessionId }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async loadSessionContent(sessionId: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:fs-sync|load_session_content", { sessionId }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async deleteSessionFolder(sessionId: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:fs-sync|delete_session_folder", { sessionId }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async scanAndRead(scanDir: string, filePatterns: string[], recursive: boolean, pathFilter: string | null) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:fs-sync|scan_and_read", { scanDir, filePatterns, recursive, pathFilter }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async chatDir(chatGroupId: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:fs-sync|chat_dir", { chatGroupId }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async entityDir(dirName: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:fs-sync|entity_dir", { dirName }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async attachmentSave(sessionId: string, data: number[], filename: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:fs-sync|attachment_save", { sessionId, data, filename }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async attachmentList(sessionId: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:fs-sync|attachment_list", { sessionId }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async attachmentRead(sessionId: string, attachmentId: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:fs-sync|attachment_read", { sessionId, attachmentId }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async attachmentRemove(sessionId: string, attachmentId: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:fs-sync|attachment_remove", { sessionId, attachmentId }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async folderAttachmentSave(folderPath: string, data: number[], filename: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:fs-sync|folder_attachment_save", { folderPath, data, filename }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async folderAttachmentList(folderPath: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:fs-sync|folder_attachment_list", { folderPath }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async folderAttachmentRead(folderPath: string, attachmentId: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:fs-sync|folder_attachment_read", { folderPath, attachmentId }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async folderAttachmentRemove(folderPath: string, attachmentId: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:fs-sync|folder_attachment_remove", { folderPath, attachmentId }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +} +} /** user-defined events **/ + export const events = __makeEvents__<{ - audioImportEvent: AudioImportEvent; +audioImportEvent: AudioImportEvent }>({ - audioImportEvent: "plugin:fs-sync:audio-import-event", -}); +audioImportEvent: "plugin:fs-sync:audio-import-event" +}) /** user-defined constants **/ + + /** user-defined types **/ -export type AttachmentInfo = { - attachmentId: string; - path: string; - extension: string; - modifiedAt: string; -}; -export type AttachmentSaveResult = { path: string; attachmentId: string }; -export type AudioFileMetadata = { - filename: string; - contentType: string; - sizeBytes: number; - sha256: string; -}; -export type AudioImportEvent = - | { type: "audioImportStarted"; session_id: string } - | { type: "audioImportProgress"; session_id: string; percentage: number } - | { type: "audioImportCompleted"; session_id: string } - | { type: "audioImportFailed"; session_id: string; error: string }; -export type AudioSourceMetadata = { - createdAt: string | null; - modifiedAt: string | null; - durationMs: number | null; -}; -export type FolderInfo = { name: string; parent_folder_id: string | null }; -export type FolderSessionUpdate = { sessionId: string; folderId: string }; -export type JsonValue = - | null - | boolean - | number - | string - | JsonValue[] - | Partial<{ [key in string]: JsonValue }>; -export type ListFoldersResult = { - folders: Partial<{ [key in string]: FolderInfo }>; - session_folder_map: Partial<{ [key in string]: string }>; -}; -export type MoveSessionResult = { sessionId: string; folderId: string }; -export type ParsedDocument = { - frontmatter: Partial<{ [key in string]: JsonValue }>; - content: string; -}; -export type RenameFolderResult = { updates: FolderSessionUpdate[] }; -export type ScanResult = { - files: Partial<{ [key in string]: string }>; - dirs: string[]; -}; -export type SessionContentData = { - sessionId: string; - meta: SessionMetaData | null; - rawMemoTiptapJson: JsonValue | null; - rawMemoMarkdown: string | null; - transcript: TranscriptJson | null; - notes: SessionNoteData[]; -}; -export type SessionMetaData = { - id: string; - userId: string; - createdAt: string | null; - title: string | null; - event: JsonValue | null; - eventId: string | null; - participants: SessionMetaParticipant[]; - tags: string[]; -}; -export type SessionMetaParticipant = { - id: string; - userId: string; - sessionId: string; - humanId: string; - source: string; -}; -export type SessionNoteData = { - id: string; - sessionId: string; - templateId: string | null; - position: number | null; - title: string | null; - tiptapJson: JsonValue; - markdown: string | null; -}; -export type TranscriptJson = { transcripts?: TranscriptWithData[] }; -export type TranscriptSpeakerHint = { - id?: string | null; - word_id: string; - type: string; - value?: JsonValue; -}; -export type TranscriptWithData = { - id: string; - user_id?: string; - created_at?: string; - session_id: string; - started_at?: number; - ended_at?: number | null; - memo_md?: string; - words?: TranscriptWord[]; - speaker_hints?: TranscriptSpeakerHint[]; -}; -export type TranscriptWord = { - id?: string | null; - text: string; - start_ms: number; - end_ms: number; - channel: number; - speaker?: string | null; - metadata?: Partial<{ [key in string]: JsonValue }> | null; -}; +export type AttachmentInfo = { attachmentId: string; path: string; extension: string; modifiedAt: string } +export type AttachmentSaveResult = { path: string; attachmentId: string } +export type AudioFileMetadata = { filename: string; contentType: string; sizeBytes: number; sha256: string } +export type AudioImportEvent = { type: "audioImportStarted"; session_id: string } | { type: "audioImportProgress"; session_id: string; percentage: number } | { type: "audioImportCompleted"; session_id: string } | { type: "audioImportFailed"; session_id: string; error: string } +export type AudioSourceMetadata = { createdAt: string | null; modifiedAt: string | null; durationMs: number | null } +export type FolderInfo = { name: string; parent_folder_id: string | null } +export type FolderSessionUpdate = { sessionId: string; folderId: string } +export type JsonValue = null | boolean | number | string | JsonValue[] | Partial<{ [key in string]: JsonValue }> +export type ListFoldersResult = { folders: Partial<{ [key in string]: FolderInfo }>; session_folder_map: Partial<{ [key in string]: string }> } +export type MoveSessionResult = { sessionId: string; folderId: string } +export type ParsedDocument = { frontmatter: Partial<{ [key in string]: JsonValue }>; content: string } +export type RenameFolderResult = { updates: FolderSessionUpdate[] } +export type ScanResult = { files: Partial<{ [key in string]: string }>; dirs: string[] } +export type SessionContentData = { sessionId: string; meta: SessionMetaData | null; rawMemoTiptapJson: JsonValue | null; rawMemoMarkdown: string | null; transcript: TranscriptJson | null; notes: SessionNoteData[] } +export type SessionMetaData = { id: string; userId: string; createdAt: string | null; title: string | null; event: JsonValue | null; eventId: string | null; participants: SessionMetaParticipant[]; tags: string[] } +export type SessionMetaParticipant = { id: string; userId: string; sessionId: string; humanId: string; source: string } +export type SessionNoteData = { id: string; sessionId: string; templateId: string | null; position: number | null; title: string | null; tiptapJson: JsonValue; markdown: string | null } +export type TranscriptJson = { transcripts?: TranscriptWithData[] } +export type TranscriptSpeakerHint = { id?: string | null; word_id: string; type: string; value?: JsonValue } +export type TranscriptWithData = { id: string; user_id?: string; created_at?: string; session_id: string; started_at?: number; ended_at?: number | null; memo_md?: string; words?: TranscriptWord[]; speaker_hints?: TranscriptSpeakerHint[] } +export type TranscriptWord = { id?: string | null; text: string; start_ms: number; end_ms: number; channel: number; speaker?: string | null; metadata?: Partial<{ [key in string]: JsonValue }> | null } /** tauri-specta globals **/ import { - invoke as TAURI_INVOKE, - Channel as TAURI_CHANNEL, + invoke as TAURI_INVOKE, + Channel as TAURI_CHANNEL, } from "@tauri-apps/api/core"; import * as TAURI_API_EVENT from "@tauri-apps/api/event"; import { type WebviewWindow as __WebviewWindow__ } from "@tauri-apps/api/webviewWindow"; type __EventObj__ = { - listen: ( - cb: TAURI_API_EVENT.EventCallback, - ) => ReturnType>; - once: ( - cb: TAURI_API_EVENT.EventCallback, - ) => ReturnType>; - emit: null extends T - ? (payload?: T) => ReturnType - : (payload: T) => ReturnType; + listen: ( + cb: TAURI_API_EVENT.EventCallback, + ) => ReturnType>; + once: ( + cb: TAURI_API_EVENT.EventCallback, + ) => ReturnType>; + emit: null extends T + ? (payload?: T) => ReturnType + : (payload: T) => ReturnType; }; export type Result = - | { status: "ok"; data: T } - | { status: "error"; error: E }; + | { status: "ok"; data: T } + | { status: "error"; error: E }; function __makeEvents__>( - mappings: Record, + mappings: Record, ) { - return new Proxy( - {} as unknown as { - [K in keyof T]: __EventObj__ & { - (handle: __WebviewWindow__): __EventObj__; - }; - }, - { - get: (_, event) => { - const name = mappings[event as keyof T]; + return new Proxy( + {} as unknown as { + [K in keyof T]: __EventObj__ & { + (handle: __WebviewWindow__): __EventObj__; + }; + }, + { + get: (_, event) => { + const name = mappings[event as keyof T]; - return new Proxy((() => {}) as any, { - apply: (_, __, [window]: [__WebviewWindow__]) => ({ - listen: (arg: any) => window.listen(name, arg), - once: (arg: any) => window.once(name, arg), - emit: (arg: any) => window.emit(name, arg), - }), - get: (_, command: keyof __EventObj__) => { - switch (command) { - case "listen": - return (arg: any) => TAURI_API_EVENT.listen(name, arg); - case "once": - return (arg: any) => TAURI_API_EVENT.once(name, arg); - case "emit": - return (arg: any) => TAURI_API_EVENT.emit(name, arg); - } - }, - }); - }, - }, - ); + return new Proxy((() => {}) as any, { + apply: (_, __, [window]: [__WebviewWindow__]) => ({ + listen: (arg: any) => window.listen(name, arg), + once: (arg: any) => window.once(name, arg), + emit: (arg: any) => window.emit(name, arg), + }), + get: (_, command: keyof __EventObj__) => { + switch (command) { + case "listen": + return (arg: any) => TAURI_API_EVENT.listen(name, arg); + case "once": + return (arg: any) => TAURI_API_EVENT.once(name, arg); + case "emit": + return (arg: any) => TAURI_API_EVENT.emit(name, arg); + } + }, + }); + }, + }, + ); } diff --git a/plugins/fs-sync/permissions/autogenerated/commands/audio_has_speech.toml b/plugins/fs-sync/permissions/autogenerated/commands/audio_has_speech.toml new file mode 100644 index 00000000000..dcb5c031f36 --- /dev/null +++ b/plugins/fs-sync/permissions/autogenerated/commands/audio_has_speech.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-audio-has-speech" +description = "Enables the audio_has_speech command without any pre-configured scope." +commands.allow = ["audio_has_speech"] + +[[permission]] +identifier = "deny-audio-has-speech" +description = "Denies the audio_has_speech command without any pre-configured scope." +commands.deny = ["audio_has_speech"] diff --git a/plugins/fs-sync/permissions/autogenerated/reference.md b/plugins/fs-sync/permissions/autogenerated/reference.md index c9289c85fcf..5b7dfd82149 100644 --- a/plugins/fs-sync/permissions/autogenerated/reference.md +++ b/plugins/fs-sync/permissions/autogenerated/reference.md @@ -20,6 +20,7 @@ Default permissions for the fs-sync plugin - `allow-audio-import` - `allow-audio-import-data` - `allow-audio-source-metadata` +- `allow-audio-has-speech` - `allow-audio-path` - `allow-audio-copy` - `allow-session-dir` @@ -257,6 +258,32 @@ Denies the audio_exist command without any pre-configured scope. +`fs-sync:allow-audio-has-speech` + + + + +Enables the audio_has_speech command without any pre-configured scope. + + + + + + + +`fs-sync:deny-audio-has-speech` + + + + +Denies the audio_has_speech command without any pre-configured scope. + + + + + + + `fs-sync:allow-audio-import` diff --git a/plugins/fs-sync/permissions/default.toml b/plugins/fs-sync/permissions/default.toml index 532bbe5f789..150d56d9244 100644 --- a/plugins/fs-sync/permissions/default.toml +++ b/plugins/fs-sync/permissions/default.toml @@ -17,6 +17,7 @@ permissions = [ "allow-audio-import", "allow-audio-import-data", "allow-audio-source-metadata", + "allow-audio-has-speech", "allow-audio-path", "allow-audio-copy", "allow-session-dir", diff --git a/plugins/fs-sync/permissions/schemas/schema.json b/plugins/fs-sync/permissions/schemas/schema.json index 0f0aa37d05d..fed877b1162 100644 --- a/plugins/fs-sync/permissions/schemas/schema.json +++ b/plugins/fs-sync/permissions/schemas/schema.json @@ -390,6 +390,18 @@ "const": "deny-audio-exist", "markdownDescription": "Denies the audio_exist command without any pre-configured scope." }, + { + "description": "Enables the audio_has_speech command without any pre-configured scope.", + "type": "string", + "const": "allow-audio-has-speech", + "markdownDescription": "Enables the audio_has_speech command without any pre-configured scope." + }, + { + "description": "Denies the audio_has_speech command without any pre-configured scope.", + "type": "string", + "const": "deny-audio-has-speech", + "markdownDescription": "Denies the audio_has_speech command without any pre-configured scope." + }, { "description": "Enables the audio_import command without any pre-configured scope.", "type": "string", @@ -691,10 +703,10 @@ "markdownDescription": "Denies the write_json_batch command without any pre-configured scope." }, { - "description": "Default permissions for the fs-sync plugin\n#### This default permission set includes:\n\n- `allow-deserialize`\n- `allow-write-json-batch`\n- `allow-write-document-batch`\n- `allow-read-document-batch`\n- `allow-list-folders`\n- `allow-move-session`\n- `allow-create-folder`\n- `allow-rename-folder`\n- `allow-delete-folder`\n- `allow-audio-exist`\n- `allow-audio-delete`\n- `allow-audio-metadata`\n- `allow-audio-delete-orphaned-expired`\n- `allow-audio-import`\n- `allow-audio-import-data`\n- `allow-audio-source-metadata`\n- `allow-audio-path`\n- `allow-audio-copy`\n- `allow-session-dir`\n- `allow-load-session-content`\n- `allow-delete-session-folder`\n- `allow-scan-and-read`\n- `allow-chat-dir`\n- `allow-entity-dir`\n- `allow-attachment-save`\n- `allow-attachment-list`\n- `allow-attachment-read`\n- `allow-attachment-remove`\n- `allow-folder-attachment-save`\n- `allow-folder-attachment-list`\n- `allow-folder-attachment-read`\n- `allow-folder-attachment-remove`", + "description": "Default permissions for the fs-sync plugin\n#### This default permission set includes:\n\n- `allow-deserialize`\n- `allow-write-json-batch`\n- `allow-write-document-batch`\n- `allow-read-document-batch`\n- `allow-list-folders`\n- `allow-move-session`\n- `allow-create-folder`\n- `allow-rename-folder`\n- `allow-delete-folder`\n- `allow-audio-exist`\n- `allow-audio-delete`\n- `allow-audio-metadata`\n- `allow-audio-delete-orphaned-expired`\n- `allow-audio-import`\n- `allow-audio-import-data`\n- `allow-audio-source-metadata`\n- `allow-audio-has-speech`\n- `allow-audio-path`\n- `allow-audio-copy`\n- `allow-session-dir`\n- `allow-load-session-content`\n- `allow-delete-session-folder`\n- `allow-scan-and-read`\n- `allow-chat-dir`\n- `allow-entity-dir`\n- `allow-attachment-save`\n- `allow-attachment-list`\n- `allow-attachment-read`\n- `allow-attachment-remove`\n- `allow-folder-attachment-save`\n- `allow-folder-attachment-list`\n- `allow-folder-attachment-read`\n- `allow-folder-attachment-remove`", "type": "string", "const": "default", - "markdownDescription": "Default permissions for the fs-sync plugin\n#### This default permission set includes:\n\n- `allow-deserialize`\n- `allow-write-json-batch`\n- `allow-write-document-batch`\n- `allow-read-document-batch`\n- `allow-list-folders`\n- `allow-move-session`\n- `allow-create-folder`\n- `allow-rename-folder`\n- `allow-delete-folder`\n- `allow-audio-exist`\n- `allow-audio-delete`\n- `allow-audio-metadata`\n- `allow-audio-delete-orphaned-expired`\n- `allow-audio-import`\n- `allow-audio-import-data`\n- `allow-audio-source-metadata`\n- `allow-audio-path`\n- `allow-audio-copy`\n- `allow-session-dir`\n- `allow-load-session-content`\n- `allow-delete-session-folder`\n- `allow-scan-and-read`\n- `allow-chat-dir`\n- `allow-entity-dir`\n- `allow-attachment-save`\n- `allow-attachment-list`\n- `allow-attachment-read`\n- `allow-attachment-remove`\n- `allow-folder-attachment-save`\n- `allow-folder-attachment-list`\n- `allow-folder-attachment-read`\n- `allow-folder-attachment-remove`" + "markdownDescription": "Default permissions for the fs-sync plugin\n#### This default permission set includes:\n\n- `allow-deserialize`\n- `allow-write-json-batch`\n- `allow-write-document-batch`\n- `allow-read-document-batch`\n- `allow-list-folders`\n- `allow-move-session`\n- `allow-create-folder`\n- `allow-rename-folder`\n- `allow-delete-folder`\n- `allow-audio-exist`\n- `allow-audio-delete`\n- `allow-audio-metadata`\n- `allow-audio-delete-orphaned-expired`\n- `allow-audio-import`\n- `allow-audio-import-data`\n- `allow-audio-source-metadata`\n- `allow-audio-has-speech`\n- `allow-audio-path`\n- `allow-audio-copy`\n- `allow-session-dir`\n- `allow-load-session-content`\n- `allow-delete-session-folder`\n- `allow-scan-and-read`\n- `allow-chat-dir`\n- `allow-entity-dir`\n- `allow-attachment-save`\n- `allow-attachment-list`\n- `allow-attachment-read`\n- `allow-attachment-remove`\n- `allow-folder-attachment-save`\n- `allow-folder-attachment-list`\n- `allow-folder-attachment-read`\n- `allow-folder-attachment-remove`" } ] } diff --git a/plugins/fs-sync/src/commands.rs b/plugins/fs-sync/src/commands.rs index a5e2e61c02d..092bb40df8d 100644 --- a/plugins/fs-sync/src/commands.rs +++ b/plugins/fs-sync/src/commands.rs @@ -416,6 +416,19 @@ pub(crate) async fn audio_source_metadata( }) } +#[tauri::command] +#[specta::specta] +pub(crate) async fn audio_has_speech( + app: tauri::AppHandle, + session_id: String, +) -> Result { + let session_dir = resolve_session_dir(&app, &session_id)?; + spawn_blocking!({ + let path = crate::audio::path(&session_dir).ok_or("audio_not_found")?; + crate::audio::has_speech(&path).map_err(|error| error.to_string()) + }) +} + #[tauri::command] #[specta::specta] pub(crate) async fn audio_path( diff --git a/plugins/fs-sync/src/lib.rs b/plugins/fs-sync/src/lib.rs index 94fa3b089b2..3e9b1e441f8 100644 --- a/plugins/fs-sync/src/lib.rs +++ b/plugins/fs-sync/src/lib.rs @@ -27,6 +27,7 @@ fn make_specta_builder() -> tauri_specta::Builder { commands::audio_import::, commands::audio_import_data::, commands::audio_source_metadata, + commands::audio_has_speech::, commands::audio_path::, commands::audio_copy::, commands::session_dir::, From 4296ffa2ce8b111f324d6e033226fea20f4caf95 Mon Sep 17 00:00:00 2001 From: John Jeong Date: Tue, 8 Sep 2026 00:46:57 +0900 Subject: [PATCH 2/3] Address PR review findings Handle SQLite boolean attachment flags during safe automatic cleanup and accept both string and structured attachment versions when reconciling synced deletions. --- .../src/stt/empty-automatic-capture.test.ts | 33 ++++++++----- .../src/stt/empty-automatic-capture.ts | 4 +- .../db-app/src/e2ee/tests/session_deletion.rs | 18 ++++++++ crates/db-app/src/session_deletion.rs | 46 +++++++++++++++++-- 4 files changed, 84 insertions(+), 17 deletions(-) diff --git a/apps/desktop/src/stt/empty-automatic-capture.test.ts b/apps/desktop/src/stt/empty-automatic-capture.test.ts index 8e388b2cd93..fafabbd9844 100644 --- a/apps/desktop/src/stt/empty-automatic-capture.test.ts +++ b/apps/desktop/src/stt/empty-automatic-capture.test.ts @@ -44,12 +44,18 @@ beforeEach(() => { mocks.flush.mockResolvedValue(undefined); }); -it("discards only silent automatic audio without deleting the shared note", async () => { - expect(await discardEmptyAutomaticCapture(input)).toBe(true); - expect(mocks.remove).toHaveBeenCalledWith("meeting"); - expect(mocks.execute).toHaveBeenCalledTimes(1); - expect(mocks.execute.mock.calls[0][0]).toMatch(/^SELECT /); -}); +it.each([0, false])( + "discards silent automatic audio when the attachment flag is %j", + async (hasAttachments) => { + mocks.execute.mockResolvedValue([ + { title: "Standup", has_attachments: hasAttachments }, + ]); + expect(await discardEmptyAutomaticCapture(input)).toBe(true); + expect(mocks.remove).toHaveBeenCalledWith("meeting"); + expect(mocks.execute).toHaveBeenCalledTimes(1); + expect(mocks.execute.mock.calls[0][0]).toMatch(/^SELECT /); + }, +); it.each([ { automatic: false }, @@ -81,11 +87,16 @@ it("keeps recordings when the user adds notes", async () => { expect(mocks.remove).not.toHaveBeenCalled(); }); -it("keeps audio catalogued before recovery or contributed by another device", async () => { - mocks.execute.mockResolvedValue([{ title: "Standup", has_attachments: 1 }]); - expect(await discardEmptyAutomaticCapture(input)).toBe(false); - expect(mocks.remove).not.toHaveBeenCalled(); -}); +it.each([1, true, null, undefined, "0"])( + "keeps audio when the attachment flag is present or uncertain: %j", + async (hasAttachments) => { + mocks.execute.mockResolvedValue([ + { title: "Standup", has_attachments: hasAttachments }, + ]); + expect(await discardEmptyAutomaticCapture(input)).toBe(false); + expect(mocks.remove).not.toHaveBeenCalled(); + }, +); it("flushes edits made during audio analysis before deciding whether to discard", async () => { mocks.speech.mockImplementation(async () => { diff --git a/apps/desktop/src/stt/empty-automatic-capture.ts b/apps/desktop/src/stt/empty-automatic-capture.ts index 4e4fb6b6903..248834c6299 100644 --- a/apps/desktop/src/stt/empty-automatic-capture.ts +++ b/apps/desktop/src/stt/empty-automatic-capture.ts @@ -40,7 +40,7 @@ export async function discardEmptyAutomaticCapture({ await flushCanonicalSessionEditorChanges(sessionId); const [session] = await liveQueryClient.execute<{ title: string; - has_attachments: number; + has_attachments: boolean | number; }>( `SELECT title, EXISTS ( SELECT 1 FROM session_attachments @@ -52,7 +52,7 @@ export async function discardEmptyAutomaticCapture({ if ( !session || session.title !== initialTitle || - session.has_attachments !== 0 || + (session.has_attachments !== 0 && session.has_attachments !== false) || !(await isSessionEmpty(sessionId)) ) { return false; diff --git a/crates/db-app/src/e2ee/tests/session_deletion.rs b/crates/db-app/src/e2ee/tests/session_deletion.rs index 9c7033ab6f1..d46904fe2ad 100644 --- a/crates/db-app/src/e2ee/tests/session_deletion.rs +++ b/crates/db-app/src/e2ee/tests/session_deletion.rs @@ -185,6 +185,16 @@ async fn replacement_audio_survives_deletion_of_the_older_recording() { .await .unwrap(); delete(a.pool(), "2099-01-01").await; + let context: String = + sqlx::query_scalar("SELECT deletion_context FROM sessions WHERE id = 'meeting'") + .fetch_one(a.pool()) + .await + .unwrap(); + let context: serde_json::Value = serde_json::from_str(&context).unwrap(); + assert_eq!( + context["observed"]["attachment:session-audio:meeting"], + serde_json::json!("[\"empty-device-a\",100,\"\",\"\",\"\"]") + ); sync(a.pool(), b.pool()).await; sync(b.pool(), a.pool()).await; for db in [&a, &b] { @@ -198,6 +208,14 @@ async fn replacement_audio_survives_deletion_of_the_older_recording() { .unwrap(); assert_eq!(hash, "useful-device-b"); } + delete(a.pool(), "2100-01-01").await; + sync(a.pool(), b.pool()).await; + sync(b.pool(), a.pool()).await; + assert_deleted(a.pool(), true).await; + assert_deleted(b.pool(), true).await; + let fresh = test_db().await; + sync(a.pool(), fresh.pool()).await; + assert_deleted(fresh.pool(), true).await; } #[tokio::test] diff --git a/crates/db-app/src/session_deletion.rs b/crates/db-app/src/session_deletion.rs index 08719f7482d..da4b676b5d2 100644 --- a/crates/db-app/src/session_deletion.rs +++ b/crates/db-app/src/session_deletion.rs @@ -1,6 +1,7 @@ use std::collections::BTreeMap; use serde::Deserialize; +use serde_json::Value; use sqlx::{Sqlite, Transaction}; #[derive(Deserialize)] @@ -8,7 +9,15 @@ use sqlx::{Sqlite, Transaction}; struct DeletionContext { version: u32, deleted_at: String, - observed: BTreeMap, + observed: BTreeMap, +} + +fn observed_content_version(value: Option<&Value>) -> Option { + match value { + Some(Value::String(value)) => Some(value.clone()), + Some(value) => serde_json::to_string(value).ok(), + None => None, + } } pub(crate) async fn reconcile_session_deletion( @@ -89,9 +98,9 @@ async fn reconcile_deleted_session( .bind(&context.deleted_at) .fetch_all(&mut **transaction) .await?; - let unseen_content = content - .iter() - .any(|(id, version)| context.observed.get(id) != Some(version)); + let unseen_content = content.iter().any(|(id, version)| { + observed_content_version(context.observed.get(id)).as_deref() != Some(version) + }); let deleted_at = (!unseen_content).then_some(context.deleted_at.as_str()); // Keep the original observation even after restoring the note. Later row @@ -170,3 +179,32 @@ async fn reconcile_deleted_session( .await?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn observed_content_versions_accept_strings_and_json_values() { + let context: DeletionContext = serde_json::from_str( + r#"{ + "version": 1, + "deletedAt": "2099-01-01", + "observed": { + "document:meeting": "content-token", + "attachment:meeting": ["sha", 123, "", "", ""] + } + }"#, + ) + .unwrap(); + + assert_eq!( + observed_content_version(context.observed.get("document:meeting")), + Some("content-token".into()) + ); + assert_eq!( + observed_content_version(context.observed.get("attachment:meeting")), + Some(r#"["sha",123,"","",""]"#.into()) + ); + } +} From 4f1bdbf0b51cbde694d7570aaeb1c7dd8e8e8dc9 Mon Sep 17 00:00:00 2001 From: John Jeong Date: Tue, 8 Sep 2026 01:05:13 +0900 Subject: [PATCH 3/3] Restore deleted notes when local capture content arrives Restore unseen local transcripts, notes, and audio immediately while preserving deletion observations for later sync. Keep empty writes and intentional deletions unchanged, with regression coverage for offline writes. --- ...0400_session_local_content_restoration.sql | 73 ++++++++++++++++ ...00_session_documents_local_restoration.sql | 13 +++ ...07120600_transcripts_local_restoration.sql | 11 +++ ..._session_attachments_local_restoration.sql | 22 +++++ ...60907120800_sessions_local_restoration.sql | 11 +++ .../db-app/src/e2ee/tests/session_deletion.rs | 87 +++++++++++++++++++ crates/db-app/src/lib.rs | 33 +++++++ 7 files changed, 250 insertions(+) create mode 100644 crates/db-app/migrations/20260907120400_session_local_content_restoration.sql create mode 100644 crates/db-app/migrations/20260907120500_session_documents_local_restoration.sql create mode 100644 crates/db-app/migrations/20260907120600_transcripts_local_restoration.sql create mode 100644 crates/db-app/migrations/20260907120700_session_attachments_local_restoration.sql create mode 100644 crates/db-app/migrations/20260907120800_sessions_local_restoration.sql diff --git a/crates/db-app/migrations/20260907120400_session_local_content_restoration.sql b/crates/db-app/migrations/20260907120400_session_local_content_restoration.sql new file mode 100644 index 00000000000..d109a287965 --- /dev/null +++ b/crates/db-app/migrations/20260907120400_session_local_content_restoration.sql @@ -0,0 +1,73 @@ +-- Local content writes must restore a stale deletion before returning, including +-- while offline. Keep one restoration body for every content table. +CREATE VIEW session_deletion_conflicts AS +SELECT s.workspace_id, s.id AS session_id, s.deleted_at +FROM sessions AS s +WHERE s.deleted_at IS NOT NULL + AND CASE WHEN json_valid(s.deletion_context) THEN + json_extract(s.deletion_context, '$.version') = 1 + AND json_extract(s.deletion_context, '$.deletedAt') = s.deleted_at + AND json_type(s.deletion_context, '$.observed') = 'object' + AND EXISTS ( + SELECT 1 FROM session_content_observations AS content + WHERE content.workspace_id = s.workspace_id AND content.session_id = s.id + AND (content.deleted_at IS NULL OR content.deleted_at = s.deleted_at) + AND NOT EXISTS ( + SELECT 1 FROM json_each(s.deletion_context, '$.observed') AS observed + WHERE observed.key = content.content_id AND observed.value = content.content_version + ) + ) + ELSE 0 END; + +CREATE TRIGGER session_deletion_conflicts_restore +INSTEAD OF UPDATE OF deleted_at ON session_deletion_conflicts +WHEN NEW.deleted_at IS NULL AND NOT EXISTS ( + SELECT 1 FROM e2ee_apply_guard + WHERE workspace_id = OLD.workspace_id AND table_name = 'sessions' AND row_id = OLD.session_id +) +BEGIN + -- Preserve the deletion observation so later incoming child tombstones can + -- still be reconciled against the same intent. + INSERT INTO e2ee_apply_guard (workspace_id, table_name, row_id) + VALUES (OLD.workspace_id, 'sessions', OLD.session_id); + + UPDATE sessions SET deleted_at = NULL + WHERE workspace_id = OLD.workspace_id AND id = OLD.session_id; + + UPDATE session_documents SET deleted_at = NULL + WHERE workspace_id = OLD.workspace_id AND session_id = OLD.session_id + AND deleted_at = OLD.deleted_at; + + UPDATE transcripts SET deleted_at = NULL + WHERE workspace_id = OLD.workspace_id AND session_id = OLD.session_id + AND deleted_at = OLD.deleted_at; + + UPDATE session_attachments SET deleted_at = NULL + WHERE workspace_id = OLD.workspace_id AND session_id = OLD.session_id + AND deleted_at = OLD.deleted_at; + + UPDATE session_participants SET deleted_at = NULL + WHERE workspace_id = OLD.workspace_id AND session_id = OLD.session_id + AND deleted_at = OLD.deleted_at; + + UPDATE session_tags SET deleted_at = NULL + WHERE workspace_id = OLD.workspace_id AND session_id = OLD.session_id + AND deleted_at = OLD.deleted_at; + + UPDATE action_items SET deleted_at = NULL + WHERE workspace_id = OLD.workspace_id AND session_id = OLD.session_id + AND deleted_at = OLD.deleted_at; + + UPDATE entity_mentions SET deleted_at = NULL + WHERE workspace_id = OLD.workspace_id AND deleted_at = OLD.deleted_at + AND ((source_type = 'session' AND source_id = OLD.session_id) + OR (target_type = 'session' AND target_id = OLD.session_id)); + + DELETE FROM e2ee_apply_guard + WHERE workspace_id = OLD.workspace_id AND table_name = 'sessions' AND row_id = OLD.session_id; + + INSERT INTO e2ee_dirty_rows (workspace_id, table_name, row_id) + VALUES (OLD.workspace_id, 'sessions', OLD.session_id) + ON CONFLICT (workspace_id, table_name, row_id) + DO UPDATE SET generation = generation + 1; +END; diff --git a/crates/db-app/migrations/20260907120500_session_documents_local_restoration.sql b/crates/db-app/migrations/20260907120500_session_documents_local_restoration.sql new file mode 100644 index 00000000000..b29dbd0f093 --- /dev/null +++ b/crates/db-app/migrations/20260907120500_session_documents_local_restoration.sql @@ -0,0 +1,13 @@ +-- Content versions are assigned after both local inserts and content edits. +-- Waiting for that write avoids depending on sibling trigger execution order. +CREATE TRIGGER session_documents_restore_session +AFTER UPDATE OF content_version ON session_documents +WHEN NEW.content_version IS NOT OLD.content_version AND NOT EXISTS ( + SELECT 1 FROM e2ee_apply_guard + WHERE workspace_id = NEW.workspace_id AND table_name = 'session_documents' AND row_id = NEW.id +) +BEGIN + UPDATE session_deletion_conflicts SET deleted_at = NULL + WHERE workspace_id = NEW.workspace_id AND session_id = NEW.session_id; +END; + diff --git a/crates/db-app/migrations/20260907120600_transcripts_local_restoration.sql b/crates/db-app/migrations/20260907120600_transcripts_local_restoration.sql new file mode 100644 index 00000000000..e40a7e054d6 --- /dev/null +++ b/crates/db-app/migrations/20260907120600_transcripts_local_restoration.sql @@ -0,0 +1,11 @@ +CREATE TRIGGER transcripts_restore_session +AFTER UPDATE OF content_version ON transcripts +WHEN NEW.content_version IS NOT OLD.content_version AND NOT EXISTS ( + SELECT 1 FROM e2ee_apply_guard + WHERE workspace_id = NEW.workspace_id AND table_name = 'transcripts' AND row_id = NEW.id +) +BEGIN + UPDATE session_deletion_conflicts SET deleted_at = NULL + WHERE workspace_id = NEW.workspace_id AND session_id = NEW.session_id; +END; + diff --git a/crates/db-app/migrations/20260907120700_session_attachments_local_restoration.sql b/crates/db-app/migrations/20260907120700_session_attachments_local_restoration.sql new file mode 100644 index 00000000000..24e02f1573a --- /dev/null +++ b/crates/db-app/migrations/20260907120700_session_attachments_local_restoration.sql @@ -0,0 +1,22 @@ +CREATE TRIGGER session_attachments_restore_session_insert +AFTER INSERT ON session_attachments +WHEN NOT EXISTS ( + SELECT 1 FROM e2ee_apply_guard + WHERE workspace_id = NEW.workspace_id AND table_name = 'session_attachments' AND row_id = NEW.id +) +BEGIN + UPDATE session_deletion_conflicts SET deleted_at = NULL + WHERE workspace_id = NEW.workspace_id AND session_id = NEW.session_id; +END; + +CREATE TRIGGER session_attachments_restore_session_update +AFTER UPDATE OF sha256, size_bytes, relative_path, source_type, source_id ON session_attachments +WHEN NOT EXISTS ( + SELECT 1 FROM e2ee_apply_guard + WHERE workspace_id = NEW.workspace_id AND table_name = 'session_attachments' AND row_id = NEW.id +) +BEGIN + UPDATE session_deletion_conflicts SET deleted_at = NULL + WHERE workspace_id = NEW.workspace_id AND session_id = NEW.session_id; +END; + diff --git a/crates/db-app/migrations/20260907120800_sessions_local_restoration.sql b/crates/db-app/migrations/20260907120800_sessions_local_restoration.sql new file mode 100644 index 00000000000..047988aa920 --- /dev/null +++ b/crates/db-app/migrations/20260907120800_sessions_local_restoration.sql @@ -0,0 +1,11 @@ +CREATE TRIGGER sessions_restore_after_title_edit +AFTER UPDATE OF title ON sessions +WHEN NEW.title IS NOT OLD.title AND NOT EXISTS ( + SELECT 1 FROM e2ee_apply_guard + WHERE workspace_id = NEW.workspace_id AND table_name = 'sessions' AND row_id = NEW.id +) +BEGIN + UPDATE session_deletion_conflicts SET deleted_at = NULL + WHERE workspace_id = NEW.workspace_id AND session_id = NEW.id; +END; + diff --git a/crates/db-app/src/e2ee/tests/session_deletion.rs b/crates/db-app/src/e2ee/tests/session_deletion.rs index d46904fe2ad..6a85007b87d 100644 --- a/crates/db-app/src/e2ee/tests/session_deletion.rs +++ b/crates/db-app/src/e2ee/tests/session_deletion.rs @@ -149,6 +149,93 @@ async fn empty_rows_and_metadata_do_not_resurrect_a_deleted_note() { assert_deleted(b.pool(), true).await; } +#[tokio::test] +async fn local_content_after_a_synced_deletion_restores_without_another_sync() { + for change in [ + "INSERT INTO transcripts (id, workspace_id, session_id, words_json) + VALUES ('late', 'workspace-a', 'meeting', '[{\"text\":\"Recording finished\"}]')", + "UPDATE transcripts SET words_json = '[{\"text\":\"Recording continued\"}]' WHERE id = 'capture'", + "UPDATE session_documents SET body_format = 'markdown', body = 'Local notes' WHERE id = 'meeting'", + "INSERT INTO session_documents (id, workspace_id, session_id, body_format, body) + VALUES ('new-notes', 'workspace-a', 'meeting', 'markdown', 'New notes')", + "INSERT INTO session_attachments (id, workspace_id, session_id, size_bytes, sha256) + VALUES ('new-audio', 'workspace-a', 'meeting', 1234, 'new-recording')", + "UPDATE session_attachments SET size_bytes = 5678, sha256 = 'replacement' WHERE id = 'audio'", + "UPDATE sessions SET title = 'Edited meeting' WHERE id = 'meeting'", + ] { + let a = test_db().await; + let b = test_db().await; + seed(a.pool()).await; + sqlx::raw_sql( + "INSERT INTO transcripts (id, workspace_id, session_id) VALUES ('capture', 'workspace-a', 'meeting'); + INSERT INTO session_attachments (id, workspace_id, session_id, size_bytes, sha256) + VALUES ('audio', 'workspace-a', 'meeting', 100, 'original');", + ) + .execute(a.pool()).await.unwrap(); + sync(a.pool(), b.pool()).await; + delete(a.pool(), "2099-01-01").await; + sync(a.pool(), b.pool()).await; + assert_deleted(b.pool(), true).await; + + sqlx::raw_sql(sqlx::AssertSqlSafe(change)) + .execute(b.pool()) + .await + .unwrap(); + assert_deleted(b.pool(), false).await; + let (context, live_documents): (String, i64) = sqlx::query_as( + "SELECT deletion_context, (SELECT count(*) FROM session_documents WHERE id = 'meeting' AND deleted_at IS NULL) + FROM sessions WHERE id = 'meeting'", + ).fetch_one(b.pool()).await.unwrap(); + assert!(!context.is_empty()); + assert_eq!(live_documents, 1); + sync(b.pool(), a.pool()).await; + assert_deleted(a.pool(), false).await; + delete(b.pool(), "2100-01-01").await; + assert_deleted(b.pool(), true).await; + sync(b.pool(), a.pool()).await; + assert_deleted(a.pool(), true).await; + } +} + +#[tokio::test] +async fn local_empty_writes_after_a_synced_deletion_do_not_restore() { + let a = test_db().await; + let b = test_db().await; + seed(a.pool()).await; + delete(a.pool(), "2099-01-01").await; + sync(a.pool(), b.pool()).await; + sqlx::raw_sql( + "INSERT INTO transcripts (id, workspace_id, session_id) VALUES ('empty', 'workspace-a', 'meeting'); + UPDATE transcripts SET content_revision = 1 WHERE id = 'empty'; + UPDATE session_documents SET body = '{\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\"}]}' WHERE id = 'meeting'; + INSERT INTO session_attachments (id, workspace_id, session_id) VALUES ('empty-audio', 'workspace-a', 'meeting'); + UPDATE sessions SET ended_at = '2099-02-01' WHERE id = 'meeting';", + ).execute(b.pool()).await.unwrap(); + assert_deleted(b.pool(), true).await; +} + +#[tokio::test] +async fn local_content_preserves_unrecognized_deletion_contexts() { + for context in [ + "", + "invalid-json", + r#"{"version":2,"deletedAt":"2099-01-01","observed":{}}"#, + r#"{"version":1,"deletedAt":"2099-01-01","observed":[]}"#, + ] { + let db = test_db().await; + seed(db.pool()).await; + delete(db.pool(), "2099-01-01").await; + sqlx::query("UPDATE sessions SET deletion_context = ? WHERE id = 'meeting'") + .bind(context) + .execute(db.pool()) + .await + .unwrap(); + sqlx::query("UPDATE session_documents SET body_format = 'markdown', body = 'New notes' WHERE id = 'meeting'") + .execute(db.pool()).await.unwrap(); + assert_deleted(db.pool(), true).await; + } +} + #[tokio::test] async fn local_undo_clears_the_deletion_observation() { let a = test_db().await; diff --git a/crates/db-app/src/lib.rs b/crates/db-app/src/lib.rs index f9979be423d..475ca94c98f 100644 --- a/crates/db-app/src/lib.rs +++ b/crates/db-app/src/lib.rs @@ -451,6 +451,39 @@ pub const APP_MIGRATION_STEPS: &[anlg_db_migrate::MigrationStep] = &[ }, sql: include_str!("../migrations/20260907120300_session_deletion_context.sql"), }, + anlg_db_migrate::MigrationStep { + id: "20260907120400_session_local_content_restoration", + scope: anlg_db_migrate::MigrationScope::Plain, + sql: include_str!("../migrations/20260907120400_session_local_content_restoration.sql"), + }, + anlg_db_migrate::MigrationStep { + id: "20260907120500_session_documents_local_restoration", + scope: anlg_db_migrate::MigrationScope::CloudsyncAlter { + table_name: "session_documents", + }, + sql: include_str!("../migrations/20260907120500_session_documents_local_restoration.sql"), + }, + anlg_db_migrate::MigrationStep { + id: "20260907120600_transcripts_local_restoration", + scope: anlg_db_migrate::MigrationScope::CloudsyncAlter { + table_name: "transcripts", + }, + sql: include_str!("../migrations/20260907120600_transcripts_local_restoration.sql"), + }, + anlg_db_migrate::MigrationStep { + id: "20260907120700_session_attachments_local_restoration", + scope: anlg_db_migrate::MigrationScope::CloudsyncAlter { + table_name: "session_attachments", + }, + sql: include_str!("../migrations/20260907120700_session_attachments_local_restoration.sql"), + }, + anlg_db_migrate::MigrationStep { + id: "20260907120800_sessions_local_restoration", + scope: anlg_db_migrate::MigrationScope::CloudsyncAlter { + table_name: "sessions", + }, + sql: include_str!("../migrations/20260907120800_sessions_local_restoration.sql"), + }, ]; pub fn schema() -> anlg_db_migrate::DbSchema {