Skip to content
Closed
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
55 changes: 54 additions & 1 deletion packages/core/src/db/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import Database from 'better-sqlite3'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { existsSync, mkdirSync, statSync } from 'node:fs'
import { computeIdentity, type IdentityFs } from '../projects/identity.js'
import { realFs } from '../projects/fs.js'

export const SPOOL_DIR = process.env['SPOOL_DATA_DIR'] ?? join(homedir(), '.spool')
export const DB_PATH = join(SPOOL_DIR, 'spool.db')
Expand Down Expand Up @@ -42,7 +44,7 @@ export function getDBSize(): number {
}
}

function runMigrations(db: Database.Database): void {
export function runMigrations(db: Database.Database): void {
db.exec(`
CREATE TABLE IF NOT EXISTS sources (
id INTEGER PRIMARY KEY,
Expand Down Expand Up @@ -318,11 +320,62 @@ function runMigrations(db: Database.Database): void {
db.pragma('user_version = 5')
}

if (version < 6) {
db.transaction(() => {
db.exec(`
ALTER TABLE projects ADD COLUMN identity_kind TEXT;
ALTER TABLE projects ADD COLUMN identity_key TEXT;
CREATE INDEX IF NOT EXISTS idx_projects_identity
ON projects (identity_kind, identity_key);

CREATE VIEW IF NOT EXISTS project_groups_v AS
SELECT
p.identity_kind,
p.identity_key,
MIN(p.display_name) AS display_name,
GROUP_CONCAT(DISTINCT s.name) AS sources_csv,
COALESCE(SUM(c.session_count),0) AS session_count,
MAX(c.last_session_at) AS last_session_at
FROM projects p
JOIN sources s ON s.id = p.source_id
LEFT JOIN (
SELECT project_id,
COUNT(*) AS session_count,
MAX(started_at) AS last_session_at
FROM sessions
GROUP BY project_id
) c ON c.project_id = p.id
WHERE p.identity_kind IS NOT NULL
GROUP BY p.identity_kind, p.identity_key;
`)
})()
db.pragma('user_version = 6')
backfillProjectIdentities(db, realFs)
}

rebuildFtsTableIfEmpty(db, 'messages', 'messages_fts_trigram')
rebuildFtsTableIfEmpty(db, 'session_search', 'session_search_fts')
rebuildFtsTableIfEmpty(db, 'session_search', 'session_search_fts_trigram')
}

export function backfillProjectIdentities(db: Database.Database, fs: IdentityFs) {
const rows = db.prepare(
`SELECT id, display_path FROM projects WHERE identity_kind IS NULL`
).all() as { id: number; display_path: string }[]
if (rows.length === 0) return
const update = db.prepare(
`UPDATE projects
SET identity_kind = ?, identity_key = ?, display_name = COALESCE(?, display_name)
WHERE id = ?`,
)
db.transaction(() => {
for (const r of rows) {
const id = computeIdentity(r.display_path, fs)
update.run(id.kind, id.key, id.displayName, r.id)
}
})()
}

function rebuildFtsTableIfEmpty(
db: Database.Database,
contentTable: 'messages' | 'session_search',
Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/db/migration-v5.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,8 +148,8 @@ describe('migration v5 (connector subsystem removal)', () => {
expect(dbModule.wasNewDb()).toBe(false)
expect(dbModule.getInitialUserVersion()).toBe(4)

// user_version bumped to 5
expect((db.pragma('user_version') as Array<{ user_version: number }>)[0]?.user_version).toBe(5)
// user_version bumped past 5 (current head is 6)
expect((db.pragma('user_version') as Array<{ user_version: number }>)[0]?.user_version).toBeGreaterThanOrEqual(5)

// Connector tables and FTS gone
const tablesAfter = db.prepare("SELECT name FROM sqlite_master WHERE type='table' OR type='virtual'").all() as Array<{ name: string }>
Expand Down Expand Up @@ -189,7 +189,7 @@ describe('migration v5 (connector subsystem removal)', () => {
expect(dbModule.wasNewDb()).toBe(true)
expect(dbModule.getInitialUserVersion()).toBe(0)

expect((db.pragma('user_version') as Array<{ user_version: number }>)[0]?.user_version).toBe(5)
expect((db.pragma('user_version') as Array<{ user_version: number }>)[0]?.user_version).toBeGreaterThanOrEqual(5)

// Stars exists with narrow CHECK
expect(() => db.prepare("INSERT INTO stars (item_type, item_uuid) VALUES ('capture', 'x')").run()).toThrow()
Expand Down
79 changes: 79 additions & 0 deletions packages/core/src/db/migration-v6.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import Database from 'better-sqlite3'
import { runMigrations, backfillProjectIdentities } from './db.js'
import type { IdentityFs } from '../projects/identity.js'

const stubFs: IdentityFs = {
exists: () => false,
readText: () => null,
spawn: () => ({ stdout: '', exitCode: 1 }),
}

describe('migration v6', () => {
let db: Database.Database
beforeEach(() => { db = new Database(':memory:') })
afterEach(() => db.close())

it('adds identity_kind / identity_key columns to projects', () => {
runMigrations(db)
const cols = db.prepare(`PRAGMA table_info(projects)`).all() as { name: string }[]
expect(cols.map(c => c.name)).toEqual(
expect.arrayContaining(['identity_kind', 'identity_key'])
)
const v = db.pragma('user_version') as Array<{ user_version: number }>
expect(v[0].user_version).toBe(6)
})

it('creates project_groups_v view', () => {
runMigrations(db)
const v = db.prepare(
`SELECT name FROM sqlite_master WHERE type='view' AND name='project_groups_v'`
).get()
expect(v).toBeDefined()
})

it('view groups rows with same identity across sources', () => {
runMigrations(db)
db.exec(`
INSERT INTO projects (source_id, slug, display_path, display_name, identity_kind, identity_key)
VALUES
(1, 'spool-c', '/Users/chen/Code/spool', 'spool', 'git_remote', 'github.com/spool-lab/spool'),
(2, 'spool-x', '/Users/chen/Code/spool', 'spool', 'git_remote', 'github.com/spool-lab/spool');
`)
const groups = db.prepare(`SELECT * FROM project_groups_v`).all() as Array<{ identity_key: string }>
expect(groups).toHaveLength(1)
expect(groups[0].identity_key).toBe('github.com/spool-lab/spool')
})
})

describe('backfillProjectIdentities', () => {
let db: Database.Database
beforeEach(() => { db = new Database(':memory:') })
afterEach(() => db.close())

it('backfills identity for rows with NULL identity_kind', () => {
runMigrations(db)
db.exec(`
INSERT INTO projects (source_id, slug, display_path, display_name, identity_kind, identity_key)
VALUES (1, 'old-row', '/Users/chen/scratch/notes', 'notes', NULL, NULL);
`)
backfillProjectIdentities(db, stubFs)
const r = db.prepare(`SELECT identity_kind, identity_key FROM projects WHERE slug = ?`)
.get('old-row') as { identity_kind: string; identity_key: string }
expect(r.identity_kind).toBe('path')
expect(r.identity_key).toBe('/Users/chen/scratch/notes')
})

it('skips rows that already have identity', () => {
runMigrations(db)
db.exec(`
INSERT INTO projects (source_id, slug, display_path, display_name, identity_kind, identity_key)
VALUES (1, 'has-id', '/x', 'x', 'git_remote', 'github.com/foo/bar');
`)
backfillProjectIdentities(db, stubFs)
const r = db.prepare(`SELECT identity_kind, identity_key FROM projects WHERE slug = ?`)
.get('has-id') as { identity_kind: string; identity_key: string }
expect(r.identity_kind).toBe('git_remote') // unchanged
expect(r.identity_key).toBe('github.com/foo/bar') // unchanged
})
})
34 changes: 34 additions & 0 deletions packages/core/src/db/queries.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { describe, it, expect, beforeEach } from 'vitest'
import Database from 'better-sqlite3'
import { runMigrations } from './db.js'
import { getOrCreateProject } from './queries.js'

describe('getOrCreateProject (with identity)', () => {
let db: Database.Database
beforeEach(() => {
db = new Database(':memory:')
runMigrations(db)
// sources are seeded by runMigrations; do not re-insert
})

it('persists identity_kind and identity_key', () => {
getOrCreateProject(db, 1, 'spool-c', '/Users/chen/Code/spool', 'spool', {
identityKind: 'git_remote',
identityKey: 'github.com/spool-lab/spool',
})
const row = db.prepare(`SELECT identity_kind, identity_key FROM projects WHERE slug = ?`)
.get('spool-c') as { identity_kind: string; identity_key: string }
expect(row.identity_kind).toBe('git_remote')
expect(row.identity_key).toBe('github.com/spool-lab/spool')
})

it('does not duplicate on second call (same source_id, slug)', () => {
const id1 = getOrCreateProject(db, 1, 'spool-c', '/Users/chen/Code/spool', 'spool', {
identityKind: 'git_remote', identityKey: 'github.com/spool-lab/spool',
})
const id2 = getOrCreateProject(db, 1, 'spool-c', '/Users/chen/Code/spool', 'spool', {
identityKind: 'git_remote', identityKey: 'github.com/spool-lab/spool',
})
expect(id1).toBe(id2)
})
})
7 changes: 4 additions & 3 deletions packages/core/src/db/queries.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type Database from 'better-sqlite3'
import type { Session, Message, FragmentResult, StatusInfo, SearchMatchType, SessionSource, StarKind, StarredItem } from '../types.js'
import type { Session, Message, FragmentResult, StatusInfo, SearchMatchType, SessionSource, StarKind, StarredItem, ProjectIdentityKind } from '../types.js'
import { DB_PATH, getDBSize } from './db.js'
import { buildSearchPlan, canUseSessionSearchFts, getNaturalSearchPhrase, getNaturalSearchTerms, selectFtsTableKind, shouldUseSessionFallback } from './search-query.js'

Expand All @@ -9,6 +9,7 @@ export function getOrCreateProject(
slug: string,
displayPath: string,
displayName: string,
identity: { identityKind: ProjectIdentityKind; identityKey: string },
): number {
const existing = db
.prepare('SELECT id FROM projects WHERE source_id = ? AND slug = ?')
Expand All @@ -18,9 +19,9 @@ export function getOrCreateProject(

const result = db
.prepare(
'INSERT INTO projects (source_id, slug, display_path, display_name) VALUES (?, ?, ?, ?)',
'INSERT INTO projects (source_id, slug, display_path, display_name, identity_kind, identity_key) VALUES (?, ?, ?, ?, ?, ?)',
)
.run(sourceId, slug, displayPath, displayName)
.run(sourceId, slug, displayPath, displayName, identity.identityKind, identity.identityKey)

return Number(result.lastInsertRowid)
}
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/db/stars.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ async function loadInto(_spoolDir: string) {
projectDisplay.toLowerCase().replace(/\s+/g, '-'),
`/fake/${projectDisplay}`,
projectDisplay,
{ identityKind: 'path', identityKey: `/fake/${projectDisplay}` },
)
queryModule.upsertSession(db, {
projectId,
Expand Down
18 changes: 18 additions & 0 deletions packages/core/src/projects/display-name.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { describe, it, expect } from 'vitest'
import { fallbackDisplayName } from './display-name.js'

describe('fallbackDisplayName', () => {
it('returns last path segment', () => {
expect(fallbackDisplayName('/Users/chen/Code/spool')).toBe('spool')
expect(fallbackDisplayName('/var/folders/scratch')).toBe('scratch')
})
it('handles trailing slash', () => {
expect(fallbackDisplayName('/Users/chen/Code/spool/')).toBe('spool')
})
it('returns "(root)" for /', () => {
expect(fallbackDisplayName('/')).toBe('(root)')
})
it('returns last segment of slash-bearing strings even non-paths', () => {
expect(fallbackDisplayName('github.com/foo/bar')).toBe('bar')
})
})
6 changes: 6 additions & 0 deletions packages/core/src/projects/display-name.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export function fallbackDisplayName(input: string): string {
if (input === '/') return '(root)'
const trimmed = input.replace(/\/+$/, '')
const parts = trimmed.split('/').filter(Boolean)
return parts[parts.length - 1] ?? trimmed
}
45 changes: 45 additions & 0 deletions packages/core/src/projects/e2e.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { describe, it, expect, beforeEach } from 'vitest'
import Database from 'better-sqlite3'
import { runMigrations } from '../db/db.js'
import { getOrCreateProject } from '../db/queries.js'
import { listProjectGroups } from './groups.js'

describe('project identity e2e', () => {
let db: Database.Database
beforeEach(() => {
db = new Database(':memory:')
runMigrations(db)
// sources auto-seeded by runMigrations (claude=1, codex=2, gemini=3)
// We need a 4th source for the test ('chatgpt') if it's not seeded.
// Verify what's auto-seeded:
// SELECT id, name FROM sources;
// If chatgpt isn't there, insert it.
db.exec(`INSERT OR IGNORE INTO sources (name, base_path) VALUES ('chatgpt', '/tmp/chatgpt');`)
})

it('unifies same repo across multiple sources into one group', () => {
const id = { identityKind: 'git_remote' as const, identityKey: 'github.com/spool-lab/spool' }

// Look up source IDs by name (don't hardcode positional integers)
const claudeId = (db.prepare(`SELECT id FROM sources WHERE name = 'claude'`).get() as { id: number }).id
const codexId = (db.prepare(`SELECT id FROM sources WHERE name = 'codex'`).get() as { id: number }).id
const chatgptId = (db.prepare(`SELECT id FROM sources WHERE name = 'chatgpt'`).get() as { id: number }).id

getOrCreateProject(db, claudeId, 'spool-claude', '/Users/chen/Code/spool', 'spool', id)
getOrCreateProject(db, codexId, 'spool-codex', '/Users/chen/Code/spool', 'spool', id)
getOrCreateProject(db, chatgptId, 'spool-chatgpt', '/Users/chen/Code/spool', 'spool', id)

const groups = listProjectGroups(db)
expect(groups).toHaveLength(1)
expect(groups[0].sources.sort()).toEqual(['chatgpt', 'claude', 'codex'])
})

it('keeps two unrelated path-based projects separate', () => {
const claudeId = (db.prepare(`SELECT id FROM sources WHERE name = 'claude'`).get() as { id: number }).id
getOrCreateProject(db, claudeId, 'a', '/Users/chen/playground/a', 'a',
{ identityKind: 'path', identityKey: '/Users/chen/playground/a' })
getOrCreateProject(db, claudeId, 'b', '/Users/chen/playground/b', 'b',
{ identityKind: 'path', identityKey: '/Users/chen/playground/b' })
expect(listProjectGroups(db)).toHaveLength(2)
})
})
16 changes: 16 additions & 0 deletions packages/core/src/projects/fs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { existsSync, readFileSync } from 'node:fs'
import { spawnSync } from 'node:child_process'
import type { IdentityFs } from './identity.js'

export const realFs: IdentityFs = {
exists: existsSync,
readText: (p) => {
try { return readFileSync(p, 'utf8') } catch { return null }
},
spawn: (cmd, args, opts) => {
// 5s timeout: a hung `git config` (e.g. ssh-agent passphrase prompt,
// network-mounted repo) must not block session indexing.
const r = spawnSync(cmd, args, { cwd: opts.cwd, encoding: 'utf8', timeout: 5000 })
return { stdout: r.stdout ?? '', exitCode: r.status ?? 1 }
},
}
54 changes: 54 additions & 0 deletions packages/core/src/projects/groups.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, it, expect, beforeEach } from 'vitest'
import Database from 'better-sqlite3'
import { runMigrations } from '../db/db.js'
import { listProjectGroups } from './groups.js'

describe('listProjectGroups', () => {
let db: Database.Database
beforeEach(() => {
db = new Database(':memory:')
runMigrations(db)
// sources auto-seeded by runMigrations
})

it('returns empty array when no projects', () => {
expect(listProjectGroups(db)).toEqual([])
})

it('aggregates same-identity rows across sources', () => {
db.exec(`
INSERT INTO projects (source_id, slug, display_path, display_name, identity_kind, identity_key)
VALUES
(1,'spool-c','/Users/chen/Code/spool','spool','git_remote','github.com/spool-lab/spool'),
(2,'spool-x','/Users/chen/Code/spool','spool','git_remote','github.com/spool-lab/spool');
INSERT INTO sessions (project_id, source_id, session_uuid, file_path, title, started_at, ended_at, message_count, has_tool_use, raw_file_mtime)
VALUES
(1,1,'u1','/p1','t','2026-04-28T10:00:00Z','2026-04-28T10:30:00Z',5,0,'2026-04-28T10:30:00Z'),
(2,2,'u2','/p2','t','2026-04-27T10:00:00Z','2026-04-27T10:30:00Z',3,0,'2026-04-27T10:30:00Z');
`)
const groups = listProjectGroups(db)
expect(groups).toHaveLength(1)
expect(groups[0]).toMatchObject({
identityKey: 'github.com/spool-lab/spool',
sources: expect.arrayContaining(['claude', 'codex']),
sessionCount: 2,
})
})

it('orders by lastSessionAt desc, loose last', () => {
db.exec(`
INSERT INTO projects (source_id, slug, display_path, display_name, identity_kind, identity_key)
VALUES
(1,'a','/Users/chen/Code/a','a','path','/Users/chen/Code/a'),
(1,'b','/Users/chen/Code/b','b','path','/Users/chen/Code/b'),
(1,'l','','Loose','loose','loose');
INSERT INTO sessions (project_id, source_id, session_uuid, file_path, title, started_at, ended_at, message_count, has_tool_use, raw_file_mtime)
VALUES
(1,1,'u-a','/pa','t','2026-04-28T10:00:00Z','2026-04-28T11:00:00Z',1,0,'2026-04-28T11:00:00Z'),
(2,1,'u-b','/pb','t','2026-04-26T10:00:00Z','2026-04-26T11:00:00Z',1,0,'2026-04-26T11:00:00Z'),
(3,1,'u-l','/pl','t','2026-04-29T10:00:00Z','2026-04-29T11:00:00Z',1,0,'2026-04-29T11:00:00Z');
`)
const groups = listProjectGroups(db)
expect(groups.map(g => g.identityKind)).toEqual(['path', 'path', 'loose'])
})
})
Loading