From 36bc8eb39c7b22442506dccbd094835823ecb34d Mon Sep 17 00:00:00 2001 From: Carlos Garcia Date: Thu, 25 Jun 2026 16:21:07 +0200 Subject: [PATCH 01/13] auto cache rolling cleanup for posts --- .../local/ephemeral_mode/cleanup.test.ts | 379 ++++++++++++++++++ app/actions/local/ephemeral_mode/cleanup.ts | 292 ++++++++++++++ app/actions/local/post.ts | 37 ++ app/actions/websocket/index.ts | 3 + app/components/post_list/post_list.tsx | 6 +- app/constants/database.ts | 1 + app/constants/post.ts | 2 + app/managers/ephemeral_mode_manager/index.ts | 26 +- app/queries/servers/post.ts | 49 +++ .../channel_post_list/channel_post_list.tsx | 28 +- app/screens/gallery/index.tsx | 18 +- app/screens/pdf_viewer/pdf_viewer.tsx | 22 + app/store/ephemeral_store.ts | 16 + 13 files changed, 869 insertions(+), 10 deletions(-) create mode 100644 app/actions/local/ephemeral_mode/cleanup.test.ts create mode 100644 app/actions/local/ephemeral_mode/cleanup.ts diff --git a/app/actions/local/ephemeral_mode/cleanup.test.ts b/app/actions/local/ephemeral_mode/cleanup.test.ts new file mode 100644 index 00000000000..f2c6d6d285e --- /dev/null +++ b/app/actions/local/ephemeral_mode/cleanup.test.ts @@ -0,0 +1,379 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import * as LocalPost from '@actions/local/post'; +import {Screens} from '@constants'; +import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; +import {AUTO_CACHE_CLEANUP_PROTECTION_BUFFER} from '@constants/post'; +import DatabaseManager from '@database/manager'; +import OfflinePersistenceManager from '@managers/offline_persistence_manager'; +import {getCurrentChannelId} from '@queries/servers/system'; +import EphemeralStore from '@store/ephemeral_store'; +import {NavigationStore} from '@store/navigation_store'; +import {logError} from '@utils/log'; + +import {autoCacheCleanup} from './cleanup'; + +import type ServerDataOperator from '@database/operator/server_data_operator'; +import type {Database} from '@nozbe/watermelondb'; +import type MyChannelModel from '@typings/database/models/servers/my_channel'; +import type PostsInChannelModel from '@typings/database/models/servers/posts_in_channel'; +import type PostsInThreadModel from '@typings/database/models/servers/posts_in_thread'; + +const {SERVER: {MY_CHANNEL, POST, POSTS_IN_CHANNEL, POSTS_IN_THREAD}} = MM_TABLES; + +jest.mock('@managers/offline_persistence_manager', () => ({ + __esModule: true, + default: {getAutoCacheCleanupDays: jest.fn()}, +})); + +jest.mock('@store/navigation_store', () => ({ + NavigationStore: {getScreensInStack: jest.fn()}, +})); + +jest.mock('@store/ephemeral_store', () => ({ + __esModule: true, + default: { + getCurrentChannelOldestVisibleCreateAt: jest.fn(), + getCurrentThreadId: jest.fn(), + getCurrentFileViewerPostId: jest.fn(), + }, +})); + +jest.mock('@utils/log'); + +jest.mock('@queries/servers/system', () => ({ + getCurrentChannelId: jest.fn(), +})); + +jest.mock('@actions/local/post', () => ({ + deletePostsInChannelsByCutoff: jest.fn(), +})); + +const SERVER_URL = 'cleanup.test.com'; + +// Fixed clock so Date.now() inside autoCacheCleanup is deterministic. +// cleanupDays=1 in debug mode → cutoff = NOW - 1*3_600_000 +const NOW = 10_000_000; +const CUTOFF = NOW - 3_600_000; // 6_400_000 +const OLD = 1_000_000; // clearly below CUTOFF +const RECENT = NOW; // clearly above CUTOFF + +let database: Database; +let operator: ServerDataOperator; + +async function writePiC(channelId: string, earliest: number, latest: number): Promise { + let record!: PostsInChannelModel; + await database.write(async () => { + record = await database.get(POSTS_IN_CHANNEL).create((r) => { + r.channelId = channelId; + r.earliest = earliest; + r.latest = latest; + }); + }); + return record; +} + +async function writePiT(rootId: string, earliest: number, latest: number): Promise { + let record!: PostsInThreadModel; + await database.write(async () => { + record = await database.get(POSTS_IN_THREAD).create((r) => { + r.rootId = rootId; + r.earliest = earliest; + r.latest = latest; + }); + }); + return record; +} + +async function writeMyChannel(channelId: string, lastFetchedAt: number): Promise { + await database.write(async () => { + await database.get(MY_CHANNEL).create((r) => { + r._raw.id = channelId; + r.lastFetchedAt = lastFetchedAt; + }); + }); +} + +async function writePost(id: string, channelId: string, createAt: number): Promise { + await database.write(async () => { + await database.get(POST).create((r: any) => { + r._raw.id = id; + r.channelId = channelId; + r.createAt = createAt; + r.deleteAt = 0; + r.editAt = 0; + r.isPinned = false; + r.message = ''; + r.messageSource = ''; + r.originalId = ''; + r.pendingPostId = ''; + r.previousPostId = ''; + r.props = '{}'; + r.rootId = ''; + r.type = ''; + r.updateAt = 0; + r.userId = ''; + }); + }); +} + +describe('autoCacheCleanup', () => { + beforeEach(async () => { + jest.clearAllMocks(); + jest.spyOn(Date, 'now').mockReturnValue(NOW); + await DatabaseManager.init([SERVER_URL]); + ({database, operator} = DatabaseManager.getServerDatabaseAndOperator(SERVER_URL)); + jest.spyOn(DatabaseManager, 'getActiveServerUrl').mockResolvedValue('other-server'); + + // LokiJS adapter has no unsafeVacuum; mock it to avoid a hanging toPromise callback + jest.spyOn(database, 'unsafeVacuum').mockResolvedValue(); + jest.mocked(OfflinePersistenceManager.getAutoCacheCleanupDays).mockReturnValue(1); + jest.mocked(NavigationStore.getScreensInStack).mockReturnValue([]); + jest.mocked(EphemeralStore.getCurrentChannelOldestVisibleCreateAt).mockReturnValue(0); + jest.mocked(EphemeralStore.getCurrentThreadId).mockReturnValue(''); + jest.mocked(EphemeralStore.getCurrentFileViewerPostId).mockReturnValue(''); + jest.mocked(getCurrentChannelId).mockResolvedValue(''); + jest.mocked(LocalPost.deletePostsInChannelsByCutoff).mockResolvedValue({error: undefined}); + }); + + afterEach(async () => { + await DatabaseManager.destroyServerDatabase(SERVER_URL); + jest.restoreAllMocks(); + }); + + // TC-1 + it('exits early without DB access when cleanupDays is 0 or negative', async () => { + const dbSpy = jest.spyOn(DatabaseManager, 'getServerDatabaseAndOperator'); + + jest.mocked(OfflinePersistenceManager.getAutoCacheCleanupDays).mockReturnValue(0); + await autoCacheCleanup(SERVER_URL); + + jest.mocked(OfflinePersistenceManager.getAutoCacheCleanupDays).mockReturnValue(-3); + await autoCacheCleanup(SERVER_URL); + + expect(dbSpy).not.toHaveBeenCalled(); + }); + + // TC-2 + it('logs an error and returns when getServerDatabaseAndOperator throws', async () => { + jest.spyOn(DatabaseManager, 'getServerDatabaseAndOperator').mockImplementationOnce(() => { + throw new Error('db unavailable'); + }); + + await autoCacheCleanup(SERVER_URL); + + expect(logError).toHaveBeenCalledWith('autoCacheCleanup getServerDatabaseAndOperator', expect.any(Error)); + }); + + // TC-3b + it('does not call getCurrentChannelId when the channel screen is not in the navigation stack', async () => { + jest.spyOn(DatabaseManager, 'getActiveServerUrl').mockResolvedValue(SERVER_URL); + jest.mocked(NavigationStore.getScreensInStack).mockReturnValue([]); + + await autoCacheCleanup(SERVER_URL); + + expect(getCurrentChannelId).not.toHaveBeenCalled(); + }); + + // TC-4 + it('destroys the PiC row and resets MyChannel.lastFetchedAt for an entirely-stale unprotected range', async () => { + const channelId = 'ch-stale'; + await writePiC(channelId, OLD - 100, OLD); + await writeMyChannel(channelId, 1000); + + await autoCacheCleanup(SERVER_URL); + + const picRows = await database.get(POSTS_IN_CHANNEL).query().fetch(); + expect(picRows.length).toBe(0); + + const mc = await database.get(MY_CHANNEL).find(channelId); + expect(mc.lastFetchedAt).toBe(0); + }); + + // TC-5 + it('updates PiC earliest to CUTOFF for a straddling range and preserves MyChannel.lastFetchedAt', async () => { + const channelId = 'ch-straddle'; + await writePiC(channelId, OLD, RECENT); + await writeMyChannel(channelId, 1000); + + await autoCacheCleanup(SERVER_URL); + + const [pic] = await database.get(POSTS_IN_CHANNEL).query().fetch(); + expect(pic.earliest).toBe(CUTOFF); + expect(pic.latest).toBe(RECENT); + + const mc = await database.get(MY_CHANNEL).find(channelId); + expect(mc.lastFetchedAt).toBe(1000); + }); + + // TC-6 + it('uses the 20th-older-post create_at as the effective cutoff for the viewed channel', async () => { + const viewedChannelId = 'ch-viewed'; + jest.spyOn(DatabaseManager, 'getActiveServerUrl').mockResolvedValue(SERVER_URL); + jest.mocked(NavigationStore.getScreensInStack).mockReturnValue([Screens.CHANNEL]); + jest.mocked(getCurrentChannelId).mockResolvedValue(viewedChannelId); + + // oldest-visible is slightly above CUTOFF + const OLDEST_VISIBLE = CUTOFF + 2000; + jest.mocked(EphemeralStore.getCurrentChannelOldestVisibleCreateAt).mockReturnValue(OLDEST_VISIBLE); + + // 20 posts older than OLDEST_VISIBLE; the 20th (oldest) lands below CUTOFF + await Promise.all( + Array.from({length: AUTO_CACHE_CLEANUP_PROTECTION_BUFFER}, (_, idx) => + writePost(`post-${idx + 1}`, viewedChannelId, OLDEST_VISIBLE - ((idx + 1) * 105)), + ), + ); + await writePiC(viewedChannelId, OLD, RECENT); + + await autoCacheCleanup(SERVER_URL); + + // 20th post create_at = OLDEST_VISIBLE - 20*105 = CUTOFF - 100 + const PROTECTION_CUTOFF = OLDEST_VISIBLE - (AUTO_CACHE_CLEANUP_PROTECTION_BUFFER * 105); + + expect(LocalPost.deletePostsInChannelsByCutoff).toHaveBeenCalledWith( + SERVER_URL, [viewedChannelId], PROTECTION_CUTOFF, expect.any(Set), + ); + + const [pic] = await database.get(POSTS_IN_CHANNEL).query().fetch(); + expect(pic.earliest).toBe(PROTECTION_CUTOFF); + }); + + // TC-7 + it('applies no extra protection when fewer than BUFFER posts precede the scroll anchor', async () => { + const viewedChannelId = 'ch-viewed-few'; + jest.spyOn(DatabaseManager, 'getActiveServerUrl').mockResolvedValue(SERVER_URL); + jest.mocked(NavigationStore.getScreensInStack).mockReturnValue([Screens.CHANNEL]); + jest.mocked(getCurrentChannelId).mockResolvedValue(viewedChannelId); + jest.mocked(EphemeralStore.getCurrentChannelOldestVisibleCreateAt).mockReturnValue(CUTOFF + 500); + + // Only 5 posts older than anchor — fewer than BUFFER=20 + await Promise.all( + Array.from({length: 5}, (_, idx) => + writePost(`post-few-${idx + 1}`, viewedChannelId, (CUTOFF + 500) - ((idx + 1) * 10)), + ), + ); + await writePiC(viewedChannelId, OLD, RECENT); + + await autoCacheCleanup(SERVER_URL); + + // viewedChannelLimit = Infinity → effective cutoff = raw CUTOFF + expect(LocalPost.deletePostsInChannelsByCutoff).toHaveBeenCalledWith( + SERVER_URL, [viewedChannelId], CUTOFF, expect.any(Set), + ); + + const [pic] = await database.get(POSTS_IN_CHANNEL).query().fetch(); + expect(pic.earliest).toBe(CUTOFF); + }); + + // TC-8 + it('skips the open-thread PiT row, adds its root to excluded IDs, and protects the thread-parent channel', async () => { + const rootId = 'root-post'; + const threadParentChannelId = 'ch-thread-parent'; + jest.spyOn(DatabaseManager, 'getActiveServerUrl').mockResolvedValue(SERVER_URL); + jest.mocked(EphemeralStore.getCurrentThreadId).mockReturnValue(rootId); + + await writePost(rootId, threadParentChannelId, OLD + 500); + + // straddles CUTOFF: without the skip it would get earliest updated; with skip it is preserved + await writePiT(rootId, OLD, RECENT); + + // needs a PiC row so deletePostsInChannelsByCutoff is actually invoked + await writePiC('ch-other', OLD, RECENT); + + await autoCacheCleanup(SERVER_URL); + + // PiT row for the open thread must be unchanged + const [pit] = await database.get(POSTS_IN_THREAD).query().fetch(); + expect(pit.earliest).toBe(OLD); + + // Root post ID must appear in excludedPostIds of every deletePostsInChannelsByCutoff call + const calls = jest.mocked(LocalPost.deletePostsInChannelsByCutoff).mock.calls; + const excluded = calls.flatMap((c) => [...(c[3] as Set)]); + expect(excluded).toContain(rootId); + }); + + // TC-9 + it('excludes active thread root IDs from post deletion even when the thread is not currently open', async () => { + const rootId = 'live-root'; + + // live thread: latest >= CUTOFF → rootId goes into activeThreadRootIds + await writePiT(rootId, OLD, RECENT); + await writePiC('ch-unprotected', OLD, RECENT); + + await autoCacheCleanup(SERVER_URL); + + const [[, , , excludedPostIds]] = jest.mocked(LocalPost.deletePostsInChannelsByCutoff).mock.calls; + expect((excludedPostIds as Set).has(rootId)).toBe(true); + }); + + // TC-10 + it('includes the file-viewer post ID in excludedPostIds passed to deletePostsInChannelsByCutoff', async () => { + const filePostId = 'file-post-id'; + jest.spyOn(DatabaseManager, 'getActiveServerUrl').mockResolvedValue(SERVER_URL); + jest.mocked(EphemeralStore.getCurrentFileViewerPostId).mockReturnValue(filePostId); + await writePiC('ch-file', OLD, RECENT); + + await autoCacheCleanup(SERVER_URL); + + const [[, , , excludedPostIds]] = jest.mocked(LocalPost.deletePostsInChannelsByCutoff).mock.calls; + expect((excludedPostIds as Set).has(filePostId)).toBe(true); + }); + + // TC-11 + it('destroys stale PiT rows, updates straddling PiT rows earliest to CUTOFF, and leaves the current-thread PiT row untouched', async () => { + const currentThreadId = 'current-thread'; + jest.spyOn(DatabaseManager, 'getActiveServerUrl').mockResolvedValue(SERVER_URL); + jest.mocked(EphemeralStore.getCurrentThreadId).mockReturnValue(currentThreadId); + + await writePiT('stale-root', OLD - 100, OLD); + await writePiT('straddle-root', OLD, RECENT); + await writePiT(currentThreadId, OLD, RECENT); + + // provide a post so getPostById succeeds for the open thread + await writePost(currentThreadId, 'ch-parent', OLD + 50); + + await autoCacheCleanup(SERVER_URL); + + const pitRows = await database.get(POSTS_IN_THREAD).query().fetch(); + expect(pitRows.length).toBe(2); // stale-root destroyed + + const straddle = pitRows.find((r) => r.rootId === 'straddle-root')!; + expect(straddle.earliest).toBe(CUTOFF); + + const current = pitRows.find((r) => r.rootId === currentThreadId)!; + expect(current.earliest).toBe(OLD); // not updated + }); + + // TC-12 + it('calls unsafeVacuum and stamps LAST_AUTO_CACHE_CLEANUP_RUN after a successful run', async () => { + const vacuumSpy = jest.spyOn(database, 'unsafeVacuum'); + const handleSystemSpy = jest.spyOn(operator, 'handleSystem'); + + await autoCacheCleanup(SERVER_URL); + + expect(vacuumSpy).toHaveBeenCalledTimes(1); + expect(handleSystemSpy).toHaveBeenCalledWith( + expect.objectContaining({ + systems: expect.arrayContaining([ + expect.objectContaining({id: SYSTEM_IDENTIFIERS.LAST_AUTO_CACHE_CLEANUP_RUN}), + ]), + }), + ); + }); + + // TC-13 + it('does not call unsafeVacuum and logs the error when cleanup throws mid-run', async () => { + jest.mocked(LocalPost.deletePostsInChannelsByCutoff).mockRejectedValueOnce(new Error('cleanup failed')); + + // entirely-recent range: no prepareUpdate called, so no orphaned record when the throw occurs + await writePiC('ch-err', RECENT, RECENT + 100); + + const vacuumSpy = jest.spyOn(database, 'unsafeVacuum').mockResolvedValue(); + + await autoCacheCleanup(SERVER_URL); + + expect(vacuumSpy).not.toHaveBeenCalled(); + expect(logError).toHaveBeenCalledWith('autoCacheCleanup', expect.any(Error)); + }); +}); diff --git a/app/actions/local/ephemeral_mode/cleanup.ts b/app/actions/local/ephemeral_mode/cleanup.ts new file mode 100644 index 00000000000..ecc3aef24a9 --- /dev/null +++ b/app/actions/local/ephemeral_mode/cleanup.ts @@ -0,0 +1,292 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {deletePostsInChannelsByCutoff} from '@actions/local/post'; +import {Screens} from '@constants'; +import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; +import DateConstants from '@constants/datetime'; +import {AUTO_CACHE_CLEANUP_PROTECTION_BUFFER} from '@constants/post'; +import DatabaseManager from '@database/manager'; +import OfflinePersistenceManager from '@managers/offline_persistence_manager'; +import { + createAtOfNthPostOlderThan, + getPostById, + queryActiveThreadRootIds, +} from '@queries/servers/post'; +import {getCurrentChannelId} from '@queries/servers/system'; +import EphemeralStore from '@store/ephemeral_store'; +import {NavigationStore} from '@store/navigation_store'; +import {logDebug, logError} from '@utils/log'; + +import type {Database, Model} from '@nozbe/watermelondb'; +import type MyChannelModel from '@typings/database/models/servers/my_channel'; +import type PostInChannelModel from '@typings/database/models/servers/posts_in_channel'; +import type PostsInThreadModel from '@typings/database/models/servers/posts_in_thread'; +import type SystemModel from '@typings/database/models/servers/system'; + +const {SERVER: {MY_CHANNEL, POSTS_IN_CHANNEL, POSTS_IN_THREAD, SYSTEM}} = MM_TABLES; + +interface CleanupProtections { + viewedChannelId: string | undefined; + viewedChannelLimit: number; + threadParentChannelId: string | undefined; + threadParentLimit: number; + viewedThreadId: string | undefined; + activeThreadRootIds: Set; + fileViewerPostId: string | undefined; +} + +async function getLastAutoCacheCleanupRun(database: Database): Promise { + try { + const data = await database.get(SYSTEM).find(SYSTEM_IDENTIFIERS.LAST_AUTO_CACHE_CLEANUP_RUN); + return data?.value ? Number(data.value) : 0; + } catch { + return 0; + } +} + +async function setLastAutoCacheCleanupRun(serverUrl: string): Promise { + try { + const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + await operator.handleSystem({ + systems: [{id: SYSTEM_IDENTIFIERS.LAST_AUTO_CACHE_CLEANUP_RUN, value: Date.now()}], + prepareRecordsOnly: false, + }); + } catch (error) { + logError('autoCacheCleanup setLastAutoCacheCleanupRun', error); + } +} + +async function computeCleanupProtections( + database: Database, + cutoff: number, + isActive: boolean, +): Promise { + // DB-consistency guard: always computed regardless of active server. + // A thread with replies newer than cutoff is alive even if its root is old. + const activeThreadRootIds = await queryActiveThreadRootIds(database, cutoff); + + if (!isActive) { + return { + viewedChannelId: undefined, + viewedChannelLimit: Infinity, + viewedThreadId: undefined, + threadParentChannelId: undefined, + threadParentLimit: Infinity, + activeThreadRootIds, + fileViewerPostId: undefined, + }; + } + + const isChannelScreenMounted = NavigationStore.getScreensInStack().includes(Screens.CHANNEL); + const viewedChannelId = (isChannelScreenMounted && await getCurrentChannelId(database)) || undefined; + const oldestVisible = EphemeralStore.getCurrentChannelOldestVisibleCreateAt(); + + let viewedChannelLimit = Infinity; + if (viewedChannelId && oldestVisible) { + viewedChannelLimit = + await createAtOfNthPostOlderThan( + database, viewedChannelId, oldestVisible, AUTO_CACHE_CLEANUP_PROTECTION_BUFFER, + ) ?? Infinity; + } + + const viewedThreadId = EphemeralStore.getCurrentThreadId() || undefined; + let threadParentChannelId: string | undefined; + let threadParentLimit = Infinity; + + if (viewedThreadId) { + activeThreadRootIds.add(viewedThreadId); + const rootPost = await getPostById(database, viewedThreadId); + if (rootPost) { + threadParentChannelId = rootPost.channelId; + threadParentLimit = + (await createAtOfNthPostOlderThan( + database, threadParentChannelId, rootPost.createAt, AUTO_CACHE_CLEANUP_PROTECTION_BUFFER, + )) ?? Infinity; + } + } + + const fileViewerPostId = EphemeralStore.getCurrentFileViewerPostId() || undefined; + + return { + viewedChannelId, + viewedChannelLimit, + viewedThreadId, + threadParentChannelId, + threadParentLimit, + activeThreadRootIds, + fileViewerPostId, + }; +} + +function channelProtectionLimit(channelId: string, protections: CleanupProtections): number { + if (channelId === protections.viewedChannelId) { + return protections.viewedChannelLimit; + } + if (channelId === protections.threadParentChannelId) { + return protections.threadParentLimit; + } + return Infinity; +} + +async function cleanupPosts( + serverUrl: string, + database: Database, + operator: {batchRecords: (models: Model[], description: string) => Promise}, + cutoff: number, + protections: CleanupProtections, +): Promise { + const postsInChannelItems = await database.get(POSTS_IN_CHANNEL).query().fetch(); + const myChannels = await database.get(MY_CHANNEL).query().fetch(); + const myChannelIds = new Map(myChannels.map((mc) => [mc.id, mc])); + + const unprotectedChannels = new Set(postsInChannelItems.filter((row) => row.channelId !== protections.viewedChannelId && row.channelId !== protections.threadParentChannelId).map((row) => row.channelId)); + const channelsWithPostRanges = postsInChannelItems.reduce((map, row) => { + map.set(row.channelId, (map.get(row.channelId) ?? 0) + 1); + return map; + }, new Map()); + const prepared: Model[] = []; + + // iterate over channels with posts in the database + for (const postInChannel of postsInChannelItems) { + const computedChannelCutoff = Math.min(cutoff, channelProtectionLimit(postInChannel.channelId, protections)); + + if (postInChannel.latest < computedChannelCutoff) { + prepared.push(postInChannel.prepareDestroyPermanently()); + channelsWithPostRanges.set(postInChannel.channelId, (channelsWithPostRanges.get(postInChannel.channelId) ?? 0) - 1); + } else if (postInChannel.earliest < computedChannelCutoff) { + prepared.push(postInChannel.prepareUpdate((r: PostInChannelModel) => { + r.earliest = computedChannelCutoff; + })); + } + } + + const excludedPostIds: Set = new Set([ + ...protections.activeThreadRootIds, + ...(protections.fileViewerPostId ? [protections.fileViewerPostId] : []), + ]); + + // delete posts in channels not currently being viewed using a single query + if (unprotectedChannels.size > 0) { + await deletePostsInChannelsByCutoff(serverUrl, Array.from(unprotectedChannels), cutoff, excludedPostIds); + } + + // delete posts in viewed channel if any + if (protections.viewedChannelId && channelsWithPostRanges.has(protections.viewedChannelId)) { + const computedChannelCutoff = Math.min(cutoff, channelProtectionLimit(protections.viewedChannelId, protections)); + await deletePostsInChannelsByCutoff(serverUrl, [protections.viewedChannelId], computedChannelCutoff, excludedPostIds); + } + + // delete posts in thread parent channel if any + if (protections.threadParentChannelId && protections.threadParentChannelId !== protections.viewedChannelId && channelsWithPostRanges.has(protections.threadParentChannelId)) { + const computedChannelCutoff = Math.min(cutoff, channelProtectionLimit(protections.threadParentChannelId, protections)); + await deletePostsInChannelsByCutoff(serverUrl, [protections.threadParentChannelId], computedChannelCutoff, excludedPostIds); + } + + // Fetch PostInThread rows after deletions — deletePostsInChannelsByCutoff already removed rows + // for deleted root posts, so this avoids calling prepareDestroyPermanently on gone records. + const allPostInThreadRows = await database.get(POSTS_IN_THREAD).query().fetch(); + + // Update MyChannel lastFetchedAt to 0 for channels that have no posts left. + channelsWithPostRanges.forEach((count, channelId) => { + if (count <= 0) { + const mc = myChannelIds.get(channelId); + if (mc && mc.lastFetchedAt > 0) { + prepared.push(mc.prepareUpdate((r: MyChannelModel) => { + r.lastFetchedAt = 0; + })); + } + } + }); + + // PostsInThread bookkeeping. + for (const postInThread of allPostInThreadRows) { + if (postInThread.rootId === protections.viewedThreadId) { + continue; // open thread is fully protected + } + if (postInThread.latest < cutoff) { + prepared.push(postInThread.prepareDestroyPermanently()); + } else if (postInThread.earliest < cutoff) { + prepared.push(postInThread.prepareUpdate((r: PostsInThreadModel) => { + r.earliest = cutoff; + })); + } + } + + if (prepared.length > 0) { + await operator.batchRecords(prepared, 'autoCacheCleanupRanges'); + } +} + +// Stubs for Stage 3 and Stage 4. +// eslint-disable-next-line @typescript-eslint/no-unused-vars +async function cleanupAiThreads(_database: Database, _cutoff: number, _isActive: boolean) { + // Implemented in Stage 3. +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +async function cleanupPlaybookRuns(_database: Database, _cutoff: number, _isActive: boolean) { + // Implemented in Stage 4. +} + +export async function autoCacheCleanup(serverUrl: string): Promise { + const cleanupDays = OfflinePersistenceManager.getAutoCacheCleanupDays(serverUrl); + if (cleanupDays <= 0) { + logDebug('autoCacheCleanup: cleanupDays <= 0 for', serverUrl); + return; + } + + let database: Database; + let operator: {batchRecords: (models: Model[], description: string) => Promise}; + try { + ({database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl)); + } catch (error) { + logError('autoCacheCleanup getServerDatabaseAndOperator', error); + return; + } + + const toDate = (ms: number) => (ms ? new Date(ms).toString() : undefined); + const lastRunAt = await getLastAutoCacheCleanupRun(database); + const shouldRun = !lastRunAt || new Date(lastRunAt).toDateString() !== new Date().toDateString(); + logDebug('autoCacheCleanup: rolling cleanup check for', serverUrl, '— lastRunAt:', toDate(lastRunAt), '— shouldRun:', shouldRun); + if (!shouldRun) { + return; + } + + try { + const cutoff = Date.now() - (cleanupDays * DateConstants.SECONDS.DAY * 1000); + const activeUrl = await DatabaseManager.getActiveServerUrl(); + const isActive = serverUrl === activeUrl; + + const limits = await computeCleanupProtections(database, cutoff, isActive); + const toDateOrInf = (ms: number) => { + if (!Number.isFinite(ms)) { + return ms > 0 ? 'Infinity' : '-Infinity'; + } + return new Date(ms).toString(); + }; + logDebug( + 'autoCacheCleanup: running for', serverUrl, + '— cutoff:', toDate(cutoff), + '— isActive:', isActive, + '— currentChannelId:', limits.viewedChannelId, + '— viewedChannelLimit:', toDateOrInf(limits.viewedChannelLimit), + '— threadParentChannelId:', limits.threadParentChannelId, + '— threadParentLimit:', toDateOrInf(limits.threadParentLimit), + '— currentThreadId:', limits.viewedThreadId, + '— activeThreadRootIds count:', limits.activeThreadRootIds.size, + '— fileViewerPostId:', limits.fileViewerPostId, + ); + + await cleanupPosts(serverUrl, database, operator, cutoff, limits); + await cleanupAiThreads(database, cutoff, isActive); + await cleanupPlaybookRuns(database, cutoff, isActive); + + await database.unsafeVacuum(); + await setLastAutoCacheCleanupRun(serverUrl); + logDebug('autoCacheCleanup: completed successfully for', serverUrl); + } catch (error) { + logError('autoCacheCleanup', error); + } + logDebug('autoCacheCleanup: done for', serverUrl); +} diff --git a/app/actions/local/post.ts b/app/actions/local/post.ts index 7b174456da5..93a50266850 100644 --- a/app/actions/local/post.ts +++ b/app/actions/local/post.ts @@ -414,6 +414,43 @@ export async function deletePosts(serverUrl: string, postIds: string[]) { } } +export async function deletePostsInChannelsByCutoff( + serverUrl: string, + channelIds: string[], + cutoff: number, + excludedPostIds: Set = new Set(), +): Promise<{error: unknown}> { + try { + const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + + const channels = `'${channelIds.join("','")}'`; + const exclusionClause = excludedPostIds.size > 0 + ? ` AND id NOT IN ('${[...excludedPostIds].join("','")}')` + : ''; + + const postCondition = `channel_id IN (${channels}) AND create_at < ${cutoff}${exclusionClause}`; + const postSubquery = `SELECT id FROM ${POST} WHERE ${postCondition}`; + + await database.write(() => { + return database.adapter.unsafeExecute({ + sqls: [ + [`DELETE FROM ${REACTION} WHERE post_id IN (${postSubquery})`, []], + [`DELETE FROM ${FILE} WHERE post_id IN (${postSubquery})`, []], + [`DELETE FROM ${DRAFT} WHERE root_id IN (${postSubquery})`, []], + [`DELETE FROM ${POSTS_IN_THREAD} WHERE root_id IN (${postSubquery})`, []], + [`DELETE FROM ${THREAD} WHERE id IN (${postSubquery})`, []], + [`DELETE FROM ${THREAD_PARTICIPANT} WHERE thread_id IN (${postSubquery})`, []], + [`DELETE FROM ${THREADS_IN_TEAM} WHERE thread_id IN (${postSubquery})`, []], + [`DELETE FROM ${POST} WHERE ${postCondition}`, []], + ], + }); + }); + return {error: undefined}; + } catch (error) { + return {error}; + } +} + export function getUsersCountFromMentions(serverUrl: string, mentions: string[]): Promise { try { const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); diff --git a/app/actions/websocket/index.ts b/app/actions/websocket/index.ts index 4537069adb0..0c66866889f 100644 --- a/app/actions/websocket/index.ts +++ b/app/actions/websocket/index.ts @@ -2,6 +2,7 @@ // See LICENSE.txt for license information. import {markChannelAsViewed} from '@actions/local/channel'; +import {autoCacheCleanup} from '@actions/local/ephemeral_mode/cleanup'; import {dataRetentionCleanup, expiredBoRPostCleanup} from '@actions/local/systems'; import {markChannelAsRead} from '@actions/remote/channel'; import {fetchClassificationBanner} from '@actions/remote/classification'; @@ -112,6 +113,8 @@ async function doReconnect(serverUrl: string, groupLabel?: BaseRequestGroupLabel expiredBoRPostCleanup(serverUrl); + autoCacheCleanup(serverUrl); + AppsManager.refreshAppBindings(serverUrl, groupLabel); return undefined; } finally { diff --git a/app/components/post_list/post_list.tsx b/app/components/post_list/post_list.tsx index 23874e36735..a86ad6dbaaa 100644 --- a/app/components/post_list/post_list.tsx +++ b/app/components/post_list/post_list.tsx @@ -54,6 +54,7 @@ type Props = { lastViewedAt: number; location: AvailableScreens; onEndReached?: () => void; + onViewableItemsChanged?: (viewableItems: ViewToken[]) => void; posts: PostModel[]; rootId?: string; shouldRenderReplyButton?: boolean; @@ -105,6 +106,7 @@ const PostList = ({ lastViewedAt, location, onEndReached, + onViewableItemsChanged: onViewableItemsChangedProp, posts, rootId, shouldRenderReplyButton = true, @@ -385,10 +387,12 @@ const PostList = ({ DeviceEventEmitter.emit(Events.ITEM_IN_VIEWPORT, viewableItemsMap); }); + onViewableItemsChangedProp?.(viewableItems); + if (onViewableItemsChangedListener.current) { onViewableItemsChangedListener.current(viewableItems); } - }, [location, trackInitialRenderMetrics]); + }, [location, onViewableItemsChangedProp, trackInitialRenderMetrics]); const registerScrollEndIndexListener = useCallback((listener: onScrollEndIndexListenerEvent) => { onScrollEndIndexListener.current = listener; diff --git a/app/constants/database.ts b/app/constants/database.ts index caf30180c23..b4fcf2d88fe 100644 --- a/app/constants/database.ts +++ b/app/constants/database.ts @@ -84,6 +84,7 @@ export const SYSTEM_IDENTIFIERS = { PLAYBOOKS_VERSION: 'playbooks_version', AGENTS_VERSION: 'agents_version', LAST_BOR_POST_CLEANUP_RUN: 'lastBoRPostCleanupRun', + LAST_AUTO_CACHE_CLEANUP_RUN: 'lastAutoCacheCleanupRun', }; export const GLOBAL_IDENTIFIERS = { diff --git a/app/constants/post.ts b/app/constants/post.ts index ffb2f2769db..c2de5b363e0 100644 --- a/app/constants/post.ts +++ b/app/constants/post.ts @@ -58,6 +58,8 @@ export const POST_TIME_TO_FAIL = toMilliseconds({seconds: 10}); export const BOR_POST_CLEANUP_MIN_RUN_INTERVAL = toMilliseconds({minutes: 15}); +export const AUTO_CACHE_CLEANUP_PROTECTION_BUFFER = 20; + export default { POST_COLLAPSE_TIMEOUT: toMilliseconds({minutes: 5}), POST_TYPES: PostTypes, diff --git a/app/managers/ephemeral_mode_manager/index.ts b/app/managers/ephemeral_mode_manager/index.ts index 35c880c9894..02be87fe170 100644 --- a/app/managers/ephemeral_mode_manager/index.ts +++ b/app/managers/ephemeral_mode_manager/index.ts @@ -15,7 +15,7 @@ import {getServer, getServerDisplayName} from '@queries/app/servers'; import {getDisconnectedSince, getLastSeenTime, getOfflineSince, observeConfigValue} from '@queries/servers/system'; import {navigateToScreen} from '@screens/navigation'; import {deleteFileCache} from '@utils/file'; -import {logError} from '@utils/log'; +import {logDebug, logError} from '@utils/log'; type ServerEntry = | {kind: 'zpm'} @@ -30,6 +30,7 @@ class EphemeralModeManagerSingleton { private appStateSubscription?: NativeEventSubscription; private trackedServers = new Map(); + private cleanupDays: Record = {}; private wipeInProgress = new Set(); private evalQueue: Record> = {}; @@ -88,6 +89,7 @@ class EphemeralModeManagerSingleton { this.trackedServers.delete(serverUrl); this.configSubscriptions[serverUrl]?.unsubscribe(); delete this.configSubscriptions[serverUrl]; + delete this.cleanupDays[serverUrl]; delete this.offlineSubjects[serverUrl]; this.maybeRemoveAppStateListener(); }; @@ -96,6 +98,10 @@ class EphemeralModeManagerSingleton { return this.offlineSubjects[serverUrl]?.getValue() ?? false; }; + public getAutoCacheCleanupDays = (serverUrl: string): number => { + return this.cleanupDays[serverUrl] ?? 0; + }; + public isZeroPersistenceMode = (serverUrl: string): boolean => { return this.trackedServers.get(serverUrl)?.kind === 'zpm'; }; @@ -121,13 +127,16 @@ class EphemeralModeManagerSingleton { observeConfigValue(database, 'MobileEphemeralModeEnabled'), observeConfigValue(database, 'MobileEphemeralModeDisconnectionTimeoutSeconds'), observeConfigValue(database, 'MobileEphemeralModeOfflinePersistenceTimerHours'), + observeConfigValue(database, 'MobileEphemeralModeAutoCacheCleanupDays'), ]).pipe( distinctUntilChanged( - ([prevEnabled, prevTimeout, prevPurgeHours], [nextEnabled, nextTimeout, nextPurgeHours]) => - prevEnabled === nextEnabled && prevTimeout === nextTimeout && prevPurgeHours === nextPurgeHours, + ([prevEnabled, prevTimeout, prevPurgeHours, prevCleanupDays], + [nextEnabled, nextTimeout, nextPurgeHours, nextCleanupDays]) => + prevEnabled === nextEnabled && prevTimeout === nextTimeout && + prevPurgeHours === nextPurgeHours && prevCleanupDays === nextCleanupDays, ), - ).subscribe(([enabledStr, timeoutStr, purgeHoursStr]) => { - this.onEphemeralModeConfigChange(serverUrl, enabledStr, timeoutStr, purgeHoursStr); + ).subscribe(([enabledStr, timeoutStr, purgeHoursStr, cleanupDaysStr]) => { + this.onEphemeralModeConfigChange(serverUrl, enabledStr, timeoutStr, purgeHoursStr, cleanupDaysStr); }); }; @@ -136,11 +145,18 @@ class EphemeralModeManagerSingleton { enabledStr: string | undefined, timeoutStr: string | undefined, purgeHoursStr: string | undefined, + cleanupDaysStr: string | undefined, ) => { const nextEnabled = enabledStr === 'true'; const nextThresholdMs = Math.max(0, Number(timeoutStr ?? '0')) * 1000; const nextPurgeThresholdMs = Math.max(0, Number(purgeHoursStr ?? '0')) * 3600 * 1000; + const nextCleanupDays = Math.max(0, Number(cleanupDaysStr ?? '0')); const wasActive = this.trackedServers.get(serverUrl)?.kind === 'mem'; + this.cleanupDays[serverUrl] = nextCleanupDays; + + if (nextCleanupDays > 0) { + logDebug('OfflinePersistenceManager: auto cache cleanup config received, days:', nextCleanupDays, 'for', serverUrl); + } if (nextEnabled && !wasActive) { this.track(serverUrl, nextThresholdMs, nextPurgeThresholdMs); diff --git a/app/queries/servers/post.ts b/app/queries/servers/post.ts index 6fcdd5170e2..cff5de0de54 100644 --- a/app/queries/servers/post.ts +++ b/app/queries/servers/post.ts @@ -427,3 +427,52 @@ export const syncPermalinkPreviewsForEditedPost = async ( return []; } }; + +export const fetchPostIdsByBucket = async ( + database: Database, + channelIds: string[], + cutoff: number, + exclusions: Set, +): Promise => { + const ids = await database.get(POST).query( + Q.where('channel_id', Q.oneOf(channelIds)), + Q.where('create_at', Q.lt(cutoff)), + Q.where('delete_at', Q.eq(0)), + ).fetchIds(); + return ids.filter((id) => !exclusions.has(id)); +}; + +// Returns the create_at of the nth post in channelId that is strictly older +// than anchor (sorted descending). Returns undefined when fewer than n such +// posts exist +export const createAtOfNthPostOlderThan = async ( + database: Database, + channelId: string, + anchor: number, + n: number, +): Promise => { + const posts = await database.get(POST).query( + Q.where('channel_id', Q.eq(channelId)), + Q.where('create_at', Q.lt(anchor)), + Q.where('delete_at', Q.eq(0)), + Q.sortBy('create_at', Q.desc), + Q.take(n), + ).fetch(); + if (posts.length < n) { + return undefined; + } + return posts[posts.length - 1].createAt; +}; + +// Returns the root IDs of all PostsInThread rows whose latest >= cutoff. +// These are live threads — their root post must not be evicted even if its +// create_at is below the cutoff. +export const queryActiveThreadRootIds = async ( + database: Database, + cutoff: number, +): Promise> => { + const rows = await database.get(POSTS_IN_THREAD).query( + Q.where('latest', Q.gte(cutoff)), + ).fetch(); + return new Set(rows.map((r) => r.rootId)); +}; diff --git a/app/screens/channel/channel_post_list/channel_post_list.tsx b/app/screens/channel/channel_post_list/channel_post_list.tsx index 34c203cba7a..bace208fc91 100644 --- a/app/screens/channel/channel_post_list/channel_post_list.tsx +++ b/app/screens/channel/channel_post_list/channel_post_list.tsx @@ -2,7 +2,7 @@ // See LICENSE.txt for license information. import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import {type StyleProp, StyleSheet, type ViewStyle, DeviceEventEmitter} from 'react-native'; +import {type StyleProp, StyleSheet, type ViewStyle, type ViewToken, DeviceEventEmitter} from 'react-native'; import {type Edge, SafeAreaView} from 'react-native-safe-area-context'; import {markChannelAsRead, unsetActiveChannelOnServer} from '@actions/remote/channel'; @@ -90,10 +90,13 @@ const ChannelPostList = ({ }, [fetchingPosts, posts]); useDidUpdate(() => { - if (oldPostsCount.current < posts.length && appState === 'active') { - oldPostsCount.current = posts.length; + if (posts.length < oldPostsCount.current) { + // Cache cleanup evicted posts — allow paginating older posts again. + canLoadPostsBefore.current = true; + } else if (posts.length > oldPostsCount.current && appState === 'active') { markChannelAsRead(serverUrl, channelId, true); } + oldPostsCount.current = posts.length; }, [posts.length]); useDidUpdate(() => { @@ -107,10 +110,28 @@ const ChannelPostList = ({ useDidMount(() => { return () => { + // Clear the viewport anchor when leaving the channel so a stale value + // cannot influence a cleanup run that fires before the next channel is opened. + EphemeralStore.setCurrentChannelOldestVisibleCreateAt(undefined); unsetActiveChannelOnServer(serverUrl); }; }); + const onViewablePostsChanged = useCallback((viewableItems: ViewToken[]) => { + let oldest = Infinity; + for (const {item, isViewable} of viewableItems) { + if (isViewable && item.type === 'post') { + const createAt = item.value.currentPost.createAt; + if (createAt < oldest) { + oldest = createAt; + } + } + } + if (oldest < Infinity) { + EphemeralStore.setCurrentChannelOldestVisibleCreateAt(oldest); + } + }, []); + const intro = useMemo(() => (), [channelId]); const postList = ( @@ -122,6 +143,7 @@ const ChannelPostList = ({ lastViewedAt={lastViewedAt} location={Screens.CHANNEL} onEndReached={onEndReached} + onViewableItemsChanged={onViewablePostsChanged} posts={posts} shouldShowJoinLeaveMessages={shouldShowJoinLeaveMessages} showMoreMessages={true} diff --git a/app/screens/gallery/index.tsx b/app/screens/gallery/index.tsx index f21dc0a566d..b578a74e166 100644 --- a/app/screens/gallery/index.tsx +++ b/app/screens/gallery/index.tsx @@ -8,8 +8,10 @@ import {useSafeAreaInsets} from 'react-native-safe-area-context'; import {Events, Screens} from '@constants'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import {useWindowDimensions} from '@hooks/device'; +import useDidMount from '@hooks/did_mount'; import {useGalleryControls} from '@hooks/gallery'; import {navigateBack} from '@screens/navigation'; +import EphemeralStore from '@store/ephemeral_store'; import Footer from './footer'; import Gallery, {type GalleryRef} from './gallery'; @@ -29,6 +31,14 @@ const GalleryScreen = ({galleryIdentifier, hideActions, initialIndex, items}: Ga const {bottom: bottomInset} = useSafeAreaInsets(); const [localIndex, setLocalIndex] = useState(initialIndex); + useDidMount(() => { + const postId = items[initialIndex]?.postId; + if (postId) { + EphemeralStore.setCurrentFileViewerPostId(postId); + } + return () => EphemeralStore.clearCurrentFileViewerPostId(); + }); + const {headerAndFooterHidden, hideHeaderAndFooter, headerStyles, footerStyles} = useGalleryControls(bottomInset); const galleryRef = useRef(null); @@ -49,7 +59,13 @@ const GalleryScreen = ({galleryIdentifier, hideActions, initialIndex, items}: Ga const onIndexChange = useCallback((index: number) => { setLocalIndex(index); - }, []); + const postId = items[index]?.postId; + if (postId) { + EphemeralStore.setCurrentFileViewerPostId(postId); + } else { + EphemeralStore.clearCurrentFileViewerPostId(); + } + }, [items]); useEffect(() => { const listener = DeviceEventEmitter.addListener(Events.CLOSE_GALLERY, () => { diff --git a/app/screens/pdf_viewer/pdf_viewer.tsx b/app/screens/pdf_viewer/pdf_viewer.tsx index a1a8187c2c6..94f230320d0 100644 --- a/app/screens/pdf_viewer/pdf_viewer.tsx +++ b/app/screens/pdf_viewer/pdf_viewer.tsx @@ -18,10 +18,13 @@ import {setFileAsBlocked} from '@actions/local/file'; import {Screens} from '@constants'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; +import DatabaseManager from '@database/manager'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import useDidMount from '@hooks/did_mount'; +import {getFileById} from '@queries/servers/file'; import {navigateBack} from '@screens/navigation'; import CallbackStore from '@store/callback_store'; +import EphemeralStore from '@store/ephemeral_store'; import {deleteFile} from '@utils/file'; import {logError} from '@utils/log'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; @@ -134,6 +137,25 @@ const PdfViewer = ({allowPdfLinkNavigation, fileId, filePath, siteURL}: Props) = return dismiss; }); + useDidMount(() => { + let active = true; + (async () => { + try { + const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + const file = await getFileById(database, fileId); + if (active && file?.postId) { + EphemeralStore.setCurrentFileViewerPostId(file.postId); + } + } catch { + // file not in DB — no protection needed + } + })(); + return () => { + active = false; + EphemeralStore.clearCurrentFileViewerPostId(); + }; + }); + return ( {Boolean(errorMessage) && } diff --git a/app/store/ephemeral_store.ts b/app/store/ephemeral_store.ts index d3c3a484faf..9a8c75ff251 100644 --- a/app/store/ephemeral_store.ts +++ b/app/store/ephemeral_store.ts @@ -42,6 +42,8 @@ class EphemeralStoreSingleton { private acknowledgingPost = new Set(); private unacknowledgingPost = new Set(); private currentThreadId = ''; + private currentChannelOldestVisibleCreateAt: number | undefined = undefined; + private currentFileViewerPostId = ''; private notificationTapped = false; private enablingCRT = false; @@ -338,6 +340,20 @@ class EphemeralStoreSingleton { this.currentThreadId = id; }; + getCurrentChannelOldestVisibleCreateAt = () => this.currentChannelOldestVisibleCreateAt; + setCurrentChannelOldestVisibleCreateAt = (value: number | undefined) => { + this.currentChannelOldestVisibleCreateAt = value; + }; + + // Protects the backing post from eviction while a file viewer is open. + getCurrentFileViewerPostId = () => this.currentFileViewerPostId; + setCurrentFileViewerPostId = (id: string) => { + this.currentFileViewerPostId = id; + }; + clearCurrentFileViewerPostId = () => { + this.currentFileViewerPostId = ''; + }; + // Ephemeral control when (un)archiving a channel locally addSwitchingToChannel = (channelId: string) => { this.switchingToChannel.add(channelId); From f505eccd457f7aa76ac11d233d1bdd59a7059d96 Mon Sep 17 00:00:00 2001 From: Carlos Garcia Date: Fri, 26 Jun 2026 10:58:27 +0200 Subject: [PATCH 02/13] ai threads rolling cleanup --- .../local/ephemeral_mode/cleanup.test.ts | 57 ++++++++++++++++++- app/actions/local/ephemeral_mode/cleanup.ts | 25 ++++++-- .../agents/database/queries/thread.ts | 9 +++ 3 files changed, 83 insertions(+), 8 deletions(-) diff --git a/app/actions/local/ephemeral_mode/cleanup.test.ts b/app/actions/local/ephemeral_mode/cleanup.test.ts index f2c6d6d285e..0d345847799 100644 --- a/app/actions/local/ephemeral_mode/cleanup.test.ts +++ b/app/actions/local/ephemeral_mode/cleanup.test.ts @@ -2,6 +2,7 @@ // See LICENSE.txt for license information. import * as LocalPost from '@actions/local/post'; +import {AGENTS_TABLES} from '@agents/constants/database'; import {Screens} from '@constants'; import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; import {AUTO_CACHE_CLEANUP_PROTECTION_BUFFER} from '@constants/post'; @@ -14,6 +15,7 @@ import {logError} from '@utils/log'; import {autoCacheCleanup} from './cleanup'; +import type AiThreadModel from '@agents/types/database/models/ai_thread'; import type ServerDataOperator from '@database/operator/server_data_operator'; import type {Database} from '@nozbe/watermelondb'; import type MyChannelModel from '@typings/database/models/servers/my_channel'; @@ -21,6 +23,7 @@ import type PostsInChannelModel from '@typings/database/models/servers/posts_in_ import type PostsInThreadModel from '@typings/database/models/servers/posts_in_thread'; const {SERVER: {MY_CHANNEL, POST, POSTS_IN_CHANNEL, POSTS_IN_THREAD}} = MM_TABLES; +const {AI_THREAD} = AGENTS_TABLES; jest.mock('@managers/offline_persistence_manager', () => ({ __esModule: true, @@ -53,9 +56,9 @@ jest.mock('@actions/local/post', () => ({ const SERVER_URL = 'cleanup.test.com'; // Fixed clock so Date.now() inside autoCacheCleanup is deterministic. -// cleanupDays=1 in debug mode → cutoff = NOW - 1*3_600_000 -const NOW = 10_000_000; -const CUTOFF = NOW - 3_600_000; // 6_400_000 +// cleanupDays=1 → cutoff = NOW - 1 day (DateConstants.SECONDS.DAY * 1000). +const NOW = 100_000_000; +const CUTOFF = NOW - (86_400 * 1000); // 13_600_000 const OLD = 1_000_000; // clearly below CUTOFF const RECENT = NOW; // clearly above CUTOFF @@ -118,6 +121,19 @@ async function writePost(id: string, channelId: string, createAt: number): Promi }); } +async function writeAiThread(id: string, updateAt: number): Promise { + await database.write(async () => { + await database.get(AI_THREAD).create((r) => { + r._raw.id = id; + r.channelId = 'bot-dm'; + r.message = ''; + r.title = ''; + r.replyCount = 0; + r.updateAt = updateAt; + }); + }); +} + describe('autoCacheCleanup', () => { beforeEach(async () => { jest.clearAllMocks(); @@ -376,4 +392,39 @@ describe('autoCacheCleanup', () => { expect(vacuumSpy).not.toHaveBeenCalled(); expect(logError).toHaveBeenCalledWith('autoCacheCleanup', expect.any(Error)); }); + + // TC-14 + it('deletes AI threads older than the cutoff and keeps newer ones', async () => { + await writeAiThread('ai-old', OLD); + await writeAiThread('ai-recent', RECENT); + + await autoCacheCleanup(SERVER_URL); + + const ids = (await database.get(AI_THREAD).query().fetch()).map((r) => r.id); + expect(ids).toEqual(['ai-recent']); + }); + + // TC-15 + it('spares the currently-viewed AI thread on the active server even when it is stale', async () => { + jest.spyOn(DatabaseManager, 'getActiveServerUrl').mockResolvedValue(SERVER_URL); + jest.mocked(EphemeralStore.getCurrentThreadId).mockReturnValue('open-ai-thread'); + await writeAiThread('open-ai-thread', OLD); + await writeAiThread('other', OLD); + + await autoCacheCleanup(SERVER_URL); + + const ids = (await database.get(AI_THREAD).query().fetch()).map((r) => r.id); + expect(ids).toEqual(['open-ai-thread']); + }); + + // TC-16 + it('deletes all stale AI threads on a non-active server regardless of the viewed thread id', async () => { + jest.mocked(EphemeralStore.getCurrentThreadId).mockReturnValue('open-ai-thread'); + await writeAiThread('open-ai-thread', OLD); + + await autoCacheCleanup(SERVER_URL); + + const threads = await database.get(AI_THREAD).query().fetch(); + expect(threads.length).toBe(0); + }); }); diff --git a/app/actions/local/ephemeral_mode/cleanup.ts b/app/actions/local/ephemeral_mode/cleanup.ts index ecc3aef24a9..8ac99063f2f 100644 --- a/app/actions/local/ephemeral_mode/cleanup.ts +++ b/app/actions/local/ephemeral_mode/cleanup.ts @@ -2,6 +2,7 @@ // See LICENSE.txt for license information. import {deletePostsInChannelsByCutoff} from '@actions/local/post'; +import {queryAIThreadsBefore} from '@agents/database/queries/thread'; import {Screens} from '@constants'; import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; import DateConstants from '@constants/datetime'; @@ -218,12 +219,26 @@ async function cleanupPosts( } } -// Stubs for Stage 3 and Stage 4. -// eslint-disable-next-line @typescript-eslint/no-unused-vars -async function cleanupAiThreads(_database: Database, _cutoff: number, _isActive: boolean) { - // Implemented in Stage 3. +// AI threads self-heal on next open (re-fetched from the server), so the only +// protection is the currently-viewed thread. viewedThreadId is the open thread's +// root post id, which equals the AiThread id; it is undefined on non-active servers. +async function cleanupAiThreads( + database: Database, + operator: {batchRecords: (models: Model[], description: string) => Promise}, + cutoff: number, + viewedThreadId: string | undefined, +) { + const staleThreads = await queryAIThreadsBefore(database, cutoff).fetch(); + const prepared = staleThreads. + filter((thread) => thread.id !== viewedThreadId). + map((thread) => thread.prepareDestroyPermanently()); + + if (prepared.length > 0) { + await operator.batchRecords(prepared, 'cleanupAiThreads'); + } } +// Stub for Stage 4. // eslint-disable-next-line @typescript-eslint/no-unused-vars async function cleanupPlaybookRuns(_database: Database, _cutoff: number, _isActive: boolean) { // Implemented in Stage 4. @@ -279,7 +294,7 @@ export async function autoCacheCleanup(serverUrl: string): Promise { ); await cleanupPosts(serverUrl, database, operator, cutoff, limits); - await cleanupAiThreads(database, cutoff, isActive); + await cleanupAiThreads(database, operator, cutoff, limits.viewedThreadId); await cleanupPlaybookRuns(database, cutoff, isActive); await database.unsafeVacuum(); diff --git a/app/products/agents/database/queries/thread.ts b/app/products/agents/database/queries/thread.ts index 9519ed1b290..b9b78e75697 100644 --- a/app/products/agents/database/queries/thread.ts +++ b/app/products/agents/database/queries/thread.ts @@ -83,3 +83,12 @@ export async function getAIThreadById(database: Database, threadId: string) { export async function getAllAIThreads(database: Database) { return queryAIThreads(database).fetch(); } + +/** + * Returns a query for AI threads last updated before the given cutoff. + */ +export function queryAIThreadsBefore(database: Database, cutoff: number) { + return database.get(AI_THREAD).query( + Q.where('update_at', Q.lt(cutoff)), + ); +} From 999625dcbbd3c91bce501a5afde62589d7b4cb48 Mon Sep 17 00:00:00 2001 From: Carlos Garcia Date: Tue, 30 Jun 2026 12:00:04 +0200 Subject: [PATCH 03/13] auto rolling cleanup of playbook runs --- .../local/ephemeral_mode/cleanup.test.ts | 66 +++++++++++++++++++ app/actions/local/ephemeral_mode/cleanup.ts | 30 +++++++-- .../playbooks/database/queries/run.ts | 6 ++ .../playbook_run/playbook_run.test.tsx | 1 + .../screens/playbook_run/playbook_run.tsx | 9 +++ app/store/ephemeral_store.ts | 10 +++ 6 files changed, 117 insertions(+), 5 deletions(-) diff --git a/app/actions/local/ephemeral_mode/cleanup.test.ts b/app/actions/local/ephemeral_mode/cleanup.test.ts index 0d345847799..a8aa4dc72c4 100644 --- a/app/actions/local/ephemeral_mode/cleanup.test.ts +++ b/app/actions/local/ephemeral_mode/cleanup.test.ts @@ -8,6 +8,7 @@ import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; import {AUTO_CACHE_CLEANUP_PROTECTION_BUFFER} from '@constants/post'; import DatabaseManager from '@database/manager'; import OfflinePersistenceManager from '@managers/offline_persistence_manager'; +import {PLAYBOOK_TABLES} from '@playbooks/constants/database'; import {getCurrentChannelId} from '@queries/servers/system'; import EphemeralStore from '@store/ephemeral_store'; import {NavigationStore} from '@store/navigation_store'; @@ -24,6 +25,7 @@ import type PostsInThreadModel from '@typings/database/models/servers/posts_in_t const {SERVER: {MY_CHANNEL, POST, POSTS_IN_CHANNEL, POSTS_IN_THREAD}} = MM_TABLES; const {AI_THREAD} = AGENTS_TABLES; +const {PLAYBOOK_RUN, PLAYBOOK_CHECKLIST, PLAYBOOK_CHECKLIST_ITEM} = PLAYBOOK_TABLES; jest.mock('@managers/offline_persistence_manager', () => ({ __esModule: true, @@ -40,6 +42,7 @@ jest.mock('@store/ephemeral_store', () => ({ getCurrentChannelOldestVisibleCreateAt: jest.fn(), getCurrentThreadId: jest.fn(), getCurrentFileViewerPostId: jest.fn(), + getCurrentPlaybookRunId: jest.fn(), }, })); @@ -134,6 +137,21 @@ async function writeAiThread(id: string, updateAt: number): Promise { }); } +async function writePlaybookRun(id: string, createAt: number): Promise { + await database.write(async () => { + const run = await database.get(PLAYBOOK_RUN).create((r: any) => { + r._raw.id = id; + r.createAt = createAt; + }); + const checklist = await database.get(PLAYBOOK_CHECKLIST).create((r: any) => { + r.runId = run.id; + }); + await database.get(PLAYBOOK_CHECKLIST_ITEM).create((r: any) => { + r.checklistId = checklist.id; + }); + }); +} + describe('autoCacheCleanup', () => { beforeEach(async () => { jest.clearAllMocks(); @@ -149,6 +167,7 @@ describe('autoCacheCleanup', () => { jest.mocked(EphemeralStore.getCurrentChannelOldestVisibleCreateAt).mockReturnValue(0); jest.mocked(EphemeralStore.getCurrentThreadId).mockReturnValue(''); jest.mocked(EphemeralStore.getCurrentFileViewerPostId).mockReturnValue(''); + jest.mocked(EphemeralStore.getCurrentPlaybookRunId).mockReturnValue(''); jest.mocked(getCurrentChannelId).mockResolvedValue(''); jest.mocked(LocalPost.deletePostsInChannelsByCutoff).mockResolvedValue({error: undefined}); }); @@ -427,4 +446,51 @@ describe('autoCacheCleanup', () => { const threads = await database.get(AI_THREAD).query().fetch(); expect(threads.length).toBe(0); }); + + // TC-17 + it('deletes playbook runs created before the cutoff and keeps newer ones', async () => { + await writePlaybookRun('run-old', OLD); + await writePlaybookRun('run-recent', RECENT); + + await autoCacheCleanup(SERVER_URL); + + const ids = (await database.get(PLAYBOOK_RUN).query().fetch()).map((r) => r.id); + expect(ids).toEqual(['run-recent']); + }); + + // TC-18 + it('spares the currently-viewed playbook run on the active server even when it is stale', async () => { + jest.spyOn(DatabaseManager, 'getActiveServerUrl').mockResolvedValue(SERVER_URL); + jest.mocked(EphemeralStore.getCurrentPlaybookRunId).mockReturnValue('open-run'); + await writePlaybookRun('open-run', OLD); + await writePlaybookRun('other-run', OLD); + + await autoCacheCleanup(SERVER_URL); + + const ids = (await database.get(PLAYBOOK_RUN).query().fetch()).map((r) => r.id); + expect(ids).toEqual(['open-run']); + }); + + // TC-19 + it('deletes all stale playbook runs on a non-active server regardless of the viewed run id', async () => { + jest.mocked(EphemeralStore.getCurrentPlaybookRunId).mockReturnValue('open-run'); + await writePlaybookRun('open-run', OLD); + + await autoCacheCleanup(SERVER_URL); + + const runs = await database.get(PLAYBOOK_RUN).query().fetch(); + expect(runs.length).toBe(0); + }); + + // TC-20 + it('cascades deletion of a stale run to its checklists and checklist items', async () => { + await writePlaybookRun('run-old', OLD); + + await autoCacheCleanup(SERVER_URL); + + const checklists = await database.get(PLAYBOOK_CHECKLIST).query().fetch(); + const items = await database.get(PLAYBOOK_CHECKLIST_ITEM).query().fetch(); + expect(checklists.length).toBe(0); + expect(items.length).toBe(0); + }); }); diff --git a/app/actions/local/ephemeral_mode/cleanup.ts b/app/actions/local/ephemeral_mode/cleanup.ts index 8ac99063f2f..a7ba8dfb936 100644 --- a/app/actions/local/ephemeral_mode/cleanup.ts +++ b/app/actions/local/ephemeral_mode/cleanup.ts @@ -9,6 +9,7 @@ import DateConstants from '@constants/datetime'; import {AUTO_CACHE_CLEANUP_PROTECTION_BUFFER} from '@constants/post'; import DatabaseManager from '@database/manager'; import OfflinePersistenceManager from '@managers/offline_persistence_manager'; +import {queryPlaybookRunsBefore} from '@playbooks/database/queries/run'; import { createAtOfNthPostOlderThan, getPostById, @@ -35,6 +36,7 @@ interface CleanupProtections { viewedThreadId: string | undefined; activeThreadRootIds: Set; fileViewerPostId: string | undefined; + viewedPlaybookRunId: string | undefined; } async function getLastAutoCacheCleanupRun(database: Database): Promise { @@ -76,6 +78,7 @@ async function computeCleanupProtections( threadParentLimit: Infinity, activeThreadRootIds, fileViewerPostId: undefined, + viewedPlaybookRunId: undefined, }; } @@ -108,6 +111,7 @@ async function computeCleanupProtections( } const fileViewerPostId = EphemeralStore.getCurrentFileViewerPostId() || undefined; + const viewedPlaybookRunId = EphemeralStore.getCurrentPlaybookRunId() || undefined; return { viewedChannelId, @@ -117,6 +121,7 @@ async function computeCleanupProtections( threadParentLimit, activeThreadRootIds, fileViewerPostId, + viewedPlaybookRunId, }; } @@ -238,10 +243,24 @@ async function cleanupAiThreads( } } -// Stub for Stage 4. -// eslint-disable-next-line @typescript-eslint/no-unused-vars -async function cleanupPlaybookRuns(_database: Database, _cutoff: number, _isActive: boolean) { - // Implemented in Stage 4. +// Playbook runs re-fetch from the server on next open, so the only protection is +// the currently-viewed run; viewedPlaybookRunId is undefined on non-active servers. +async function cleanupPlaybookRuns( + database: Database, + operator: {batchRecords: (models: Model[], description: string) => Promise}, + cutoff: number, + viewedPlaybookRunId: string | undefined, +) { + const staleRuns = await queryPlaybookRunsBefore(database, cutoff).fetch(); + const prepared = (await Promise.all( + staleRuns. + filter((run) => run.id !== viewedPlaybookRunId). + map((run) => run.prepareDestroyWithRelations()), + )).flat(); + + if (prepared.length > 0) { + await operator.batchRecords(prepared, 'cleanupPlaybookRuns'); + } } export async function autoCacheCleanup(serverUrl: string): Promise { @@ -291,11 +310,12 @@ export async function autoCacheCleanup(serverUrl: string): Promise { '— currentThreadId:', limits.viewedThreadId, '— activeThreadRootIds count:', limits.activeThreadRootIds.size, '— fileViewerPostId:', limits.fileViewerPostId, + '— currentPlaybookRunId:', limits.viewedPlaybookRunId, ); await cleanupPosts(serverUrl, database, operator, cutoff, limits); await cleanupAiThreads(database, operator, cutoff, limits.viewedThreadId); - await cleanupPlaybookRuns(database, cutoff, isActive); + await cleanupPlaybookRuns(database, operator, cutoff, limits.viewedPlaybookRunId); await database.unsafeVacuum(); await setLastAutoCacheCleanupRun(serverUrl); diff --git a/app/products/playbooks/database/queries/run.ts b/app/products/playbooks/database/queries/run.ts index b4b381152d3..a2d12f13127 100644 --- a/app/products/playbooks/database/queries/run.ts +++ b/app/products/playbooks/database/queries/run.ts @@ -29,6 +29,12 @@ export const queryPlaybookRunsPerChannel = (database: Database, channelId: strin ); }; +export const queryPlaybookRunsBefore = (database: Database, cutoff: number) => { + return database.get(PLAYBOOK_RUN).query( + Q.where('create_at', Q.lt(cutoff)), + ); +}; + export const getPlaybookRunById = async (database: Database, id: string) => { try { const run = await database.get(PLAYBOOK_RUN).find(id); diff --git a/app/products/playbooks/screens/playbook_run/playbook_run.test.tsx b/app/products/playbooks/screens/playbook_run/playbook_run.test.tsx index 5d27ce0c0b8..ce17fbab68f 100644 --- a/app/products/playbooks/screens/playbook_run/playbook_run.test.tsx +++ b/app/products/playbooks/screens/playbook_run/playbook_run.test.tsx @@ -134,6 +134,7 @@ describe('PlaybookRun', () => { ]; return { + playbookRunId: mockPlaybookRun.id, playbookRun: mockPlaybookRun, owner: mockOwner, participants: mockParticipants, diff --git a/app/products/playbooks/screens/playbook_run/playbook_run.tsx b/app/products/playbooks/screens/playbook_run/playbook_run.tsx index 12901c73737..498f2eb1427 100644 --- a/app/products/playbooks/screens/playbook_run/playbook_run.tsx +++ b/app/products/playbooks/screens/playbook_run/playbook_run.tsx @@ -16,10 +16,12 @@ import {Screens} from '@constants'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; +import useDidMount from '@hooks/did_mount'; import {finishRun, setOwner} from '@playbooks/actions/remote/runs'; import {PLAYBOOK_RUN_TYPES} from '@playbooks/constants/playbook_run'; import {getRunScheduledTimestamp, isRunFinished} from '@playbooks/utils/run'; import {navigateBack} from '@screens/navigation'; +import EphemeralStore from '@store/ephemeral_store'; import {openUserProfile} from '@utils/navigation'; import {showPlaybookErrorSnackbar} from '@utils/snack_bar'; import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme'; @@ -165,6 +167,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => ({ })); type Props = { + playbookRunId: string; playbookRun?: PlaybookRunModel | PlaybookRun; owner?: UserModel; participants: UserModel[]; @@ -177,6 +180,7 @@ type Props = { } export default function PlaybookRun({ + playbookRunId, playbookRun, owner, participants, @@ -198,6 +202,11 @@ export default function PlaybookRun({ useAndroidHardwareBackHandler(Screens.PLAYBOOK_RUN, navigateBack); + useDidMount(() => { + EphemeralStore.setCurrentPlaybookRunId(playbookRunId); + return () => EphemeralStore.clearCurrentPlaybookRunId(); + }); + const isParticipant = participants.some((p) => p.id === currentUserId) || owner?.id === currentUserId; const isFinished = isRunFinished(playbookRun); diff --git a/app/store/ephemeral_store.ts b/app/store/ephemeral_store.ts index 9a8c75ff251..784b1761f72 100644 --- a/app/store/ephemeral_store.ts +++ b/app/store/ephemeral_store.ts @@ -44,6 +44,7 @@ class EphemeralStoreSingleton { private currentThreadId = ''; private currentChannelOldestVisibleCreateAt: number | undefined = undefined; private currentFileViewerPostId = ''; + private currentPlaybookRunId = ''; private notificationTapped = false; private enablingCRT = false; @@ -354,6 +355,15 @@ class EphemeralStoreSingleton { this.currentFileViewerPostId = ''; }; + // Protects the viewed playbook run from rolling cache eviction. + getCurrentPlaybookRunId = () => this.currentPlaybookRunId; + setCurrentPlaybookRunId = (id: string) => { + this.currentPlaybookRunId = id; + }; + clearCurrentPlaybookRunId = () => { + this.currentPlaybookRunId = ''; + }; + // Ephemeral control when (un)archiving a channel locally addSwitchingToChannel = (channelId: string) => { this.switchingToChannel.add(channelId); From 70769be9d8c7122e0afdeb4fde88d74d23180189 Mon Sep 17 00:00:00 2001 From: Carlos Garcia Date: Tue, 14 Jul 2026 15:29:02 +0200 Subject: [PATCH 04/13] fix to import and use renamed module --- app/actions/local/ephemeral_mode/cleanup.test.ts | 10 +++++----- app/actions/local/ephemeral_mode/cleanup.ts | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app/actions/local/ephemeral_mode/cleanup.test.ts b/app/actions/local/ephemeral_mode/cleanup.test.ts index a8aa4dc72c4..c421ebe8852 100644 --- a/app/actions/local/ephemeral_mode/cleanup.test.ts +++ b/app/actions/local/ephemeral_mode/cleanup.test.ts @@ -7,7 +7,7 @@ import {Screens} from '@constants'; import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; import {AUTO_CACHE_CLEANUP_PROTECTION_BUFFER} from '@constants/post'; import DatabaseManager from '@database/manager'; -import OfflinePersistenceManager from '@managers/offline_persistence_manager'; +import EphemeralModeManager from '@managers/ephemeral_mode_manager'; import {PLAYBOOK_TABLES} from '@playbooks/constants/database'; import {getCurrentChannelId} from '@queries/servers/system'; import EphemeralStore from '@store/ephemeral_store'; @@ -27,7 +27,7 @@ const {SERVER: {MY_CHANNEL, POST, POSTS_IN_CHANNEL, POSTS_IN_THREAD}} = MM_TABLE const {AI_THREAD} = AGENTS_TABLES; const {PLAYBOOK_RUN, PLAYBOOK_CHECKLIST, PLAYBOOK_CHECKLIST_ITEM} = PLAYBOOK_TABLES; -jest.mock('@managers/offline_persistence_manager', () => ({ +jest.mock('@managers/ephemeral_mode_manager', () => ({ __esModule: true, default: {getAutoCacheCleanupDays: jest.fn()}, })); @@ -162,7 +162,7 @@ describe('autoCacheCleanup', () => { // LokiJS adapter has no unsafeVacuum; mock it to avoid a hanging toPromise callback jest.spyOn(database, 'unsafeVacuum').mockResolvedValue(); - jest.mocked(OfflinePersistenceManager.getAutoCacheCleanupDays).mockReturnValue(1); + jest.mocked(EphemeralModeManager.getAutoCacheCleanupDays).mockReturnValue(1); jest.mocked(NavigationStore.getScreensInStack).mockReturnValue([]); jest.mocked(EphemeralStore.getCurrentChannelOldestVisibleCreateAt).mockReturnValue(0); jest.mocked(EphemeralStore.getCurrentThreadId).mockReturnValue(''); @@ -181,10 +181,10 @@ describe('autoCacheCleanup', () => { it('exits early without DB access when cleanupDays is 0 or negative', async () => { const dbSpy = jest.spyOn(DatabaseManager, 'getServerDatabaseAndOperator'); - jest.mocked(OfflinePersistenceManager.getAutoCacheCleanupDays).mockReturnValue(0); + jest.mocked(EphemeralModeManager.getAutoCacheCleanupDays).mockReturnValue(0); await autoCacheCleanup(SERVER_URL); - jest.mocked(OfflinePersistenceManager.getAutoCacheCleanupDays).mockReturnValue(-3); + jest.mocked(EphemeralModeManager.getAutoCacheCleanupDays).mockReturnValue(-3); await autoCacheCleanup(SERVER_URL); expect(dbSpy).not.toHaveBeenCalled(); diff --git a/app/actions/local/ephemeral_mode/cleanup.ts b/app/actions/local/ephemeral_mode/cleanup.ts index a7ba8dfb936..b8877a33c52 100644 --- a/app/actions/local/ephemeral_mode/cleanup.ts +++ b/app/actions/local/ephemeral_mode/cleanup.ts @@ -8,7 +8,7 @@ import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; import DateConstants from '@constants/datetime'; import {AUTO_CACHE_CLEANUP_PROTECTION_BUFFER} from '@constants/post'; import DatabaseManager from '@database/manager'; -import OfflinePersistenceManager from '@managers/offline_persistence_manager'; +import EphemeralModeManager from '@managers/ephemeral_mode_manager'; import {queryPlaybookRunsBefore} from '@playbooks/database/queries/run'; import { createAtOfNthPostOlderThan, @@ -264,7 +264,7 @@ async function cleanupPlaybookRuns( } export async function autoCacheCleanup(serverUrl: string): Promise { - const cleanupDays = OfflinePersistenceManager.getAutoCacheCleanupDays(serverUrl); + const cleanupDays = EphemeralModeManager.getAutoCacheCleanupDays(serverUrl); if (cleanupDays <= 0) { logDebug('autoCacheCleanup: cleanupDays <= 0 for', serverUrl); return; From 3c67d20731f5e09890bc17cca1b74eb6350ec794 Mon Sep 17 00:00:00 2001 From: Carlos Garcia Date: Tue, 21 Jul 2026 12:22:51 +0200 Subject: [PATCH 05/13] makes post cleanup atomic by moving processing to sql layer --- .../local/ephemeral_mode/cleanup.test.ts | 221 ++++++++++-------- app/actions/local/ephemeral_mode/cleanup.ts | 103 +++----- app/actions/local/post.test.ts | 70 +++++- app/actions/local/post.ts | 18 +- app/managers/ephemeral_mode_manager/index.ts | 2 +- 5 files changed, 244 insertions(+), 170 deletions(-) diff --git a/app/actions/local/ephemeral_mode/cleanup.test.ts b/app/actions/local/ephemeral_mode/cleanup.test.ts index c421ebe8852..52140f9f3c6 100644 --- a/app/actions/local/ephemeral_mode/cleanup.test.ts +++ b/app/actions/local/ephemeral_mode/cleanup.test.ts @@ -19,11 +19,10 @@ import {autoCacheCleanup} from './cleanup'; import type AiThreadModel from '@agents/types/database/models/ai_thread'; import type ServerDataOperator from '@database/operator/server_data_operator'; import type {Database} from '@nozbe/watermelondb'; -import type MyChannelModel from '@typings/database/models/servers/my_channel'; import type PostsInChannelModel from '@typings/database/models/servers/posts_in_channel'; import type PostsInThreadModel from '@typings/database/models/servers/posts_in_thread'; -const {SERVER: {MY_CHANNEL, POST, POSTS_IN_CHANNEL, POSTS_IN_THREAD}} = MM_TABLES; +const {SERVER: {POST, POSTS_IN_CHANNEL, POSTS_IN_THREAD}} = MM_TABLES; const {AI_THREAD} = AGENTS_TABLES; const {PLAYBOOK_RUN, PLAYBOOK_CHECKLIST, PLAYBOOK_CHECKLIST_ITEM} = PLAYBOOK_TABLES; @@ -62,8 +61,8 @@ const SERVER_URL = 'cleanup.test.com'; // cleanupDays=1 → cutoff = NOW - 1 day (DateConstants.SECONDS.DAY * 1000). const NOW = 100_000_000; const CUTOFF = NOW - (86_400 * 1000); // 13_600_000 -const OLD = 1_000_000; // clearly below CUTOFF -const RECENT = NOW; // clearly above CUTOFF +const OLD = 1_000_000; // below CUTOFF +const RECENT = NOW; // above CUTOFF let database: Database; let operator: ServerDataOperator; @@ -92,15 +91,6 @@ async function writePiT(rootId: string, earliest: number, latest: number): Promi return record; } -async function writeMyChannel(channelId: string, lastFetchedAt: number): Promise { - await database.write(async () => { - await database.get(MY_CHANNEL).create((r) => { - r._raw.id = channelId; - r.lastFetchedAt = lastFetchedAt; - }); - }); -} - async function writePost(id: string, channelId: string, createAt: number): Promise { await database.write(async () => { await database.get(POST).create((r: any) => { @@ -177,7 +167,6 @@ describe('autoCacheCleanup', () => { jest.restoreAllMocks(); }); - // TC-1 it('exits early without DB access when cleanupDays is 0 or negative', async () => { const dbSpy = jest.spyOn(DatabaseManager, 'getServerDatabaseAndOperator'); @@ -190,7 +179,6 @@ describe('autoCacheCleanup', () => { expect(dbSpy).not.toHaveBeenCalled(); }); - // TC-2 it('logs an error and returns when getServerDatabaseAndOperator throws', async () => { jest.spyOn(DatabaseManager, 'getServerDatabaseAndOperator').mockImplementationOnce(() => { throw new Error('db unavailable'); @@ -201,7 +189,6 @@ describe('autoCacheCleanup', () => { expect(logError).toHaveBeenCalledWith('autoCacheCleanup getServerDatabaseAndOperator', expect.any(Error)); }); - // TC-3b it('does not call getCurrentChannelId when the channel screen is not in the navigation stack', async () => { jest.spyOn(DatabaseManager, 'getActiveServerUrl').mockResolvedValue(SERVER_URL); jest.mocked(NavigationStore.getScreensInStack).mockReturnValue([]); @@ -211,39 +198,41 @@ describe('autoCacheCleanup', () => { expect(getCurrentChannelId).not.toHaveBeenCalled(); }); - // TC-4 - it('destroys the PiC row and resets MyChannel.lastFetchedAt for an entirely-stale unprotected range', async () => { - const channelId = 'ch-stale'; - await writePiC(channelId, OLD - 100, OLD); - await writeMyChannel(channelId, 1000); + it('routes the unprotected channel to the bulk call with the raw cutoff and excludes the viewed channel from it', async () => { + const viewedChannelId = 'ch-viewed-routing'; + const unprotectedChannelId = 'ch-unprotected-routing'; + jest.spyOn(DatabaseManager, 'getActiveServerUrl').mockResolvedValue(SERVER_URL); + jest.mocked(NavigationStore.getScreensInStack).mockReturnValue([Screens.CHANNEL]); + jest.mocked(getCurrentChannelId).mockResolvedValue(viewedChannelId); + await writePiC(viewedChannelId, OLD, RECENT); + await writePiC(unprotectedChannelId, OLD, RECENT); await autoCacheCleanup(SERVER_URL); - const picRows = await database.get(POSTS_IN_CHANNEL).query().fetch(); - expect(picRows.length).toBe(0); - - const mc = await database.get(MY_CHANNEL).find(channelId); - expect(mc.lastFetchedAt).toBe(0); + expect(LocalPost.deletePostsInChannelsByCutoff).toHaveBeenCalledWith( + SERVER_URL, [unprotectedChannelId], CUTOFF, expect.any(Set), + ); + expect(LocalPost.deletePostsInChannelsByCutoff).toHaveBeenCalledWith( + SERVER_URL, [viewedChannelId], CUTOFF, expect.any(Set), + ); }); - // TC-5 - it('updates PiC earliest to CUTOFF for a straddling range and preserves MyChannel.lastFetchedAt', async () => { - const channelId = 'ch-straddle'; - await writePiC(channelId, OLD, RECENT); - await writeMyChannel(channelId, 1000); + it('does not call the bulk unprotected-channels delete when every channel with post ranges is protected', async () => { + const viewedChannelId = 'ch-only-viewed'; + jest.spyOn(DatabaseManager, 'getActiveServerUrl').mockResolvedValue(SERVER_URL); + jest.mocked(NavigationStore.getScreensInStack).mockReturnValue([Screens.CHANNEL]); + jest.mocked(getCurrentChannelId).mockResolvedValue(viewedChannelId); + await writePiC(viewedChannelId, OLD, RECENT); await autoCacheCleanup(SERVER_URL); - const [pic] = await database.get(POSTS_IN_CHANNEL).query().fetch(); - expect(pic.earliest).toBe(CUTOFF); - expect(pic.latest).toBe(RECENT); - - const mc = await database.get(MY_CHANNEL).find(channelId); - expect(mc.lastFetchedAt).toBe(1000); + expect(LocalPost.deletePostsInChannelsByCutoff).toHaveBeenCalledTimes(1); + expect(LocalPost.deletePostsInChannelsByCutoff).toHaveBeenCalledWith( + SERVER_URL, [viewedChannelId], CUTOFF, expect.any(Set), + ); }); - // TC-6 - it('uses the 20th-older-post create_at as the effective cutoff for the viewed channel', async () => { + it('uses the create_at of the AUTO_CACHE_CLEANUP_PROTECTION_BUFFER-th older post as the effective cutoff for the viewed channel', async () => { const viewedChannelId = 'ch-viewed'; jest.spyOn(DatabaseManager, 'getActiveServerUrl').mockResolvedValue(SERVER_URL); jest.mocked(NavigationStore.getScreensInStack).mockReturnValue([Screens.CHANNEL]); @@ -253,7 +242,7 @@ describe('autoCacheCleanup', () => { const OLDEST_VISIBLE = CUTOFF + 2000; jest.mocked(EphemeralStore.getCurrentChannelOldestVisibleCreateAt).mockReturnValue(OLDEST_VISIBLE); - // 20 posts older than OLDEST_VISIBLE; the 20th (oldest) lands below CUTOFF + // AUTO_CACHE_CLEANUP_PROTECTION_BUFFER posts older than OLDEST_VISIBLE; the oldest of them lands below CUTOFF await Promise.all( Array.from({length: AUTO_CACHE_CLEANUP_PROTECTION_BUFFER}, (_, idx) => writePost(`post-${idx + 1}`, viewedChannelId, OLDEST_VISIBLE - ((idx + 1) * 105)), @@ -263,18 +252,14 @@ describe('autoCacheCleanup', () => { await autoCacheCleanup(SERVER_URL); - // 20th post create_at = OLDEST_VISIBLE - 20*105 = CUTOFF - 100 + // AUTO_CACHE_CLEANUP_PROTECTION_BUFFER-th post create_at = OLDEST_VISIBLE - (BUFFER * 105) = CUTOFF - 100 const PROTECTION_CUTOFF = OLDEST_VISIBLE - (AUTO_CACHE_CLEANUP_PROTECTION_BUFFER * 105); expect(LocalPost.deletePostsInChannelsByCutoff).toHaveBeenCalledWith( SERVER_URL, [viewedChannelId], PROTECTION_CUTOFF, expect.any(Set), ); - - const [pic] = await database.get(POSTS_IN_CHANNEL).query().fetch(); - expect(pic.earliest).toBe(PROTECTION_CUTOFF); }); - // TC-7 it('applies no extra protection when fewer than BUFFER posts precede the scroll anchor', async () => { const viewedChannelId = 'ch-viewed-few'; jest.spyOn(DatabaseManager, 'getActiveServerUrl').mockResolvedValue(SERVER_URL); @@ -296,13 +281,22 @@ describe('autoCacheCleanup', () => { expect(LocalPost.deletePostsInChannelsByCutoff).toHaveBeenCalledWith( SERVER_URL, [viewedChannelId], CUTOFF, expect.any(Set), ); + }); + + it('skips the viewed-channel-specific delete call when the viewed channel has no PostsInChannel row', async () => { + const viewedChannelId = 'ch-viewed-no-pic'; + jest.spyOn(DatabaseManager, 'getActiveServerUrl').mockResolvedValue(SERVER_URL); + jest.mocked(NavigationStore.getScreensInStack).mockReturnValue([Screens.CHANNEL]); + jest.mocked(getCurrentChannelId).mockResolvedValue(viewedChannelId); + await writePiC('ch-other-with-pic', OLD, RECENT); + + await autoCacheCleanup(SERVER_URL); - const [pic] = await database.get(POSTS_IN_CHANNEL).query().fetch(); - expect(pic.earliest).toBe(CUTOFF); + const calls = jest.mocked(LocalPost.deletePostsInChannelsByCutoff).mock.calls; + expect(calls.some((c) => (c[1] as string[]).includes(viewedChannelId))).toBe(false); }); - // TC-8 - it('skips the open-thread PiT row, adds its root to excluded IDs, and protects the thread-parent channel', async () => { + it('adds the open thread root to excluded IDs and skips the thread-parent-channel delete when it has no PostsInChannel row', async () => { const rootId = 'root-post'; const threadParentChannelId = 'ch-thread-parent'; jest.spyOn(DatabaseManager, 'getActiveServerUrl').mockResolvedValue(SERVER_URL); @@ -310,25 +304,61 @@ describe('autoCacheCleanup', () => { await writePost(rootId, threadParentChannelId, OLD + 500); - // straddles CUTOFF: without the skip it would get earliest updated; with skip it is preserved - await writePiT(rootId, OLD, RECENT); - // needs a PiC row so deletePostsInChannelsByCutoff is actually invoked await writePiC('ch-other', OLD, RECENT); await autoCacheCleanup(SERVER_URL); - // PiT row for the open thread must be unchanged - const [pit] = await database.get(POSTS_IN_THREAD).query().fetch(); - expect(pit.earliest).toBe(OLD); - // Root post ID must appear in excludedPostIds of every deletePostsInChannelsByCutoff call const calls = jest.mocked(LocalPost.deletePostsInChannelsByCutoff).mock.calls; const excluded = calls.flatMap((c) => [...(c[3] as Set)]); expect(excluded).toContain(rootId); + + // threadParentChannelId has no PiC row, so its channel-specific call is skipped + expect(calls.some((c) => (c[1] as string[]).includes(threadParentChannelId))).toBe(false); + }); + + it('protects the thread-parent channel down to the root create_at when fewer than BUFFER posts precede the root', async () => { + const rootId = 'root-sparse-history'; + const threadParentChannelId = 'ch-thread-parent-sparse'; + const rootCreateAt = CUTOFF - 2000; + jest.spyOn(DatabaseManager, 'getActiveServerUrl').mockResolvedValue(SERVER_URL); + jest.mocked(EphemeralStore.getCurrentThreadId).mockReturnValue(rootId); + + // no posts precede the root in this channel → createAtOfNthPostOlderThan returns undefined + await writePost(rootId, threadParentChannelId, rootCreateAt); + await writePiC(threadParentChannelId, OLD, RECENT); + + await autoCacheCleanup(SERVER_URL); + + // without the root.createAt floor, this would fall back to the raw (less protective) CUTOFF + expect(LocalPost.deletePostsInChannelsByCutoff).toHaveBeenCalledWith( + SERVER_URL, [threadParentChannelId], rootCreateAt, expect.any(Set), + ); + }); + + it('floors the cutoff at the root create_at when the open thread\'s parent channel is also the viewed channel', async () => { + const rootId = 'root-same-channel'; + const sharedChannelId = 'ch-same-as-viewed'; + const rootCreateAt = CUTOFF - 2000; + jest.spyOn(DatabaseManager, 'getActiveServerUrl').mockResolvedValue(SERVER_URL); + jest.mocked(NavigationStore.getScreensInStack).mockReturnValue([Screens.CHANNEL]); + jest.mocked(getCurrentChannelId).mockResolvedValue(sharedChannelId); + jest.mocked(EphemeralStore.getCurrentThreadId).mockReturnValue(rootId); + + // oldest-visible left at 0 (falsy) so viewedChannelLimit stays Infinity and can't + // provide the protection on its own — only the thread-parent floor can. + await writePost(rootId, sharedChannelId, rootCreateAt); + await writePiC(sharedChannelId, OLD, RECENT); + + await autoCacheCleanup(SERVER_URL); + + // without folding threadParentLimit in, this would fall back to the raw (less protective) CUTOFF + expect(LocalPost.deletePostsInChannelsByCutoff).toHaveBeenCalledWith( + SERVER_URL, [sharedChannelId], rootCreateAt, expect.any(Set), + ); }); - // TC-9 it('excludes active thread root IDs from post deletion even when the thread is not currently open', async () => { const rootId = 'live-root'; @@ -342,7 +372,6 @@ describe('autoCacheCleanup', () => { expect((excludedPostIds as Set).has(rootId)).toBe(true); }); - // TC-10 it('includes the file-viewer post ID in excludedPostIds passed to deletePostsInChannelsByCutoff', async () => { const filePostId = 'file-post-id'; jest.spyOn(DatabaseManager, 'getActiveServerUrl').mockResolvedValue(SERVER_URL); @@ -355,32 +384,6 @@ describe('autoCacheCleanup', () => { expect((excludedPostIds as Set).has(filePostId)).toBe(true); }); - // TC-11 - it('destroys stale PiT rows, updates straddling PiT rows earliest to CUTOFF, and leaves the current-thread PiT row untouched', async () => { - const currentThreadId = 'current-thread'; - jest.spyOn(DatabaseManager, 'getActiveServerUrl').mockResolvedValue(SERVER_URL); - jest.mocked(EphemeralStore.getCurrentThreadId).mockReturnValue(currentThreadId); - - await writePiT('stale-root', OLD - 100, OLD); - await writePiT('straddle-root', OLD, RECENT); - await writePiT(currentThreadId, OLD, RECENT); - - // provide a post so getPostById succeeds for the open thread - await writePost(currentThreadId, 'ch-parent', OLD + 50); - - await autoCacheCleanup(SERVER_URL); - - const pitRows = await database.get(POSTS_IN_THREAD).query().fetch(); - expect(pitRows.length).toBe(2); // stale-root destroyed - - const straddle = pitRows.find((r) => r.rootId === 'straddle-root')!; - expect(straddle.earliest).toBe(CUTOFF); - - const current = pitRows.find((r) => r.rootId === currentThreadId)!; - expect(current.earliest).toBe(OLD); // not updated - }); - - // TC-12 it('calls unsafeVacuum and stamps LAST_AUTO_CACHE_CLEANUP_RUN after a successful run', async () => { const vacuumSpy = jest.spyOn(database, 'unsafeVacuum'); const handleSystemSpy = jest.spyOn(operator, 'handleSystem'); @@ -397,12 +400,45 @@ describe('autoCacheCleanup', () => { ); }); - // TC-13 - it('does not call unsafeVacuum and logs the error when cleanup throws mid-run', async () => { - jest.mocked(LocalPost.deletePostsInChannelsByCutoff).mockRejectedValueOnce(new Error('cleanup failed')); + it('does not call unsafeVacuum and logs the error when the unprotected-channels delete call returns an error', async () => { + jest.mocked(LocalPost.deletePostsInChannelsByCutoff).mockResolvedValueOnce({error: new Error('cleanup failed')}); + + await writePiC('ch-err', OLD, RECENT); + + const vacuumSpy = jest.spyOn(database, 'unsafeVacuum').mockResolvedValue(); + + await autoCacheCleanup(SERVER_URL); + + expect(vacuumSpy).not.toHaveBeenCalled(); + expect(logError).toHaveBeenCalledWith('autoCacheCleanup', expect.any(Error)); + }); + + it('does not call unsafeVacuum and logs the error when the viewed-channel delete call returns an error', async () => { + const viewedChannelId = 'ch-viewed-err'; + jest.spyOn(DatabaseManager, 'getActiveServerUrl').mockResolvedValue(SERVER_URL); + jest.mocked(NavigationStore.getScreensInStack).mockReturnValue([Screens.CHANNEL]); + jest.mocked(getCurrentChannelId).mockResolvedValue(viewedChannelId); + + await writePiC(viewedChannelId, OLD, RECENT); + jest.mocked(LocalPost.deletePostsInChannelsByCutoff).mockResolvedValueOnce({error: new Error('viewed channel delete failed')}); + + const vacuumSpy = jest.spyOn(database, 'unsafeVacuum').mockResolvedValue(); + + await autoCacheCleanup(SERVER_URL); + + expect(vacuumSpy).not.toHaveBeenCalled(); + expect(logError).toHaveBeenCalledWith('autoCacheCleanup', expect.any(Error)); + }); + + it('does not call unsafeVacuum and logs the error when the thread-parent-channel delete call returns an error', async () => { + const rootId = 'root-err'; + const threadParentChannelId = 'ch-thread-parent-err'; + jest.spyOn(DatabaseManager, 'getActiveServerUrl').mockResolvedValue(SERVER_URL); + jest.mocked(EphemeralStore.getCurrentThreadId).mockReturnValue(rootId); + await writePost(rootId, threadParentChannelId, OLD + 500); - // entirely-recent range: no prepareUpdate called, so no orphaned record when the throw occurs - await writePiC('ch-err', RECENT, RECENT + 100); + await writePiC(threadParentChannelId, OLD, RECENT); + jest.mocked(LocalPost.deletePostsInChannelsByCutoff).mockResolvedValueOnce({error: new Error('thread parent channel delete failed')}); const vacuumSpy = jest.spyOn(database, 'unsafeVacuum').mockResolvedValue(); @@ -412,7 +448,6 @@ describe('autoCacheCleanup', () => { expect(logError).toHaveBeenCalledWith('autoCacheCleanup', expect.any(Error)); }); - // TC-14 it('deletes AI threads older than the cutoff and keeps newer ones', async () => { await writeAiThread('ai-old', OLD); await writeAiThread('ai-recent', RECENT); @@ -423,7 +458,6 @@ describe('autoCacheCleanup', () => { expect(ids).toEqual(['ai-recent']); }); - // TC-15 it('spares the currently-viewed AI thread on the active server even when it is stale', async () => { jest.spyOn(DatabaseManager, 'getActiveServerUrl').mockResolvedValue(SERVER_URL); jest.mocked(EphemeralStore.getCurrentThreadId).mockReturnValue('open-ai-thread'); @@ -436,7 +470,6 @@ describe('autoCacheCleanup', () => { expect(ids).toEqual(['open-ai-thread']); }); - // TC-16 it('deletes all stale AI threads on a non-active server regardless of the viewed thread id', async () => { jest.mocked(EphemeralStore.getCurrentThreadId).mockReturnValue('open-ai-thread'); await writeAiThread('open-ai-thread', OLD); @@ -447,7 +480,6 @@ describe('autoCacheCleanup', () => { expect(threads.length).toBe(0); }); - // TC-17 it('deletes playbook runs created before the cutoff and keeps newer ones', async () => { await writePlaybookRun('run-old', OLD); await writePlaybookRun('run-recent', RECENT); @@ -458,7 +490,6 @@ describe('autoCacheCleanup', () => { expect(ids).toEqual(['run-recent']); }); - // TC-18 it('spares the currently-viewed playbook run on the active server even when it is stale', async () => { jest.spyOn(DatabaseManager, 'getActiveServerUrl').mockResolvedValue(SERVER_URL); jest.mocked(EphemeralStore.getCurrentPlaybookRunId).mockReturnValue('open-run'); @@ -471,7 +502,6 @@ describe('autoCacheCleanup', () => { expect(ids).toEqual(['open-run']); }); - // TC-19 it('deletes all stale playbook runs on a non-active server regardless of the viewed run id', async () => { jest.mocked(EphemeralStore.getCurrentPlaybookRunId).mockReturnValue('open-run'); await writePlaybookRun('open-run', OLD); @@ -482,7 +512,6 @@ describe('autoCacheCleanup', () => { expect(runs.length).toBe(0); }); - // TC-20 it('cascades deletion of a stale run to its checklists and checklist items', async () => { await writePlaybookRun('run-old', OLD); diff --git a/app/actions/local/ephemeral_mode/cleanup.ts b/app/actions/local/ephemeral_mode/cleanup.ts index b8877a33c52..b74c98a484a 100644 --- a/app/actions/local/ephemeral_mode/cleanup.ts +++ b/app/actions/local/ephemeral_mode/cleanup.ts @@ -21,12 +21,10 @@ import {NavigationStore} from '@store/navigation_store'; import {logDebug, logError} from '@utils/log'; import type {Database, Model} from '@nozbe/watermelondb'; -import type MyChannelModel from '@typings/database/models/servers/my_channel'; import type PostInChannelModel from '@typings/database/models/servers/posts_in_channel'; -import type PostsInThreadModel from '@typings/database/models/servers/posts_in_thread'; import type SystemModel from '@typings/database/models/servers/system'; -const {SERVER: {MY_CHANNEL, POSTS_IN_CHANNEL, POSTS_IN_THREAD, SYSTEM}} = MM_TABLES; +const {SERVER: {POSTS_IN_CHANNEL, SYSTEM}} = MM_TABLES; interface CleanupProtections { viewedChannelId: string | undefined; @@ -103,10 +101,15 @@ async function computeCleanupProtections( const rootPost = await getPostById(database, viewedThreadId); if (rootPost) { threadParentChannelId = rootPost.channelId; - threadParentLimit = + + // Floored at the root's own create_at so the open thread's replies (always >= root.createAt) + // are never deleted, even when there aren't enough preceding posts to compute a wider buffer. + threadParentLimit = Math.min( + rootPost.createAt, (await createAtOfNthPostOlderThan( database, threadParentChannelId, rootPost.createAt, AUTO_CACHE_CLEANUP_PROTECTION_BUFFER, - )) ?? Infinity; + )) ?? Infinity, + ); } } @@ -126,101 +129,61 @@ async function computeCleanupProtections( } function channelProtectionLimit(channelId: string, protections: CleanupProtections): number { + let limit = Infinity; if (channelId === protections.viewedChannelId) { - return protections.viewedChannelLimit; + limit = Math.min(limit, protections.viewedChannelLimit); } if (channelId === protections.threadParentChannelId) { - return protections.threadParentLimit; + limit = Math.min(limit, protections.threadParentLimit); } - return Infinity; + return limit; } async function cleanupPosts( serverUrl: string, database: Database, - operator: {batchRecords: (models: Model[], description: string) => Promise}, cutoff: number, protections: CleanupProtections, ): Promise { const postsInChannelItems = await database.get(POSTS_IN_CHANNEL).query().fetch(); - const myChannels = await database.get(MY_CHANNEL).query().fetch(); - const myChannelIds = new Map(myChannels.map((mc) => [mc.id, mc])); - - const unprotectedChannels = new Set(postsInChannelItems.filter((row) => row.channelId !== protections.viewedChannelId && row.channelId !== protections.threadParentChannelId).map((row) => row.channelId)); - const channelsWithPostRanges = postsInChannelItems.reduce((map, row) => { - map.set(row.channelId, (map.get(row.channelId) ?? 0) + 1); - return map; - }, new Map()); - const prepared: Model[] = []; - - // iterate over channels with posts in the database - for (const postInChannel of postsInChannelItems) { - const computedChannelCutoff = Math.min(cutoff, channelProtectionLimit(postInChannel.channelId, protections)); - - if (postInChannel.latest < computedChannelCutoff) { - prepared.push(postInChannel.prepareDestroyPermanently()); - channelsWithPostRanges.set(postInChannel.channelId, (channelsWithPostRanges.get(postInChannel.channelId) ?? 0) - 1); - } else if (postInChannel.earliest < computedChannelCutoff) { - prepared.push(postInChannel.prepareUpdate((r: PostInChannelModel) => { - r.earliest = computedChannelCutoff; - })); - } - } + const channelsWithPostRanges = new Set(postsInChannelItems.map((row) => row.channelId)); + + const unprotectedChannels = new Set( + postsInChannelItems. + map((row) => row.channelId). + filter((channelId) => channelId !== protections.viewedChannelId && channelId !== protections.threadParentChannelId), + ); const excludedPostIds: Set = new Set([ ...protections.activeThreadRootIds, ...(protections.fileViewerPostId ? [protections.fileViewerPostId] : []), ]); - // delete posts in channels not currently being viewed using a single query + // delete posts in channels not currently being viewed using a single query. + // PostsInChannel/MyChannel bookkeeping is applied atomically inside this call. if (unprotectedChannels.size > 0) { - await deletePostsInChannelsByCutoff(serverUrl, Array.from(unprotectedChannels), cutoff, excludedPostIds); + const {error: deleteError} = await deletePostsInChannelsByCutoff(serverUrl, Array.from(unprotectedChannels), cutoff, excludedPostIds); + if (deleteError) { + throw deleteError; + } } // delete posts in viewed channel if any if (protections.viewedChannelId && channelsWithPostRanges.has(protections.viewedChannelId)) { const computedChannelCutoff = Math.min(cutoff, channelProtectionLimit(protections.viewedChannelId, protections)); - await deletePostsInChannelsByCutoff(serverUrl, [protections.viewedChannelId], computedChannelCutoff, excludedPostIds); + const {error: deleteError} = await deletePostsInChannelsByCutoff(serverUrl, [protections.viewedChannelId], computedChannelCutoff, excludedPostIds); + if (deleteError) { + throw deleteError; + } } // delete posts in thread parent channel if any if (protections.threadParentChannelId && protections.threadParentChannelId !== protections.viewedChannelId && channelsWithPostRanges.has(protections.threadParentChannelId)) { const computedChannelCutoff = Math.min(cutoff, channelProtectionLimit(protections.threadParentChannelId, protections)); - await deletePostsInChannelsByCutoff(serverUrl, [protections.threadParentChannelId], computedChannelCutoff, excludedPostIds); - } - - // Fetch PostInThread rows after deletions — deletePostsInChannelsByCutoff already removed rows - // for deleted root posts, so this avoids calling prepareDestroyPermanently on gone records. - const allPostInThreadRows = await database.get(POSTS_IN_THREAD).query().fetch(); - - // Update MyChannel lastFetchedAt to 0 for channels that have no posts left. - channelsWithPostRanges.forEach((count, channelId) => { - if (count <= 0) { - const mc = myChannelIds.get(channelId); - if (mc && mc.lastFetchedAt > 0) { - prepared.push(mc.prepareUpdate((r: MyChannelModel) => { - r.lastFetchedAt = 0; - })); - } - } - }); - - // PostsInThread bookkeeping. - for (const postInThread of allPostInThreadRows) { - if (postInThread.rootId === protections.viewedThreadId) { - continue; // open thread is fully protected + const {error: deleteError} = await deletePostsInChannelsByCutoff(serverUrl, [protections.threadParentChannelId], computedChannelCutoff, excludedPostIds); + if (deleteError) { + throw deleteError; } - if (postInThread.latest < cutoff) { - prepared.push(postInThread.prepareDestroyPermanently()); - } else if (postInThread.earliest < cutoff) { - prepared.push(postInThread.prepareUpdate((r: PostsInThreadModel) => { - r.earliest = cutoff; - })); - } - } - - if (prepared.length > 0) { - await operator.batchRecords(prepared, 'autoCacheCleanupRanges'); } } @@ -313,7 +276,7 @@ export async function autoCacheCleanup(serverUrl: string): Promise { '— currentPlaybookRunId:', limits.viewedPlaybookRunId, ); - await cleanupPosts(serverUrl, database, operator, cutoff, limits); + await cleanupPosts(serverUrl, database, cutoff, limits); await cleanupAiThreads(database, operator, cutoff, limits.viewedThreadId); await cleanupPlaybookRuns(database, operator, cutoff, limits.viewedPlaybookRunId); diff --git a/app/actions/local/post.test.ts b/app/actions/local/post.test.ts index 80aebfa71ae..50beeebf37d 100644 --- a/app/actions/local/post.test.ts +++ b/app/actions/local/post.test.ts @@ -2,7 +2,7 @@ // See LICENSE.txt for license information. import {ActionType, Post} from '@constants'; -import {SYSTEM_IDENTIFIERS} from '@constants/database'; +import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; import DatabaseManager from '@database/manager'; import {getPostById} from '@queries/servers/post'; import TestHelper from '@test/test_helper'; @@ -18,12 +18,15 @@ import { addPostAcknowledgement, removePostAcknowledgement, deletePosts, + deletePostsInChannelsByCutoff, getUsersCountFromMentions, updatePostTranslation, } from './post'; import type ServerDataOperator from '@database/operator/server_data_operator'; +const {SERVER: {DRAFT, FILE, MY_CHANNEL, POST, POSTS_IN_CHANNEL, POSTS_IN_THREAD, REACTION, THREAD, THREAD_PARTICIPANT, THREADS_IN_TEAM}} = MM_TABLES; + const serverUrl = 'baseHandler.test.com'; let operator: ServerDataOperator; @@ -400,3 +403,68 @@ describe('updatePostTranslation', () => { expect(result.error).toBeTruthy(); }); }); + +describe('deletePostsInChannelsByCutoff', () => { + const CUTOFF = 50_000_000; + + it('handle not found database', async () => { + const {error} = await deletePostsInChannelsByCutoff('foo', [channelId], CUTOFF); + expect(error).toBeTruthy(); + }); + + it('includes the PostsInChannel destroy/update and MyChannel reset, in that order, in the same unsafeExecute call as the post delete', async () => { + const database = operator.database; + jest.spyOn(database.adapter, 'unsafeExecute').mockImplementation(() => Promise.resolve()); + + const {error} = await deletePostsInChannelsByCutoff(serverUrl, [channelId], CUTOFF); + + expect(error).toBeUndefined(); + const postCondition = `channel_id IN ('${channelId}') AND create_at < ${CUTOFF}`; + const postSubquery = `SELECT id FROM ${POST} WHERE ${postCondition}`; + const rootInChannelsExists = `EXISTS (SELECT 1 FROM ${POST} WHERE ${POST}.id = ${POSTS_IN_THREAD}.root_id AND ${POST}.channel_id IN ('${channelId}'))`; + expect(database.adapter.unsafeExecute).toHaveBeenCalledWith({ + sqls: [ + [`DELETE FROM ${REACTION} WHERE post_id IN (${postSubquery})`, []], + [`DELETE FROM ${FILE} WHERE post_id IN (${postSubquery})`, []], + [`DELETE FROM ${DRAFT} WHERE root_id IN (${postSubquery})`, []], + [`DELETE FROM ${POSTS_IN_THREAD} WHERE latest < ${CUTOFF} AND ${rootInChannelsExists}`, []], + [`UPDATE ${POSTS_IN_THREAD} SET earliest = ${CUTOFF} WHERE earliest < ${CUTOFF} AND ${rootInChannelsExists}`, []], + [`DELETE FROM ${THREAD} WHERE id IN (${postSubquery})`, []], + [`DELETE FROM ${THREAD_PARTICIPANT} WHERE thread_id IN (${postSubquery})`, []], + [`DELETE FROM ${THREADS_IN_TEAM} WHERE thread_id IN (${postSubquery})`, []], + [`DELETE FROM ${POST} WHERE ${postCondition}`, []], + [`DELETE FROM ${POSTS_IN_CHANNEL} WHERE channel_id IN ('${channelId}') AND latest < ${CUTOFF}`, []], + [`UPDATE ${POSTS_IN_CHANNEL} SET earliest = ${CUTOFF} WHERE channel_id IN ('${channelId}') AND earliest < ${CUTOFF}`, []], + [`UPDATE ${MY_CHANNEL} SET last_fetched_at = 0 WHERE id IN ('${channelId}') AND last_fetched_at > 0 AND NOT EXISTS (SELECT 1 FROM ${POSTS_IN_CHANNEL} WHERE channel_id = ${MY_CHANNEL}.id)`, []], + ], + }); + }); + + it('returns an error when the underlying transaction fails', async () => { + const database = operator.database; + jest.spyOn(database.adapter, 'unsafeExecute').mockImplementation(() => Promise.reject(new Error('fail'))); + + const {error} = await deletePostsInChannelsByCutoff(serverUrl, [channelId], CUTOFF); + + expect(error).toBeTruthy(); + }); + + it('scopes the post delete and its dependent subqueries to exclude the given post IDs', async () => { + const database = operator.database; + jest.spyOn(database.adapter, 'unsafeExecute').mockImplementation(() => Promise.resolve()); + + const {error} = await deletePostsInChannelsByCutoff(serverUrl, [channelId], CUTOFF, new Set(['excluded-1', 'excluded-2'])); + + expect(error).toBeUndefined(); + const postCondition = `channel_id IN ('${channelId}') AND create_at < ${CUTOFF} AND id NOT IN ('excluded-1','excluded-2')`; + const postSubquery = `SELECT id FROM ${POST} WHERE ${postCondition}`; + expect(database.adapter.unsafeExecute).toHaveBeenCalledWith( + expect.objectContaining({ + sqls: expect.arrayContaining([ + [`DELETE FROM ${REACTION} WHERE post_id IN (${postSubquery})`, []], + [`DELETE FROM ${POST} WHERE ${postCondition}`, []], + ]), + }), + ); + }); +}); diff --git a/app/actions/local/post.ts b/app/actions/local/post.ts index 93a50266850..e15e209b214 100644 --- a/app/actions/local/post.ts +++ b/app/actions/local/post.ts @@ -21,7 +21,7 @@ import type MyChannelModel from '@typings/database/models/servers/my_channel'; import type PostModel from '@typings/database/models/servers/post'; import type UserModel from '@typings/database/models/servers/user'; -const {SERVER: {DRAFT, FILE, POST, POSTS_IN_THREAD, REACTION, THREAD, THREAD_PARTICIPANT, THREADS_IN_TEAM}} = MM_TABLES; +const {SERVER: {DRAFT, FILE, MY_CHANNEL, POST, POSTS_IN_CHANNEL, POSTS_IN_THREAD, REACTION, THREAD, THREAD_PARTICIPANT, THREADS_IN_TEAM}} = MM_TABLES; export const sendAddToChannelEphemeralPost = async (serverUrl: string, user: UserModel, addedUsernames: string[], messages: string[], channeId: string, postRootId = '') => { try { @@ -431,17 +431,31 @@ export async function deletePostsInChannelsByCutoff( const postCondition = `channel_id IN (${channels}) AND create_at < ${cutoff}${exclusionClause}`; const postSubquery = `SELECT id FROM ${POST} WHERE ${postCondition}`; + // Scopes PostsInThread trimming to roots in these channels. + const rootInChannelsExists = `EXISTS (SELECT 1 FROM ${POST} WHERE ${POST}.id = ${POSTS_IN_THREAD}.root_id AND ${POST}.channel_id IN (${channels}))`; + await database.write(() => { return database.adapter.unsafeExecute({ sqls: [ [`DELETE FROM ${REACTION} WHERE post_id IN (${postSubquery})`, []], [`DELETE FROM ${FILE} WHERE post_id IN (${postSubquery})`, []], [`DELETE FROM ${DRAFT} WHERE root_id IN (${postSubquery})`, []], - [`DELETE FROM ${POSTS_IN_THREAD} WHERE root_id IN (${postSubquery})`, []], + + // PostsInThread bookkeeping, delete or update values depending on replies left in the thread after the cutoff + [`DELETE FROM ${POSTS_IN_THREAD} WHERE latest < ${cutoff} AND ${rootInChannelsExists}`, []], + [`UPDATE ${POSTS_IN_THREAD} SET earliest = ${cutoff} WHERE earliest < ${cutoff} AND ${rootInChannelsExists}`, []], + [`DELETE FROM ${THREAD} WHERE id IN (${postSubquery})`, []], [`DELETE FROM ${THREAD_PARTICIPANT} WHERE thread_id IN (${postSubquery})`, []], [`DELETE FROM ${THREADS_IN_TEAM} WHERE thread_id IN (${postSubquery})`, []], [`DELETE FROM ${POST} WHERE ${postCondition}`, []], + + // PostsInChannel bookkeeping, delete or update values depending on posts left in the channel after the cutoff + [`DELETE FROM ${POSTS_IN_CHANNEL} WHERE channel_id IN (${channels}) AND latest < ${cutoff}`, []], + [`UPDATE ${POSTS_IN_CHANNEL} SET earliest = ${cutoff} WHERE channel_id IN (${channels}) AND earliest < ${cutoff}`, []], + + // MyChannel bookkeeping, reset last_fetched_at for channels that have no posts left after the cutoff + [`UPDATE ${MY_CHANNEL} SET last_fetched_at = 0 WHERE id IN (${channels}) AND last_fetched_at > 0 AND NOT EXISTS (SELECT 1 FROM ${POSTS_IN_CHANNEL} WHERE channel_id = ${MY_CHANNEL}.id)`, []], ], }); }); diff --git a/app/managers/ephemeral_mode_manager/index.ts b/app/managers/ephemeral_mode_manager/index.ts index 02be87fe170..b00ab57f8d6 100644 --- a/app/managers/ephemeral_mode_manager/index.ts +++ b/app/managers/ephemeral_mode_manager/index.ts @@ -155,7 +155,7 @@ class EphemeralModeManagerSingleton { this.cleanupDays[serverUrl] = nextCleanupDays; if (nextCleanupDays > 0) { - logDebug('OfflinePersistenceManager: auto cache cleanup config received, days:', nextCleanupDays, 'for', serverUrl); + logDebug('EphemeralModeManager: auto cache cleanup config received, days:', nextCleanupDays, 'for', serverUrl); } if (nextEnabled && !wasActive) { From 79cdd3233aede222d3b6f3c575a1bb8f960d10d6 Mon Sep 17 00:00:00 2001 From: Carlos Garcia Date: Tue, 21 Jul 2026 13:24:37 +0200 Subject: [PATCH 06/13] restore previous file viewer post id and use full error logging --- app/actions/local/ephemeral_mode/cleanup.test.ts | 8 ++++---- app/actions/local/ephemeral_mode/cleanup.ts | 7 ++++--- app/screens/pdf_viewer/pdf_viewer.tsx | 3 ++- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/app/actions/local/ephemeral_mode/cleanup.test.ts b/app/actions/local/ephemeral_mode/cleanup.test.ts index 52140f9f3c6..115928f8613 100644 --- a/app/actions/local/ephemeral_mode/cleanup.test.ts +++ b/app/actions/local/ephemeral_mode/cleanup.test.ts @@ -186,7 +186,7 @@ describe('autoCacheCleanup', () => { await autoCacheCleanup(SERVER_URL); - expect(logError).toHaveBeenCalledWith('autoCacheCleanup getServerDatabaseAndOperator', expect.any(Error)); + expect(logError).toHaveBeenCalledWith('autoCacheCleanup getServerDatabaseAndOperator', 'db unavailable'); }); it('does not call getCurrentChannelId when the channel screen is not in the navigation stack', async () => { @@ -410,7 +410,7 @@ describe('autoCacheCleanup', () => { await autoCacheCleanup(SERVER_URL); expect(vacuumSpy).not.toHaveBeenCalled(); - expect(logError).toHaveBeenCalledWith('autoCacheCleanup', expect.any(Error)); + expect(logError).toHaveBeenCalledWith('autoCacheCleanup', 'cleanup failed'); }); it('does not call unsafeVacuum and logs the error when the viewed-channel delete call returns an error', async () => { @@ -427,7 +427,7 @@ describe('autoCacheCleanup', () => { await autoCacheCleanup(SERVER_URL); expect(vacuumSpy).not.toHaveBeenCalled(); - expect(logError).toHaveBeenCalledWith('autoCacheCleanup', expect.any(Error)); + expect(logError).toHaveBeenCalledWith('autoCacheCleanup', 'viewed channel delete failed'); }); it('does not call unsafeVacuum and logs the error when the thread-parent-channel delete call returns an error', async () => { @@ -445,7 +445,7 @@ describe('autoCacheCleanup', () => { await autoCacheCleanup(SERVER_URL); expect(vacuumSpy).not.toHaveBeenCalled(); - expect(logError).toHaveBeenCalledWith('autoCacheCleanup', expect.any(Error)); + expect(logError).toHaveBeenCalledWith('autoCacheCleanup', 'thread parent channel delete failed'); }); it('deletes AI threads older than the cutoff and keeps newer ones', async () => { diff --git a/app/actions/local/ephemeral_mode/cleanup.ts b/app/actions/local/ephemeral_mode/cleanup.ts index b74c98a484a..040b97d908a 100644 --- a/app/actions/local/ephemeral_mode/cleanup.ts +++ b/app/actions/local/ephemeral_mode/cleanup.ts @@ -18,6 +18,7 @@ import { import {getCurrentChannelId} from '@queries/servers/system'; import EphemeralStore from '@store/ephemeral_store'; import {NavigationStore} from '@store/navigation_store'; +import {getFullErrorMessage} from '@utils/errors'; import {logDebug, logError} from '@utils/log'; import type {Database, Model} from '@nozbe/watermelondb'; @@ -54,7 +55,7 @@ async function setLastAutoCacheCleanupRun(serverUrl: string): Promise { prepareRecordsOnly: false, }); } catch (error) { - logError('autoCacheCleanup setLastAutoCacheCleanupRun', error); + logError('autoCacheCleanup setLastAutoCacheCleanupRun', getFullErrorMessage(error)); } } @@ -238,7 +239,7 @@ export async function autoCacheCleanup(serverUrl: string): Promise { try { ({database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl)); } catch (error) { - logError('autoCacheCleanup getServerDatabaseAndOperator', error); + logError('autoCacheCleanup getServerDatabaseAndOperator', getFullErrorMessage(error)); return; } @@ -284,7 +285,7 @@ export async function autoCacheCleanup(serverUrl: string): Promise { await setLastAutoCacheCleanupRun(serverUrl); logDebug('autoCacheCleanup: completed successfully for', serverUrl); } catch (error) { - logError('autoCacheCleanup', error); + logError('autoCacheCleanup', getFullErrorMessage(error)); } logDebug('autoCacheCleanup: done for', serverUrl); } diff --git a/app/screens/pdf_viewer/pdf_viewer.tsx b/app/screens/pdf_viewer/pdf_viewer.tsx index 94f230320d0..311a96aa89c 100644 --- a/app/screens/pdf_viewer/pdf_viewer.tsx +++ b/app/screens/pdf_viewer/pdf_viewer.tsx @@ -139,6 +139,7 @@ const PdfViewer = ({allowPdfLinkNavigation, fileId, filePath, siteURL}: Props) = useDidMount(() => { let active = true; + const previousFileViewerPostId = EphemeralStore.getCurrentFileViewerPostId(); (async () => { try { const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); @@ -152,7 +153,7 @@ const PdfViewer = ({allowPdfLinkNavigation, fileId, filePath, siteURL}: Props) = })(); return () => { active = false; - EphemeralStore.clearCurrentFileViewerPostId(); + EphemeralStore.setCurrentFileViewerPostId(previousFileViewerPostId); }; }); From 85aa17c7cddbeb75565b36baf3b75eba40c31710 Mon Sep 17 00:00:00 2001 From: Carlos Garcia Date: Tue, 21 Jul 2026 13:44:23 +0200 Subject: [PATCH 07/13] performs vacuum as last step and removes unused code --- app/actions/local/ephemeral_mode/cleanup.test.ts | 16 ++++++++++++++++ app/actions/local/ephemeral_mode/cleanup.ts | 8 +++++++- app/queries/servers/post.ts | 14 -------------- 3 files changed, 23 insertions(+), 15 deletions(-) diff --git a/app/actions/local/ephemeral_mode/cleanup.test.ts b/app/actions/local/ephemeral_mode/cleanup.test.ts index 115928f8613..9aa58c34dca 100644 --- a/app/actions/local/ephemeral_mode/cleanup.test.ts +++ b/app/actions/local/ephemeral_mode/cleanup.test.ts @@ -400,6 +400,22 @@ describe('autoCacheCleanup', () => { ); }); + it('stamps LAST_AUTO_CACHE_CLEANUP_RUN and logs the error when unsafeVacuum rejects', async () => { + jest.spyOn(database, 'unsafeVacuum').mockRejectedValue(new Error('vacuum failed')); + const handleSystemSpy = jest.spyOn(operator, 'handleSystem'); + + await autoCacheCleanup(SERVER_URL); + + expect(handleSystemSpy).toHaveBeenCalledWith( + expect.objectContaining({ + systems: expect.arrayContaining([ + expect.objectContaining({id: SYSTEM_IDENTIFIERS.LAST_AUTO_CACHE_CLEANUP_RUN}), + ]), + }), + ); + expect(logError).toHaveBeenCalledWith('autoCacheCleanup unsafeVacuum', 'vacuum failed'); + }); + it('does not call unsafeVacuum and logs the error when the unprotected-channels delete call returns an error', async () => { jest.mocked(LocalPost.deletePostsInChannelsByCutoff).mockResolvedValueOnce({error: new Error('cleanup failed')}); diff --git a/app/actions/local/ephemeral_mode/cleanup.ts b/app/actions/local/ephemeral_mode/cleanup.ts index 040b97d908a..ef7af727acc 100644 --- a/app/actions/local/ephemeral_mode/cleanup.ts +++ b/app/actions/local/ephemeral_mode/cleanup.ts @@ -281,8 +281,14 @@ export async function autoCacheCleanup(serverUrl: string): Promise { await cleanupAiThreads(database, operator, cutoff, limits.viewedThreadId); await cleanupPlaybookRuns(database, operator, cutoff, limits.viewedPlaybookRunId); - await database.unsafeVacuum(); await setLastAutoCacheCleanupRun(serverUrl); + + try { + await database.unsafeVacuum(); + } catch (vacuumError) { + logError('autoCacheCleanup unsafeVacuum', getFullErrorMessage(vacuumError)); + } + logDebug('autoCacheCleanup: completed successfully for', serverUrl); } catch (error) { logError('autoCacheCleanup', getFullErrorMessage(error)); diff --git a/app/queries/servers/post.ts b/app/queries/servers/post.ts index cff5de0de54..c5764a023fc 100644 --- a/app/queries/servers/post.ts +++ b/app/queries/servers/post.ts @@ -428,20 +428,6 @@ export const syncPermalinkPreviewsForEditedPost = async ( } }; -export const fetchPostIdsByBucket = async ( - database: Database, - channelIds: string[], - cutoff: number, - exclusions: Set, -): Promise => { - const ids = await database.get(POST).query( - Q.where('channel_id', Q.oneOf(channelIds)), - Q.where('create_at', Q.lt(cutoff)), - Q.where('delete_at', Q.eq(0)), - ).fetchIds(); - return ids.filter((id) => !exclusions.has(id)); -}; - // Returns the create_at of the nth post in channelId that is strictly older // than anchor (sorted descending). Returns undefined when fewer than n such // posts exist From 74d56f220a48ccbb129295a7685e3ad03f4d8eb7 Mon Sep 17 00:00:00 2001 From: Carlos Garcia Date: Tue, 21 Jul 2026 14:03:29 +0200 Subject: [PATCH 08/13] return early on empty channel array --- app/actions/local/post.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/actions/local/post.ts b/app/actions/local/post.ts index e15e209b214..ec8e24874dc 100644 --- a/app/actions/local/post.ts +++ b/app/actions/local/post.ts @@ -420,6 +420,10 @@ export async function deletePostsInChannelsByCutoff( cutoff: number, excludedPostIds: Set = new Set(), ): Promise<{error: unknown}> { + if (channelIds.length === 0) { + return {error: undefined}; + } + try { const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); From 014acc8573d6cf5897a1e94ccb60ff8e7e9f127e Mon Sep 17 00:00:00 2001 From: Carlos Garcia Date: Tue, 21 Jul 2026 14:22:51 +0200 Subject: [PATCH 09/13] address gallery items without a post id --- app/screens/gallery/index.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/screens/gallery/index.tsx b/app/screens/gallery/index.tsx index b578a74e166..4016402ad06 100644 --- a/app/screens/gallery/index.tsx +++ b/app/screens/gallery/index.tsx @@ -32,11 +32,14 @@ const GalleryScreen = ({galleryIdentifier, hideActions, initialIndex, items}: Ga const [localIndex, setLocalIndex] = useState(initialIndex); useDidMount(() => { + const previousPostId = EphemeralStore.getCurrentFileViewerPostId(); const postId = items[initialIndex]?.postId; if (postId) { EphemeralStore.setCurrentFileViewerPostId(postId); + } else { + EphemeralStore.clearCurrentFileViewerPostId(); } - return () => EphemeralStore.clearCurrentFileViewerPostId(); + return () => EphemeralStore.setCurrentFileViewerPostId(previousPostId); }); const {headerAndFooterHidden, hideHeaderAndFooter, headerStyles, footerStyles} = useGalleryControls(bottomInset); From 7d6acfb8424e315c1a10b11826d26f72bb2f7352 Mon Sep 17 00:00:00 2001 From: Carlos Garcia Date: Tue, 21 Jul 2026 14:46:09 +0200 Subject: [PATCH 10/13] prevent drafts to be deleted as part of auto cache cleanups --- app/actions/local/post.test.ts | 3 +-- app/actions/local/post.ts | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/app/actions/local/post.test.ts b/app/actions/local/post.test.ts index 50beeebf37d..a7b6becd218 100644 --- a/app/actions/local/post.test.ts +++ b/app/actions/local/post.test.ts @@ -25,7 +25,7 @@ import { import type ServerDataOperator from '@database/operator/server_data_operator'; -const {SERVER: {DRAFT, FILE, MY_CHANNEL, POST, POSTS_IN_CHANNEL, POSTS_IN_THREAD, REACTION, THREAD, THREAD_PARTICIPANT, THREADS_IN_TEAM}} = MM_TABLES; +const {SERVER: {FILE, MY_CHANNEL, POST, POSTS_IN_CHANNEL, POSTS_IN_THREAD, REACTION, THREAD, THREAD_PARTICIPANT, THREADS_IN_TEAM}} = MM_TABLES; const serverUrl = 'baseHandler.test.com'; let operator: ServerDataOperator; @@ -426,7 +426,6 @@ describe('deletePostsInChannelsByCutoff', () => { sqls: [ [`DELETE FROM ${REACTION} WHERE post_id IN (${postSubquery})`, []], [`DELETE FROM ${FILE} WHERE post_id IN (${postSubquery})`, []], - [`DELETE FROM ${DRAFT} WHERE root_id IN (${postSubquery})`, []], [`DELETE FROM ${POSTS_IN_THREAD} WHERE latest < ${CUTOFF} AND ${rootInChannelsExists}`, []], [`UPDATE ${POSTS_IN_THREAD} SET earliest = ${CUTOFF} WHERE earliest < ${CUTOFF} AND ${rootInChannelsExists}`, []], [`DELETE FROM ${THREAD} WHERE id IN (${postSubquery})`, []], diff --git a/app/actions/local/post.ts b/app/actions/local/post.ts index ec8e24874dc..a7a3bbd1816 100644 --- a/app/actions/local/post.ts +++ b/app/actions/local/post.ts @@ -443,7 +443,6 @@ export async function deletePostsInChannelsByCutoff( sqls: [ [`DELETE FROM ${REACTION} WHERE post_id IN (${postSubquery})`, []], [`DELETE FROM ${FILE} WHERE post_id IN (${postSubquery})`, []], - [`DELETE FROM ${DRAFT} WHERE root_id IN (${postSubquery})`, []], // PostsInThread bookkeeping, delete or update values depending on replies left in the thread after the cutoff [`DELETE FROM ${POSTS_IN_THREAD} WHERE latest < ${cutoff} AND ${rootInChannelsExists}`, []], From 2a41833c0bbb33df25ab99bd960c3704c3c9a7e5 Mon Sep 17 00:00:00 2001 From: Carlos Garcia Date: Tue, 21 Jul 2026 15:20:31 +0200 Subject: [PATCH 11/13] unset channel oldest visible on channel switch --- app/screens/channel/channel_post_list/channel_post_list.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/screens/channel/channel_post_list/channel_post_list.tsx b/app/screens/channel/channel_post_list/channel_post_list.tsx index bace208fc91..9b55270613c 100644 --- a/app/screens/channel/channel_post_list/channel_post_list.tsx +++ b/app/screens/channel/channel_post_list/channel_post_list.tsx @@ -108,10 +108,12 @@ const ChannelPostList = ({ } }, [appState === 'active']); + useEffect(() => { + EphemeralStore.setCurrentChannelOldestVisibleCreateAt(undefined); + }, [channelId]); + useDidMount(() => { return () => { - // Clear the viewport anchor when leaving the channel so a stale value - // cannot influence a cleanup run that fires before the next channel is opened. EphemeralStore.setCurrentChannelOldestVisibleCreateAt(undefined); unsetActiveChannelOnServer(serverUrl); }; From 958ad498da8f51cf8561046f69f7f56365f1eaf6 Mon Sep 17 00:00:00 2001 From: Carlos Garcia Date: Tue, 21 Jul 2026 15:57:16 +0200 Subject: [PATCH 12/13] clear oldest visible on empty array --- app/components/post_list/post_list.tsx | 12 ++++++------ .../channel/channel_post_list/channel_post_list.tsx | 4 +--- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/app/components/post_list/post_list.tsx b/app/components/post_list/post_list.tsx index a86ad6dbaaa..a2a78c46e95 100644 --- a/app/components/post_list/post_list.tsx +++ b/app/components/post_list/post_list.tsx @@ -372,6 +372,12 @@ const PostList = ({ }, 250); // 250ms debounce } + onViewableItemsChangedProp?.(viewableItems); + + if (onViewableItemsChangedListener.current) { + onViewableItemsChangedListener.current(viewableItems); + } + if (!viewableItems.length) { return; } @@ -386,12 +392,6 @@ const PostList = ({ requestAnimationFrame(() => { DeviceEventEmitter.emit(Events.ITEM_IN_VIEWPORT, viewableItemsMap); }); - - onViewableItemsChangedProp?.(viewableItems); - - if (onViewableItemsChangedListener.current) { - onViewableItemsChangedListener.current(viewableItems); - } }, [location, onViewableItemsChangedProp, trackInitialRenderMetrics]); const registerScrollEndIndexListener = useCallback((listener: onScrollEndIndexListenerEvent) => { diff --git a/app/screens/channel/channel_post_list/channel_post_list.tsx b/app/screens/channel/channel_post_list/channel_post_list.tsx index 9b55270613c..be8bd8d2366 100644 --- a/app/screens/channel/channel_post_list/channel_post_list.tsx +++ b/app/screens/channel/channel_post_list/channel_post_list.tsx @@ -129,9 +129,7 @@ const ChannelPostList = ({ } } } - if (oldest < Infinity) { - EphemeralStore.setCurrentChannelOldestVisibleCreateAt(oldest); - } + EphemeralStore.setCurrentChannelOldestVisibleCreateAt(oldest < Infinity ? oldest : undefined); }, []); const intro = useMemo(() => (), [channelId]); From 3fba595afca67b595693c311560b568517ac49a1 Mon Sep 17 00:00:00 2001 From: Carlos Garcia Date: Wed, 22 Jul 2026 13:08:30 +0200 Subject: [PATCH 13/13] revert early return to prevent flickering --- app/components/post_list/post_list.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/components/post_list/post_list.tsx b/app/components/post_list/post_list.tsx index a2a78c46e95..a86ad6dbaaa 100644 --- a/app/components/post_list/post_list.tsx +++ b/app/components/post_list/post_list.tsx @@ -372,12 +372,6 @@ const PostList = ({ }, 250); // 250ms debounce } - onViewableItemsChangedProp?.(viewableItems); - - if (onViewableItemsChangedListener.current) { - onViewableItemsChangedListener.current(viewableItems); - } - if (!viewableItems.length) { return; } @@ -392,6 +386,12 @@ const PostList = ({ requestAnimationFrame(() => { DeviceEventEmitter.emit(Events.ITEM_IN_VIEWPORT, viewableItemsMap); }); + + onViewableItemsChangedProp?.(viewableItems); + + if (onViewableItemsChangedListener.current) { + onViewableItemsChangedListener.current(viewableItems); + } }, [location, onViewableItemsChangedProp, trackInitialRenderMetrics]); const registerScrollEndIndexListener = useCallback((listener: onScrollEndIndexListenerEvent) => {