Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 123 additions & 1 deletion core/library/repo.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import os from 'node:os'
import fs from 'node:fs'
import path from 'node:path'
import type { Database } from 'better-sqlite3'
import { describe, expect, it } from 'vitest'
import { openDb } from '../store/db.js'
import { getPaper, listPapers, upsertPaper } from './repo.js'
import { getPaper, listPapers, upsertPaper, trashPaper, restorePaper, purgePaper } from './repo.js'
import type { PaperRecord } from './repo.js'

function makeRecord(overrides: Partial<PaperRecord> = {}): PaperRecord {
Expand Down Expand Up @@ -267,4 +270,123 @@ describe('repo', () => {
}
})
})

describe('Trash and Purge operations [L2-04]', () => {
function seedDb(db: Database): void {
upsertPaper(
db,
makeRecord({
slug: 'attention',
title: 'Attention Is All You Need',
year: 2017,
addedAt: '2026-01-01T00:00:00.000Z',
}),
)
upsertPaper(
db,
makeRecord({
slug: 'bert',
title: 'BERT: Pre-training of Deep Bidirectional Transformers',
year: 2019,
addedAt: '2026-03-01T00:00:00.000Z',
}),
)
upsertPaper(
db,
makeRecord({
slug: 'gpt3',
title: 'Language Models are Few-Shot Learners',
year: 2020,
addedAt: '2026-02-01T00:00:00.000Z',
}),
)
}

it('trashes a paper, hiding it from default library while retaining it in trashed view', () => {
const db = openDb({ path: ':memory:' })
try {
seedDb(db)
expect(listPapers(db)).toHaveLength(3)

// Trash 'bert'
const trashed = trashPaper(db, 'bert')
expect(trashed).toBeDefined()
expect(trashed?.slug).toBe('bert')
expect(trashed?.trashedAt).toBeTruthy()

// Default list excludes trashed
const active = listPapers(db)
expect(active.map((p) => p.slug)).toEqual(['gpt3', 'attention'])

// Trashed list includes bert
const trashList = listPapers(db, { trashed: true })
expect(trashList.map((p) => p.slug)).toEqual(['bert'])
} finally {
db.close()
}
})

it('restores a trashed paper back to active Library', () => {
const db = openDb({ path: ':memory:' })
try {
seedDb(db)
trashPaper(db, 'bert')
expect(listPapers(db)).toHaveLength(2)

const restored = restorePaper(db, 'bert')
expect(restored?.slug).toBe('bert')
expect(restored?.trashedAt).toBeUndefined()

const active = listPapers(db)
expect(active.map((p) => p.slug)).toContain('bert')
expect(listPapers(db, { trashed: true })).toHaveLength(0)
} finally {
db.close()
}
})

it('permanently purges a paper from DB and removes its directory on disk', () => {
const db = openDb({ path: ':memory:' })
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vellum-purge-test-'))
try {
const paperDir = path.join(tmpDir, 'papers', 'purge-me')
fs.mkdirSync(paperDir, { recursive: true })
fs.writeFileSync(path.join(paperDir, 'content.pdf'), 'fake pdf')

upsertPaper(db, makeRecord({ slug: 'purge-me', title: 'Paper To Purge' }))
expect(getPaper(db, 'purge-me')).toBeDefined()

const purged = purgePaper(db, 'purge-me', tmpDir)
expect(purged).toBe(true)
expect(getPaper(db, 'purge-me')).toBeUndefined()
expect(fs.existsSync(paperDir)).toBe(false)
} finally {
db.close()
fs.rmSync(tmpDir, { recursive: true, force: true })
}
})

it('purgePaper safely handles missing on-disk files without throwing', () => {
const db = openDb({ path: ':memory:' })
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vellum-purge-missing-'))
try {
upsertPaper(db, makeRecord({ slug: 'non-existent-files', title: 'Ghost Paper' }))
const purged = purgePaper(db, 'non-existent-files', tmpDir)
expect(purged).toBe(true)
expect(getPaper(db, 'non-existent-files')).toBeUndefined()
} finally {
db.close()
fs.rmSync(tmpDir, { recursive: true, force: true })
}
})

it('purgePaper prevents path traversal in slug', () => {
const db = openDb({ path: ':memory:' })
try {
expect(() => purgePaper(db, '../traversal')).toThrow(/Invalid slug format/)
} finally {
db.close()
}
})
})
})
66 changes: 63 additions & 3 deletions core/library/repo.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import fs from 'node:fs'
import path from 'node:path'
// Paper CRUD against the `papers` table (core/store/schema.ts SCHEMA_V1).
// Owns all SQL for the papers table — parameterized statements only, no
// string interpolation of caller-supplied values. This is the storage seam
Expand Down Expand Up @@ -34,6 +36,8 @@ export interface PaperRecord {
/** Section outline from core/ingest/extract.ts's ExtractResult.sections. */
sections?: unknown[]
addedAt: string
/** [L2-04] Timestamp when paper was moved to Trash; undefined/null if active. */
trashedAt?: string | null
}

interface PaperRow {
Expand All @@ -51,6 +55,7 @@ interface PaperRow {
pdf_path: string | null
sections: string | null
added_at: string
trashed_at: string | null
}

function toRecord(row: PaperRow): PaperRecord {
Expand All @@ -71,6 +76,7 @@ function toRecord(row: PaperRow): PaperRecord {
pdfPath: row.pdf_path ?? undefined,
sections: row.sections ? (JSON.parse(row.sections) as unknown[]) : [],
addedAt: row.added_at,
trashedAt: row.trashed_at ?? undefined,
}
}

Expand All @@ -81,8 +87,8 @@ function toRecord(row: PaperRow): PaperRecord {
*/
export function upsertPaper(db: Database, paper: PaperRecord): void {
db.prepare(
`INSERT INTO papers (slug, title, authors, author_orcids, year, venue, doi, arxiv_id, abstract, summary, md_path, pdf_path, sections, added_at)
VALUES (@slug, @title, @authors, @authorOrcids, @year, @venue, @doi, @arxivId, @abstract, @summary, @mdPath, @pdfPath, @sections, @addedAt)
`INSERT INTO papers (slug, title, authors, author_orcids, year, venue, doi, arxiv_id, abstract, summary, md_path, pdf_path, sections, added_at, trashed_at)
VALUES (@slug, @title, @authors, @authorOrcids, @year, @venue, @doi, @arxivId, @abstract, @summary, @mdPath, @pdfPath, @sections, @addedAt, @trashedAt)
ON CONFLICT(slug) DO UPDATE SET
title = excluded.title,
authors = excluded.authors,
Expand All @@ -96,7 +102,8 @@ export function upsertPaper(db: Database, paper: PaperRecord): void {
md_path = excluded.md_path,
pdf_path = excluded.pdf_path,
sections = excluded.sections,
added_at = excluded.added_at`,
added_at = excluded.added_at,
trashed_at = excluded.trashed_at`,
).run({
slug: paper.slug,
title: paper.title,
Expand All @@ -112,6 +119,7 @@ export function upsertPaper(db: Database, paper: PaperRecord): void {
pdfPath: paper.pdfPath ?? null,
sections: JSON.stringify(paper.sections ?? []),
addedAt: paper.addedAt,
trashedAt: paper.trashedAt ?? null,
})
}

Expand Down Expand Up @@ -147,6 +155,8 @@ export interface ListPapersOptions {
sort?: PaperSortColumn
/** Sort direction. Defaults to `desc`. */
order?: 'asc' | 'desc'
/** [L2-04] If true, lists only trashed papers. If false/omitted, excludes trashed papers. */
trashed?: boolean
}

/** Escape SQLite LIKE metacharacters (`%`, `_`) plus the escape char itself
Expand Down Expand Up @@ -176,6 +186,12 @@ export function listPapers(db: Database, options: ListPapersOptions = {}): Paper
const params: unknown[] = []
const conditions: string[] = []

if (options.trashed) {
conditions.push('papers.trashed_at IS NOT NULL')
} else {
conditions.push('papers.trashed_at IS NULL')
}

if (collectionId !== undefined) {
sql += ' JOIN paper_collections pc ON pc.paper_slug = papers.slug'
conditions.push('pc.collection_id = ?')
Expand All @@ -196,3 +212,47 @@ export function listPapers(db: Database, options: ListPapersOptions = {}): Paper
const rows = db.prepare(sql).all(...params) as PaperRow[]
return rows.map(toRecord)
}

/** [L2-04] Move a paper to Trash (soft delete). */
export function trashPaper(db: Database, slug: string): PaperRecord | undefined {
const now = new Date().toISOString()
db.prepare('UPDATE papers SET trashed_at = ? WHERE slug = ?').run(now, slug)
return getPaper(db, slug)
}

/** [L2-04] Restore a paper from Trash back to active Library. */
export function restorePaper(db: Database, slug: string): PaperRecord | undefined {
db.prepare('UPDATE papers SET trashed_at = NULL WHERE slug = ?').run(slug)
return getPaper(db, slug)
}

/**
* [L2-04] Permanently purge a paper from SQLite and delete its on-disk assets.
* Cascades to notes, highlights, chats, collections, and questions.
*/
export function purgePaper(db: Database, slug: string, libraryDir?: string): boolean {
if (!/^[a-z0-9-]+$/i.test(slug)) {
throw new Error(`Invalid slug format: ${slug}`)
}

const paper = getPaper(db, slug)
if (!paper) return false

if (libraryDir) {
const targetDir = path.resolve(libraryDir, 'papers', slug)
const papersRoot = path.resolve(libraryDir, 'papers')
if (!targetDir.startsWith(papersRoot + path.sep)) {
throw new Error(`Path traversal detected: ${targetDir}`)
}
try {
if (fs.existsSync(targetDir)) {
fs.rmSync(targetDir, { recursive: true, force: true })
}
} catch (err) {
throw new Error(`Failed to remove paper files from disk: ${err instanceof Error ? err.message : String(err)}`)
}
}

const result = db.prepare('DELETE FROM papers WHERE slug = ?').run(slug)
return result.changes > 0
}
2 changes: 1 addition & 1 deletion core/store/db.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ describe('openDb', () => {
const row = db2.prepare('SELECT * FROM papers WHERE slug = ?').get('p1')
expect(row).toBeTruthy()
const version = db2.pragma('user_version', { simple: true })
expect(version).toBe(6)
expect(version).toBe(7)
} finally {
db2.close()
}
Expand Down
17 changes: 15 additions & 2 deletions core/store/migrate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ describe('runMigrations', () => {
runMigrations(db)

const version = db.pragma('user_version', { simple: true })
expect(version).toBe(6)
expect(version).toBe(7)
})

it('adds the author_orcids column to papers [P2-04]', () => {
Expand Down Expand Up @@ -77,7 +77,7 @@ describe('runMigrations', () => {
runMigrations(db)

const version = db.pragma('user_version', { simple: true })
expect(version).toBe(6)
expect(version).toBe(7)

const row = db.prepare('SELECT * FROM papers WHERE slug = ?').get('a')
expect(row).toBeTruthy()
Expand All @@ -101,6 +101,19 @@ describe('runMigrations', () => {
expect(indexes).toContain('idx_suggested_questions_paper')
})


it('adds the trashed_at column to papers [L2-04]', () => {
db = new Database(':memory:')
runMigrations(db)

const columns = db
.prepare("PRAGMA table_info(papers)")
.all()
.map((row) => (row as { name: string }).name)

expect(columns).toContain('trashed_at')
})

it('applies only migrations newer than the current version, in order', () => {
db = new Database(':memory:')

Expand Down
3 changes: 2 additions & 1 deletion core/store/migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

import type { Database } from 'better-sqlite3'

import { SCHEMA_V1, SCHEMA_V3_NOTES, SCHEMA_V4_HIGHLIGHTS, SCHEMA_V5_AUTHOR_ORCIDS, SCHEMA_V6_SUGGESTED_QUESTIONS } from './schema.js'
import { SCHEMA_V1, SCHEMA_V3_NOTES, SCHEMA_V4_HIGHLIGHTS, SCHEMA_V5_AUTHOR_ORCIDS, SCHEMA_V6_SUGGESTED_QUESTIONS, SCHEMA_V7_TRASH } from './schema.js'

export interface Migration {
version: number
Expand All @@ -27,6 +27,7 @@ export const MIGRATIONS: Migration[] = [
{ version: 4, sql: SCHEMA_V4_HIGHLIGHTS }, // [P2-02] highlights table
{ version: 5, sql: SCHEMA_V5_AUTHOR_ORCIDS }, // [P2-04] author_orcids column
{ version: 6, sql: SCHEMA_V6_SUGGESTED_QUESTIONS }, // [L2-03] suggested_questions table
{ version: 7, sql: SCHEMA_V7_TRASH }, // [L2-04] trashed_at column
]

/**
Expand Down
6 changes: 6 additions & 0 deletions core/store/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,9 @@ CREATE TABLE IF NOT EXISTS suggested_questions (
);
CREATE INDEX IF NOT EXISTS idx_suggested_questions_paper ON suggested_questions(paper_slug, backend);
`

// [L2-04] Recoverable Trash: nullable trashed_at timestamp on papers.
// NULL means active in Library. Non-NULL means moved to Trash.
export const SCHEMA_V7_TRASH = `
ALTER TABLE papers ADD COLUMN trashed_at TEXT;
`
18 changes: 16 additions & 2 deletions electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { ChatManager } from '../core/chat/manager.js'
import { getChatSession } from '../core/chat/repo.js'
import { ingest } from '../core/ingest/index.js'
import type { IngestResult } from '../core/ingest/index.js'
import { getPaper, listPapers } from '../core/library/repo.js'
import { getPaper, listPapers, trashPaper, restorePaper, purgePaper } from '../core/library/repo.js'
import type { ListPapersOptions, PaperRecord, PaperSortColumn } from '../core/library/repo.js'
import { deleteNote, getNote, upsertNote } from '../core/notes/repo.js'
import {
Expand Down Expand Up @@ -149,8 +149,9 @@ function toListPapersOptions(value: unknown): ListPapersOptions {
: undefined
const order = candidate['order'] === 'asc' || candidate['order'] === 'desc' ? candidate['order'] : undefined
const collectionId = typeof candidate['collectionId'] === 'number' ? candidate['collectionId'] : undefined
const trashed = typeof candidate['trashed'] === 'boolean' ? candidate['trashed'] : undefined

return { search, collectionId, sort, order }
return { search, collectionId, sort, order, trashed }
}

// Library grid data [P1-08]. Read-only, no slug/path handling needed (unlike
Expand All @@ -163,6 +164,19 @@ ipcMain.handle('vellum:get-paper', (_event, slug: unknown): PaperRecord | null =
return getPaper(getDb(), requireSlug(slug, 'vellum:get-paper')) ?? null
})

// [L2-04] Trash and Purge operations ----------------------------------------
ipcMain.handle('vellum:paper-trash', (_event, slug: unknown): PaperRecord | null => {
return trashPaper(getDb(), requireSlug(slug, 'vellum:paper-trash')) ?? null
})

ipcMain.handle('vellum:paper-restore', (_event, slug: unknown): PaperRecord | null => {
return restorePaper(getDb(), requireSlug(slug, 'vellum:paper-restore')) ?? null
})

ipcMain.handle('vellum:paper-purge', (_event, slug: unknown): boolean => {
return purgePaper(getDb(), requireSlug(slug, 'vellum:paper-purge'), 'data')
})

// [P2-01] Notes tab — one freeform markdown note per paper. -----------------
//
// `getNote` returns undefined for "no note yet"; normalized to null over IPC
Expand Down
4 changes: 4 additions & 0 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ const api = {
listPapers: (options?: ListPapersOptions): Promise<PaperRecord[]> =>
ipcRenderer.invoke('vellum:list-papers', options ?? {}),
getPaper: (slug: string): Promise<PaperRecord | null> => ipcRenderer.invoke('vellum:get-paper', slug),
// [L2-04] Trash & Purge
paperTrash: (slug: string): Promise<PaperRecord | null> => ipcRenderer.invoke('vellum:paper-trash', slug),
paperRestore: (slug: string): Promise<PaperRecord | null> => ipcRenderer.invoke('vellum:paper-restore', slug),
paperPurge: (slug: string): Promise<boolean> => ipcRenderer.invoke('vellum:paper-purge', slug),
// [P2-01] Notes tab — one freeform markdown note per paper. `notesGet`
// resolves null when the paper has no note yet (empty-editor state).
// `notesSave` is autosave's persistence half — upsert-by-paper-slug, so the
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"test:gate:collections": "node test/collections-live.mjs",
"test:gate:chats": "node test/chats-live.mjs",
"test:gate:questions": "node test/questions-live.mjs",
"test:gate:trash": "node test/trash-live.mjs",
"dist": "electron-vite build && electron-builder"
},
"dependencies": {
Expand Down
Loading
Loading