Skip to content

Commit 06ca4d0

Browse files
committed
feat(edit): implement synchronous indexing for edit chain resolution and enhance edit tracking in tests
1 parent 4140abf commit 06ca4d0

File tree

4 files changed

+210
-13
lines changed

4 files changed

+210
-13
lines changed

frontend/src/routes/+page.svelte

Lines changed: 98 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -630,6 +630,64 @@
630630
};
631631
632632
const upsertNodeFromStream = (node: ThreadNodePayload) => {
633+
// If this node is an edit of another message, do NOT display it separately.
634+
// Instead, update the original message to point to this edit, and register
635+
// the edit key so subsequent edits in the chain can find the original.
636+
// EXCEPTION: If messages is empty, this is the first/root node being streamed
637+
// (e.g., from a search selection), so we should display it even if it's an edit.
638+
if (node.editOf && messages.length > 0) {
639+
// Walk the edit chain to find the original (non-edit) message
640+
let originalKey = node.editOf;
641+
let visited = new Set<string>();
642+
while (originalKey && !visited.has(originalKey)) {
643+
visited.add(originalKey);
644+
const path = keyToPath.get(originalKey);
645+
if (path) {
646+
const msg = getMessageAtPath(messages, path);
647+
if (msg && msg.editOf) {
648+
// This message is itself an edit, keep walking back
649+
originalKey = msg.editOf;
650+
} else {
651+
// Found the original message
652+
break;
653+
}
654+
} else {
655+
break;
656+
}
657+
}
658+
659+
const originalPath = keyToPath.get(originalKey);
660+
if (originalPath) {
661+
updateMessageAtPath(originalPath, (message) => {
662+
// Update the original message with the edit's content metadata
663+
message.mimeType = node.mimeType;
664+
message.isText = node.isText;
665+
message.sizeBytes = node.sizeBytes;
666+
// Keep original createdAt to preserve position in sort order
667+
if (node.computedId) {
668+
message.computedId = node.computedId;
669+
}
670+
// Track that this message has been edited and store the edit key
671+
message.suggestedEdit = node.key;
672+
message.resolvedKey = node.key;
673+
if (node.title) {
674+
message.title = node.title;
675+
if (!message.attachmentName) {
676+
message.attachmentName = node.title;
677+
}
678+
}
679+
});
680+
// Register the edit key to point to the original's path
681+
if (node.key) {
682+
keyToPath.set(node.key, originalPath);
683+
}
684+
requestMessageHydration(node);
685+
return;
686+
}
687+
// If original not found, skip this edit entirely - don't create a duplicate
688+
return;
689+
}
690+
633691
const existingPath = node.key ? keyToPath.get(node.key) : undefined;
634692
if (existingPath) {
635693
updateMessageAtPath(existingPath, (message) => {
@@ -1076,24 +1134,37 @@
10761134
if (!node.key) {
10771135
return;
10781136
}
1079-
const cached = await readCachedMessageRecord(node.key);
1137+
// Use suggestedEdit key if available - this points to the latest edit content
1138+
const hydrateKey = node.suggestedEdit || node.key;
1139+
1140+
// Map the hydration key to the original's path BEFORE hydrating,
1141+
// so when the result comes back, applyRecordToMessage can find it
1142+
if (hydrateKey !== node.key) {
1143+
const path = keyToPath.get(node.key);
1144+
if (path) {
1145+
keyToPath.set(hydrateKey, path);
1146+
}
1147+
}
1148+
1149+
const cached = await readCachedMessageRecord(hydrateKey);
10801150
if (cached) {
1081-
applyRecordToMessage(cached);
1151+
applyRecordToMessage({ ...cached, key: node.key });
10821152
return;
10831153
}
10841154
// For text nodes, we hydrate via the bulk worker (it will provide content
10851155
// and metadata). For non-text nodes we avoid downloading the full payload
10861156
// (could be large) and instead perform a small Range fetch to get headers
10871157
// such as X-Ouroboros-Title.
10881158
if (node.isText) {
1089-
await enqueueBulkHydration(node.key);
1159+
await enqueueBulkHydration(hydrateKey);
10901160
return;
10911161
}
10921162
try {
1093-
const record = await fetchMessageMetadata(node);
1163+
const record = await fetchMessageMetadata({ ...node, key: hydrateKey });
10941164
if (record) {
1165+
// Store with the hydrate key but apply to the original message
10951166
persistMessageRecord(record);
1096-
applyRecordToMessage(record);
1167+
applyRecordToMessage({ ...record, key: node.key });
10971168
}
10981169
} catch (error) {
10991170
console.warn('Failed to fetch message metadata', error);
@@ -1263,6 +1334,14 @@
12631334
if (node.key) {
12641335
map.set(node.key, current);
12651336
}
1337+
// Also map suggestedEdit and resolvedKey to the same path,
1338+
// so hydration results from edit keys can find the original message
1339+
if (node.suggestedEdit && node.suggestedEdit !== node.key) {
1340+
map.set(node.suggestedEdit, current);
1341+
}
1342+
if (node.resolvedKey && node.resolvedKey !== node.key) {
1343+
map.set(node.resolvedKey, current);
1344+
}
12661345
traverse(node.children, current);
12671346
});
12681347
};
@@ -1953,7 +2032,10 @@
19532032
mime_type: mimeType,
19542033
is_text: true,
19552034
filename: 'message.txt',
1956-
edit_of: target.key
2035+
// Use resolvedKey or suggestedEdit if available - this ensures edits form
2036+
// a chain (Test1 → Test2 → Test3 → Test4) rather than all pointing to the
2037+
// original (Test1 ← Test2, Test1 ← Test3, Test1 ← Test4).
2038+
edit_of: target.resolvedKey || target.suggestedEdit || target.key
19572039
};
19582040
if (target.parentKey) {
19592041
metadata.parent = target.parentKey;
@@ -1996,7 +2078,15 @@
19962078
19972079
const updated = getMessageAtPath(messages, editTargetPath);
19982080
if (updated && updated.key) {
1999-
persistMessageRecord(messageToRecord(updated));
2081+
// Cache with both the original key AND the new edit key.
2082+
// On reload, we'll hydrate using suggestedEdit (the new key),
2083+
// so it needs to be in the cache.
2084+
const record = messageToRecord(updated);
2085+
persistMessageRecord(record);
2086+
if (key && key !== updated.key) {
2087+
// Also cache under the new edit key so hydration can find it
2088+
persistMessageRecord({ ...record, key: key });
2089+
}
20002090
}
20012091
20022092
statusState = 'success';
@@ -2739,6 +2829,7 @@
27392829
top: 0;
27402830
background: var(--surface-raised);
27412831
padding: 0.75rem;
2832+
z-index: 100;
27422833
}
27432834
27442835
.conversation-header h1 {

ouroboros.go

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -316,11 +316,14 @@ func (ou *OuroborosDB) StoreData(ctx context.Context, content []byte, opts Store
316316
return hash.Hash{}, err
317317
}
318318

319-
// After successful write, index the new data asynchronously if indexer is present.
319+
// After successful write, index the new data. This must be synchronous
320+
// so that edit chain resolution works correctly on subsequent reads.
321+
// Previously this was async which caused race conditions where the index
322+
// wasn't updated in time for reload requests.
320323
if ou.indexer != nil {
321-
go func(h hash.Hash) {
322-
_ = ou.indexer.IndexHash(h)
323-
}(dataHash)
324+
if err := ou.indexer.IndexHash(dataHash); err != nil && ou.log != nil {
325+
ou.log.Warn("failed to index new data", "error", err, "key", dataHash.String())
326+
}
324327
}
325328

326329
return dataHash, nil

ouroboros_test.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,106 @@ func TestGetDataSuggestsLatestEdit(t *testing.T) {
240240
}
241241
}
242242

243+
func TestGetDataSuggestsLatestEditChained(t *testing.T) {
244+
testDir := setupTestDir(t)
245+
t.Cleanup(func() { cleanupTestDir(t, testDir) })
246+
247+
setupTestKeyFile(t, testDir)
248+
cfg := Config{Paths: []string{testDir}, MinimumFreeGB: 1, Logger: testLogger()}
249+
db, err := New(cfg)
250+
if err != nil {
251+
t.Fatalf("new db: %v", err)
252+
}
253+
if err := db.Start(context.Background()); err != nil {
254+
t.Fatalf("start db: %v", err)
255+
}
256+
defer func() { _ = db.CloseWithoutContext() }()
257+
258+
// Create original message (Test1)
259+
test1Content := []byte("Test1")
260+
test1Key, err := db.StoreData(context.Background(), test1Content, StoreOptions{MimeType: "text/plain; charset=utf-8"})
261+
if err != nil {
262+
t.Fatalf("store test1: %v", err)
263+
}
264+
time.Sleep(10 * time.Millisecond)
265+
266+
// Edit Test1 -> Test2
267+
test2Content := []byte("Test2")
268+
test2Key, err := db.StoreData(context.Background(), test2Content, StoreOptions{
269+
MimeType: "text/plain; charset=utf-8",
270+
EditOf: test1Key,
271+
})
272+
if err != nil {
273+
t.Fatalf("store test2: %v", err)
274+
}
275+
time.Sleep(10 * time.Millisecond)
276+
277+
// Edit Test2 -> Test3
278+
test3Content := []byte("Test3")
279+
test3Key, err := db.StoreData(context.Background(), test3Content, StoreOptions{
280+
MimeType: "text/plain; charset=utf-8",
281+
EditOf: test2Key,
282+
})
283+
if err != nil {
284+
t.Fatalf("store test3: %v", err)
285+
}
286+
time.Sleep(10 * time.Millisecond)
287+
288+
// Edit Test3 -> Test10
289+
test10Content := []byte("Test10")
290+
test10Key, err := db.StoreData(context.Background(), test10Content, StoreOptions{
291+
MimeType: "text/plain; charset=utf-8",
292+
EditOf: test3Key,
293+
})
294+
if err != nil {
295+
t.Fatalf("store test10: %v", err)
296+
}
297+
298+
// Force index updates so edit resolution is available synchronously.
299+
idx := db.Indexer()
300+
if idx != nil {
301+
for _, k := range []cryptHash.Hash{test1Key, test2Key, test3Key, test10Key} {
302+
if err := idx.IndexHash(k); err != nil {
303+
t.Fatalf("index %s: %v", k.String(), err)
304+
}
305+
}
306+
}
307+
308+
// Verify the edit chain resolves correctly from Test1 to Test10
309+
resolved, changed := db.resolveLatestEdit(test1Key)
310+
if !changed || resolved != test10Key {
311+
t.Fatalf("expected resolveLatestEdit(test1) to return test10 %s, got %s (changed=%v)",
312+
test10Key.String(), resolved.String(), changed)
313+
}
314+
315+
// Verify GetData on test1 returns suggestedEdit pointing to test10
316+
data, err := db.GetData(context.Background(), test1Key)
317+
if err != nil {
318+
t.Fatalf("get data: %v", err)
319+
}
320+
if data.SuggestedEdit.IsZero() || data.SuggestedEdit != test10Key {
321+
t.Fatalf("expected suggested edit %s, got %s", test10Key.String(), data.SuggestedEdit.String())
322+
}
323+
324+
// Verify the original content is returned (not the edit content)
325+
if string(data.Content) != string(test1Content) {
326+
t.Fatalf("expected original content %q, got %q", string(test1Content), string(data.Content))
327+
}
328+
329+
// Verify that intermediate edits also resolve to test10
330+
resolvedFrom2, _ := db.resolveLatestEdit(test2Key)
331+
if resolvedFrom2 != test10Key {
332+
t.Fatalf("expected resolveLatestEdit(test2) to return test10 %s, got %s",
333+
test10Key.String(), resolvedFrom2.String())
334+
}
335+
336+
resolvedFrom3, _ := db.resolveLatestEdit(test3Key)
337+
if resolvedFrom3 != test10Key {
338+
t.Fatalf("expected resolveLatestEdit(test3) to return test10 %s, got %s",
339+
test10Key.String(), resolvedFrom3.String())
340+
}
341+
}
342+
243343
func TestNewOuroborosDB_WithDefaultLogger(t *testing.T) { // A
244344
testDir := setupTestDir(t)
245345
t.Cleanup(func() { cleanupTestDir(t, testDir) })

pkg/apiServer/meta.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -249,10 +249,13 @@ func (s *Server) handleThreadNodeStream(w http.ResponseWriter, r *http.Request)
249249
return baseData.SuggestedEdit.String()
250250
}(),
251251
EditOf: func() string {
252-
if nodeData.EditOf.IsZero() {
252+
// Use baseData.EditOf, not nodeData.EditOf, because we want to know
253+
// if THIS node (by its original key) is an edit, not whether the
254+
// suggested edit content is an edit of something else.
255+
if baseData.EditOf.IsZero() {
253256
return ""
254257
}
255-
return nodeData.EditOf.String()
258+
return baseData.EditOf.String()
256259
}(),
257260
}
258261
if !nodeData.CreatedAt.IsZero() {

0 commit comments

Comments
 (0)