Skip to content

Commit f4361bc

Browse files
aksOpsclaude
andcommitted
feat: document versioning — keep all versions in graph with version references
- documents table: add version, canonical_id, is_latest columns - Idempotent migration for existing databases (ALTER TABLE ... ADD COLUMN) - SupersedeDocument: marks old version is_latest=0 (data stays in graph) - GetDocumentVersions: returns full version history by canonical ID - GetDocumentByPath: returns latest version only - ListDocuments: shows latest versions by default - Pipeline: on re-upload, creates new version instead of deleting old data - Entities/relationships from all versions remain in the graph - doc_id on relationships traces which version they came from - API: GET /api/documents/{id}/versions returns full version history Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 14bcf02 commit f4361bc

4 files changed

Lines changed: 174 additions & 62 deletions

File tree

internal/api/handlers.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,25 @@ func (h *handlers) listDocuments(w http.ResponseWriter, r *http.Request) {
6262
writeJSON(w, 200, docs)
6363
}
6464

65+
func (h *handlers) getDocumentVersions(w http.ResponseWriter, r *http.Request) {
66+
id := r.PathValue("id")
67+
doc, err := h.store.GetDocument(r.Context(), id)
68+
if err != nil {
69+
writeError(w, 500, err.Error())
70+
return
71+
}
72+
if doc == nil {
73+
writeError(w, 404, "document not found")
74+
return
75+
}
76+
versions, err := h.store.GetDocumentVersions(r.Context(), doc.CanonicalOrID())
77+
if err != nil {
78+
writeError(w, 500, err.Error())
79+
return
80+
}
81+
writeJSON(w, 200, versions)
82+
}
83+
6584
func (h *handlers) getDocument(w http.ResponseWriter, r *http.Request) {
6685
id := r.PathValue("id")
6786
doc, err := h.store.GetDocument(r.Context(), id)

internal/api/router.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ func NewRouter(st *store.Store, prov llm.Provider, emb *embedder.Embedder, cfg *
2525
mux.HandleFunc("GET /api/stats", h.getStats)
2626
mux.HandleFunc("GET /api/documents", h.listDocuments)
2727
mux.HandleFunc("GET /api/documents/{id}", h.getDocument)
28+
mux.HandleFunc("GET /api/documents/{id}/versions", h.getDocumentVersions)
2829
mux.HandleFunc("POST /api/search", h.search)
2930
mux.HandleFunc("GET /api/graph/neighborhood", h.graphNeighborhood)
3031
mux.HandleFunc("GET /api/entities", h.listEntities)

internal/pipeline/pipeline.go

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -118,19 +118,24 @@ func (p *Pipeline) indexFile(ctx context.Context, path string, opts IndexOptions
118118
h := sha256.Sum256(data)
119119
hash := hex.EncodeToString(h[:])
120120

121-
// Incremental indexing: look up by path, compare hash.
121+
// Incremental versioning: look up the latest version at this path.
122122
existing, err := p.store.GetDocumentByPath(ctx, path)
123123
if err != nil {
124124
return err
125125
}
126+
if existing != nil && existing.FileHash == hash && !opts.Force {
127+
return nil // unchanged — skip
128+
}
129+
130+
// Determine version number and canonical ID for this (possibly new) version.
131+
nextVersion := 1
132+
canonicalID := ""
126133
if existing != nil {
127-
if existing.FileHash == hash && !opts.Force {
128-
return nil // unchanged — skip
129-
}
130-
// Content changed (or --force): delete old document and all cascading data,
131-
// then fall through to re-index.
132-
if err := p.store.DeleteDocument(ctx, existing.ID); err != nil {
133-
return fmt.Errorf("delete stale doc: %w", err)
134+
nextVersion = existing.Version + 1
135+
canonicalID = existing.CanonicalOrID()
136+
// Mark the previous version as superseded (keep all its data in the graph).
137+
if err := p.store.SupersedeDocument(ctx, existing.ID); err != nil {
138+
return fmt.Errorf("supersede old version: %w", err)
134139
}
135140
}
136141

@@ -147,11 +152,14 @@ func (p *Pipeline) indexFile(ctx context.Context, path string, opts IndexOptions
147152

148153
docID := uuid.New().String()
149154
if err := p.store.UpsertDocument(ctx, &store.Document{
150-
ID: docID,
151-
Path: path,
152-
Title: doc.Title,
153-
DocType: doc.DocType,
154-
FileHash: hash,
155+
ID: docID,
156+
Path: path,
157+
Title: doc.Title,
158+
DocType: doc.DocType,
159+
FileHash: hash,
160+
Version: nextVersion,
161+
CanonicalID: canonicalID,
162+
IsLatest: true,
155163
}); err != nil {
156164
return fmt.Errorf("upsert doc: %w", err)
157165
}

internal/store/store.go

Lines changed: 133 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -35,20 +35,34 @@ func (s *Store) Close() error { return s.db.Close() }
3535
func (s *Store) DB() *sql.DB { return s.db }
3636

3737
func (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

4253
const schema = `
4354
CREATE 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
5468
CREATE 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);
114130
CREATE INDEX IF NOT EXISTS idx_chunks_doc ON chunks(doc_id);
115131
CREATE INDEX IF NOT EXISTS idx_embeddings_model ON embeddings(model);
116132
CREATE INDEX IF NOT EXISTS idx_entities_name ON entities(name);
@@ -158,70 +174,138 @@ func CosineSimilarity(a, b []float32) float32 {
158174
// ── Document CRUD ─────────────────────────────────────────────────────────────
159175

160176
type 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

171199
func (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

210299
func (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

220303
func (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

Comments
 (0)