From cfc120fcb372a872b27bce8b22db6dca5264a2ad Mon Sep 17 00:00:00 2001 From: xd-Neji <72418255+xd-Neji@users.noreply.github.com> Date: Fri, 17 Jul 2026 19:32:17 +0100 Subject: [PATCH] fix(stats): exclude soft-deleted pages from visible counts (#2235) --- src/commands/sources.ts | 2 +- src/core/pglite-engine.ts | 2 +- src/core/postgres-engine.ts | 2 +- src/core/sources-ops.ts | 16 ++++++++++++---- test/pglite-engine.test.ts | 17 ++++++++++++----- test/sources-ops.test.ts | 35 +++++++++++++++++++++++++++++++++++ test/sources.test.ts | 14 ++++++++++++++ 7 files changed, 76 insertions(+), 12 deletions(-) diff --git a/src/commands/sources.ts b/src/commands/sources.ts index acbc4b6e3..14891bdc5 100644 --- a/src/commands/sources.ts +++ b/src/commands/sources.ts @@ -107,7 +107,7 @@ async function fetchSource(engine: BrainEngine, id: string): Promise { const rows = await engine.executeRaw<{ n: number }>( - `SELECT COUNT(*)::int AS n FROM pages WHERE source_id = $1`, + `SELECT COUNT(*)::int AS n FROM pages WHERE source_id = $1 AND deleted_at IS NULL`, [sourceId], ); return rows[0]?.n ?? 0; diff --git a/src/core/pglite-engine.ts b/src/core/pglite-engine.ts index 7dab6707d..d30738f24 100644 --- a/src/core/pglite-engine.ts +++ b/src/core/pglite-engine.ts @@ -5029,7 +5029,7 @@ export class PGLiteEngine implements BrainEngine { `); const { rows: types } = await this.db.query( - `SELECT type, count(*)::int as count FROM pages GROUP BY type ORDER BY count DESC` + `SELECT type, count(*)::int as count FROM pages WHERE deleted_at IS NULL GROUP BY type ORDER BY count DESC` ); const pages_by_type: Record = {}; for (const t of types as { type: string; count: number }[]) { diff --git a/src/core/postgres-engine.ts b/src/core/postgres-engine.ts index a55487ad3..3019d6f3f 100644 --- a/src/core/postgres-engine.ts +++ b/src/core/postgres-engine.ts @@ -5008,7 +5008,7 @@ export class PostgresEngine implements BrainEngine { `; const types = await sql` - SELECT type, count(*)::int as count FROM pages GROUP BY type ORDER BY count DESC + SELECT type, count(*)::int as count FROM pages WHERE deleted_at IS NULL GROUP BY type ORDER BY count DESC `; const pages_by_type: Record = {}; for (const t of types) { diff --git a/src/core/sources-ops.ts b/src/core/sources-ops.ts index 69c31ba17..9edcfedbe 100644 --- a/src/core/sources-ops.ts +++ b/src/core/sources-ops.ts @@ -212,7 +212,7 @@ async function fetchSourceRow(engine: BrainEngine, id: string): Promise { +async function countAllPages(engine: BrainEngine, id: string): Promise { const rows = await engine.executeRaw<{ n: number }>( `SELECT COUNT(*)::int AS n FROM pages WHERE source_id = $1`, [id], @@ -220,6 +220,14 @@ async function countPages(engine: BrainEngine, id: string): Promise { return rows[0]?.n ?? 0; } +async function countVisiblePages(engine: BrainEngine, id: string): Promise { + const rows = await engine.executeRaw<{ n: number }>( + `SELECT COUNT(*)::int AS n FROM pages WHERE source_id = $1 AND deleted_at IS NULL`, + [id], + ); + return rows[0]?.n ?? 0; +} + /** Default clone dir for a remote-URL source: $GBRAIN_HOME/clones// */ export function defaultCloneDir(id: string): string { return gbrainPath('clones', id); @@ -575,7 +583,7 @@ export async function listSources( local_path: r.local_path, remote_url: typeof cfg.remote_url === 'string' ? cfg.remote_url : null, federated: cfg.federated === true, - page_count: await countPages(engine, r.id), + page_count: await countVisiblePages(engine, r.id), last_sync_at: r.last_sync_at ? new Date(r.last_sync_at).toISOString() : null, }); } @@ -621,7 +629,7 @@ export async function removeSource( throw new SourceOpError('not_found', `Source "${opts.id}" not found.`); } - const pageCount = await countPages(engine, opts.id); + const pageCount = await countAllPages(engine, opts.id); if (opts.dryRun) { return { @@ -720,7 +728,7 @@ export async function getSourceStatus( local_path: src.local_path, remote_url: remoteUrl, federated: isFederated(src.config), - page_count: await countPages(engine, id), + page_count: await countVisiblePages(engine, id), last_sync_at: src.last_sync_at ? new Date(src.last_sync_at).toISOString() : null, last_commit: src.last_commit, archived, diff --git a/test/pglite-engine.test.ts b/test/pglite-engine.test.ts index 9ba2732d3..acba63b16 100644 --- a/test/pglite-engine.test.ts +++ b/test/pglite-engine.test.ts @@ -951,11 +951,18 @@ describe('PGLiteEngine: Stats & Health', () => { }); test('getStats returns correct counts', async () => { - const stats = await engine.getStats(); - expect(stats.page_count).toBe(1); - expect(stats.chunk_count).toBe(1); - expect(stats.tag_count).toBe(1); - expect(stats.pages_by_type.concept).toBe(1); + await engine.putPage('test/stats-deleted', { ...testPage, title: 'Deleted stats page' }); + await engine.softDeletePage('test/stats-deleted'); + + try { + const stats = await engine.getStats(); + expect(stats.page_count).toBe(1); + expect(stats.chunk_count).toBe(1); + expect(stats.tag_count).toBe(1); + expect(stats.pages_by_type.concept).toBe(1); + } finally { + await engine.deletePage('test/stats-deleted'); + } }); test('getHealth returns coverage metrics', async () => { diff --git a/test/sources-ops.test.ts b/test/sources-ops.test.ts index c5595a079..1098c5267 100644 --- a/test/sources-ops.test.ts +++ b/test/sources-ops.test.ts @@ -301,6 +301,41 @@ describe('listSources', () => { // --------------------------------------------------------------------------- describe('removeSource — clone-cleanup', () => { + test('counts soft-deleted pages for destructive removal while list/status show active pages', async () => { + await withEnv2(async () => { + await addSource(engine, { id: 'soft-only', localPath: '/tmp/soft-only-fixture' }); + await engine.putPage( + 'notes/recoverable', + { + type: 'note', + title: 'Recoverable', + compiled_truth: 'still recoverable during the soft-delete window', + timeline: '', + frontmatter: {}, + }, + { sourceId: 'soft-only' }, + ); + expect(await engine.softDeletePage('notes/recoverable', { sourceId: 'soft-only' })) + .toEqual({ slug: 'notes/recoverable' }); + + const listed = await listSources(engine); + expect(listed.find((s) => s.id === 'soft-only')?.page_count).toBe(0); + const status = await getSourceStatus(engine, 'soft-only'); + expect(status.page_count).toBe(0); + + const dryRun = await removeSource(engine, { id: 'soft-only', dryRun: true }); + expect(dryRun.pages_deleted).toBe(1); + + try { + await removeSource(engine, { id: 'soft-only' }); + throw new Error('expected throw'); + } catch (e) { + expect(e).toBeInstanceOf(SourceOpError); + expect((e as SourceOpError).message).toContain('with 1 pages'); + } + }); + }); + test('removes clone IFF managed (local_path under $GBRAIN_HOME/clones/ + remote_url set)', async () => { await withEnv2(async () => { const row = await addSource(engine, { diff --git a/test/sources.test.ts b/test/sources.test.ts index cd49ccdb2..f5122655c 100644 --- a/test/sources.test.ts +++ b/test/sources.test.ts @@ -172,6 +172,20 @@ describe('sources list', () => { const select = calls.find(c => c.sql.includes('ORDER BY (id = \'default\') DESC')); expect(select).toBeDefined(); }); + + test('counts only visible pages', async () => { + const { engine, calls } = makeStub({ + 'SELECT id, name, local_path, last_commit, last_sync_at, config, created_at': [ + { id: 'default', name: 'default', local_path: null, last_commit: null, last_sync_at: null, config: '{"federated":true}', created_at: new Date() }, + ], + 'COUNT(*)::int AS n FROM pages': [{ n: 1 }], + }); + + await runSources(engine, ['list']); + + const count = calls.find(c => c.sql.includes('COUNT(*)::int AS n FROM pages')); + expect(count?.sql).toContain('deleted_at IS NULL'); + }); }); // ── remove ──────────────────────────────────────────────────