@@ -35,20 +35,34 @@ func (s *Store) Close() error { return s.db.Close() }
3535func (s * Store ) DB () * sql.DB { return s .db }
3636
3737func (s * Store ) migrate () error {
38- _ , err := s .db .Exec (schema )
39- return err
38+ if _ , err := s .db .Exec (schema ); err != nil {
39+ return err
40+ }
41+ // Idempotent column additions for existing databases
42+ migrations := []string {
43+ `ALTER TABLE documents ADD COLUMN version INTEGER NOT NULL DEFAULT 1` ,
44+ `ALTER TABLE documents ADD COLUMN canonical_id TEXT` ,
45+ `ALTER TABLE documents ADD COLUMN is_latest INTEGER NOT NULL DEFAULT 1` ,
46+ }
47+ for _ , m := range migrations {
48+ s .db .Exec (m ) // ignore "duplicate column" errors
49+ }
50+ return nil
4051}
4152
4253const schema = `
4354CREATE TABLE IF NOT EXISTS documents (
44- id TEXT PRIMARY KEY,
45- path TEXT NOT NULL,
46- title TEXT,
47- doc_type TEXT,
48- file_hash TEXT UNIQUE,
49- structured TEXT,
50- created_at INTEGER,
51- updated_at INTEGER
55+ id TEXT PRIMARY KEY,
56+ path TEXT NOT NULL,
57+ title TEXT,
58+ doc_type TEXT,
59+ file_hash TEXT UNIQUE,
60+ structured TEXT,
61+ version INTEGER NOT NULL DEFAULT 1,
62+ canonical_id TEXT, -- NULL on first version; points to v1 ID on all later versions
63+ is_latest INTEGER NOT NULL DEFAULT 1,
64+ created_at INTEGER,
65+ updated_at INTEGER
5266);
5367
5468CREATE TABLE IF NOT EXISTS chunks (
@@ -111,6 +125,8 @@ CREATE TABLE IF NOT EXISTS community_members (
111125 PRIMARY KEY (community_id, entity_id)
112126);
113127
128+ CREATE INDEX IF NOT EXISTS idx_doc_canonical ON documents(canonical_id);
129+ CREATE INDEX IF NOT EXISTS idx_doc_path_latest ON documents(path, is_latest);
114130CREATE INDEX IF NOT EXISTS idx_chunks_doc ON chunks(doc_id);
115131CREATE INDEX IF NOT EXISTS idx_embeddings_model ON embeddings(model);
116132CREATE INDEX IF NOT EXISTS idx_entities_name ON entities(name);
@@ -158,70 +174,138 @@ func CosineSimilarity(a, b []float32) float32 {
158174// ── Document CRUD ─────────────────────────────────────────────────────────────
159175
160176type Document struct {
161- ID string
162- Path string
163- Title string
164- DocType string
165- FileHash string
166- Structured string
167- CreatedAt int64
168- UpdatedAt int64
177+ ID string
178+ Path string
179+ Title string
180+ DocType string
181+ FileHash string
182+ Structured string
183+ Version int
184+ CanonicalID string // empty on v1; ID of first version on v2+
185+ IsLatest bool
186+ CreatedAt int64
187+ UpdatedAt int64
188+ }
189+
190+ // CanonicalOrID returns CanonicalID if set, otherwise the document's own ID.
191+ // This is the stable identifier across all versions of a file.
192+ func (d * Document ) CanonicalOrID () string {
193+ if d .CanonicalID != "" {
194+ return d .CanonicalID
195+ }
196+ return d .ID
169197}
170198
171199func (s * Store ) UpsertDocument (ctx context.Context , doc * Document ) error {
172200 now := time .Now ().Unix ()
201+ if doc .Version == 0 {
202+ doc .Version = 1
203+ }
204+ var canonicalID any
205+ if doc .CanonicalID != "" {
206+ canonicalID = doc .CanonicalID
207+ }
208+ isLatest := 0
209+ if doc .IsLatest {
210+ isLatest = 1
211+ }
173212 _ , err := s .db .ExecContext (ctx , `
174- INSERT INTO documents (id, path, title, doc_type, file_hash, structured, created_at, updated_at)
175- VALUES (?,?,?,?,?,?,?,?)
213+ INSERT INTO documents (id, path, title, doc_type, file_hash, structured, version, canonical_id, is_latest, created_at, updated_at)
214+ VALUES (?,?,?,?,?,?,?,?,?,?,? )
176215 ON CONFLICT(id) DO UPDATE SET
177216 path=excluded.path, title=excluded.title, doc_type=excluded.doc_type,
178- file_hash=excluded.file_hash, structured=excluded.structured, updated_at=excluded.updated_at` ,
179- doc .ID , doc .Path , doc .Title , doc .DocType , doc .FileHash , doc .Structured , now , now )
217+ file_hash=excluded.file_hash, structured=excluded.structured,
218+ version=excluded.version, canonical_id=excluded.canonical_id,
219+ is_latest=excluded.is_latest, updated_at=excluded.updated_at` ,
220+ doc .ID , doc .Path , doc .Title , doc .DocType , doc .FileHash , doc .Structured ,
221+ doc .Version , canonicalID , isLatest , now , now )
180222 return err
181223}
182224
183- func (s * Store ) GetDocumentByHash (ctx context.Context , hash string ) (* Document , error ) {
184- row := s .db .QueryRowContext (ctx , `SELECT id,path,title,doc_type,file_hash,structured,created_at,updated_at FROM documents WHERE file_hash=?` , hash )
225+ // SupersedeDocument marks a document as no longer the latest version.
226+ func (s * Store ) SupersedeDocument (ctx context.Context , id string ) error {
227+ _ , err := s .db .ExecContext (ctx , `UPDATE documents SET is_latest=0, updated_at=? WHERE id=?` , time .Now ().Unix (), id )
228+ return err
229+ }
230+
231+ // GetDocumentVersions returns all versions of a document by canonical ID, oldest first.
232+ func (s * Store ) GetDocumentVersions (ctx context.Context , canonicalID string ) ([]* Document , error ) {
233+ rows , err := s .db .QueryContext (ctx , `
234+ SELECT id,path,title,doc_type,file_hash,structured,version,canonical_id,is_latest,created_at,updated_at
235+ FROM documents
236+ WHERE id=? OR canonical_id=?
237+ ORDER BY version ASC` , canonicalID , canonicalID )
238+ if err != nil {
239+ return nil , err
240+ }
241+ defer rows .Close ()
242+ var docs []* Document
243+ for rows .Next () {
244+ d , err := scanDocRow (rows )
245+ if err != nil {
246+ return nil , err
247+ }
248+ docs = append (docs , d )
249+ }
250+ return docs , rows .Err ()
251+ }
252+
253+ const docSelect = `SELECT id,path,title,doc_type,file_hash,structured,version,canonical_id,is_latest,created_at,updated_at FROM documents`
254+
255+ func scanDocRow (rows * sql.Rows ) (* Document , error ) {
185256 var d Document
186- err := row .Scan (& d .ID , & d .Path , & d .Title , & d .DocType , & d .FileHash , & d .Structured , & d .CreatedAt , & d .UpdatedAt )
187- if err == sql .ErrNoRows {
188- return nil , nil
257+ var canonicalID sql.NullString
258+ var isLatest int
259+ err := rows .Scan (& d .ID , & d .Path , & d .Title , & d .DocType , & d .FileHash , & d .Structured ,
260+ & d .Version , & canonicalID , & isLatest , & d .CreatedAt , & d .UpdatedAt )
261+ if err != nil {
262+ return nil , err
189263 }
190- return & d , err
264+ if canonicalID .Valid {
265+ d .CanonicalID = canonicalID .String
266+ }
267+ d .IsLatest = isLatest == 1
268+ return & d , nil
191269}
192270
193- func (s * Store ) GetDocumentByPath (ctx context.Context , path string ) (* Document , error ) {
194- row := s .db .QueryRowContext (ctx , `SELECT id,path,title,doc_type,file_hash,structured,created_at,updated_at FROM documents WHERE path=?` , path )
271+ func scanDocSingleRow (row * sql.Row ) (* Document , error ) {
195272 var d Document
196- err := row .Scan (& d .ID , & d .Path , & d .Title , & d .DocType , & d .FileHash , & d .Structured , & d .CreatedAt , & d .UpdatedAt )
273+ var canonicalID sql.NullString
274+ var isLatest int
275+ err := row .Scan (& d .ID , & d .Path , & d .Title , & d .DocType , & d .FileHash , & d .Structured ,
276+ & d .Version , & canonicalID , & isLatest , & d .CreatedAt , & d .UpdatedAt )
197277 if err == sql .ErrNoRows {
198278 return nil , nil
199279 }
200- return & d , err
280+ if err != nil {
281+ return nil , err
282+ }
283+ if canonicalID .Valid {
284+ d .CanonicalID = canonicalID .String
285+ }
286+ d .IsLatest = isLatest == 1
287+ return & d , nil
201288}
202289
203- // DeleteDocument removes a document and all its cascading data
204- // (chunks, embeddings, relationships, claims) via ON DELETE CASCADE.
205- func (s * Store ) DeleteDocument (ctx context.Context , id string ) error {
206- _ , err := s .db .ExecContext (ctx , `DELETE FROM documents WHERE id=?` , id )
207- return err
290+ func (s * Store ) GetDocumentByHash (ctx context.Context , hash string ) (* Document , error ) {
291+ return scanDocSingleRow (s .db .QueryRowContext (ctx , docSelect + ` WHERE file_hash=?` , hash ))
292+ }
293+
294+ func (s * Store ) GetDocumentByPath (ctx context.Context , path string ) (* Document , error ) {
295+ // Returns the latest version at this path.
296+ return scanDocSingleRow (s .db .QueryRowContext (ctx , docSelect + ` WHERE path=? AND is_latest=1` , path ))
208297}
209298
210299func (s * Store ) GetDocument (ctx context.Context , id string ) (* Document , error ) {
211- row := s .db .QueryRowContext (ctx , `SELECT id,path,title,doc_type,file_hash,structured,created_at,updated_at FROM documents WHERE id=?` , id )
212- var d Document
213- err := row .Scan (& d .ID , & d .Path , & d .Title , & d .DocType , & d .FileHash , & d .Structured , & d .CreatedAt , & d .UpdatedAt )
214- if err == sql .ErrNoRows {
215- return nil , nil
216- }
217- return & d , err
300+ return scanDocSingleRow (s .db .QueryRowContext (ctx , docSelect + ` WHERE id=?` , id ))
218301}
219302
220303func (s * Store ) ListDocuments (ctx context.Context , docType string , limit , offset int ) ([]* Document , error ) {
221- q := `SELECT id,path,title,doc_type,file_hash,structured,created_at,updated_at FROM documents`
304+ // Default: only latest versions.
305+ q := docSelect + ` WHERE is_latest=1`
222306 args := []any {}
223307 if docType != "" {
224- q += ` WHERE doc_type=?`
308+ q += ` AND doc_type=?`
225309 args = append (args , docType )
226310 }
227311 q += ` ORDER BY created_at DESC LIMIT ? OFFSET ?`
@@ -233,11 +317,11 @@ func (s *Store) ListDocuments(ctx context.Context, docType string, limit, offset
233317 defer rows .Close ()
234318 var docs []* Document
235319 for rows .Next () {
236- var d Document
237- if err := rows . Scan ( & d . ID , & d . Path , & d . Title , & d . DocType , & d . FileHash , & d . Structured , & d . CreatedAt , & d . UpdatedAt ); err != nil {
320+ d , err := scanDocRow ( rows )
321+ if err != nil {
238322 return nil , err
239323 }
240- docs = append (docs , & d )
324+ docs = append (docs , d )
241325 }
242326 return docs , rows .Err ()
243327}
0 commit comments