mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-31 04:07:52 +00:00
fix(stats): exclude soft-deleted pages from visible counts (#2235)
This commit is contained in:
@@ -107,7 +107,7 @@ async function fetchSource(engine: BrainEngine, id: string): Promise<SourceRow |
|
||||
|
||||
async function countPages(engine: BrainEngine, sourceId: string): Promise<number> {
|
||||
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;
|
||||
|
||||
@@ -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<string, number> = {};
|
||||
for (const t of types as { type: string; count: number }[]) {
|
||||
|
||||
@@ -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<string, number> = {};
|
||||
for (const t of types) {
|
||||
|
||||
+12
-4
@@ -212,7 +212,7 @@ async function fetchSourceRow(engine: BrainEngine, id: string): Promise<SourceRo
|
||||
return { ...r, config: parseConfig(r.config) };
|
||||
}
|
||||
|
||||
async function countPages(engine: BrainEngine, id: string): Promise<number> {
|
||||
async function countAllPages(engine: BrainEngine, id: string): Promise<number> {
|
||||
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<number> {
|
||||
return rows[0]?.n ?? 0;
|
||||
}
|
||||
|
||||
async function countVisiblePages(engine: BrainEngine, id: string): Promise<number> {
|
||||
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/<id>/ */
|
||||
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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user