diff --git a/frontend/src/lib/apiClient.ts b/frontend/src/lib/apiClient.ts index 6392277..da3177c 100644 --- a/frontend/src/lib/apiClient.ts +++ b/frontend/src/lib/apiClient.ts @@ -1,5 +1,7 @@ export type ThreadSummaryPayload = { key: string; + resolvedKey?: string; + editOf?: string; preview: string; title?: string; mimeType: string; @@ -11,6 +13,8 @@ export type ThreadSummaryPayload = { export type ThreadNodePayload = { key: string; + resolvedKey?: string; + editOf?: string; parent?: string; title?: string; mimeType: string; @@ -23,6 +27,8 @@ export type ThreadNodePayload = { export type BulkDataRecord = { key: string; + resolvedKey?: string; + editOf?: string; found: boolean; mimeType?: string; isText?: boolean; diff --git a/ouroboros.go b/ouroboros.go index 30c8868..8748559 100644 --- a/ouroboros.go +++ b/ouroboros.go @@ -62,6 +62,7 @@ type StoreOptions struct { ReedSolomonParityShards uint8 MimeType string Title string + EditOf hash.Hash } // Config configures the database instance. Only Paths[0] is used at the @@ -78,14 +79,16 @@ type Config struct { } type RetrievedData struct { - Key hash.Hash - Content []byte - MimeType string - IsText bool - Parent hash.Hash - Children []hash.Hash - CreatedAt time.Time - Title string + Key hash.Hash + ResolvedKey hash.Hash + EditOf hash.Hash + Content []byte + MimeType string + IsText bool + Parent hash.Hash + Children []hash.Hash + CreatedAt time.Time + Title string } // Use shared metadata type from pkg/meta @@ -248,7 +251,12 @@ func (ou *OuroborosDB) StoreData(ctx context.Context, content []byte, opts Store encodedContent := content // TODO allow additional user-defined metadata but server set CreatedAt is mandatory - metaBytes, err := encodeMetadata(meta.Metadata{CreatedAt: time.Now().UTC(), Title: opts.Title, MimeType: opts.MimeType}) + md := meta.Metadata{CreatedAt: time.Now().UTC(), Title: opts.Title, MimeType: opts.MimeType} + if !opts.EditOf.IsZero() { + md.EditOf = opts.EditOf.String() + } + + metaBytes, err := encodeMetadata(md) if err != nil { return hash.Hash{}, fmt.Errorf("encode metadata: %w", err) } @@ -260,8 +268,12 @@ func (ou *OuroborosDB) StoreData(ctx context.Context, content []byte, opts Store RSParitySlices: opts.ReedSolomonParityShards, } - if !opts.Parent.IsZero() { - data.Parent = opts.Parent + effectiveParent := opts.Parent + if !opts.EditOf.IsZero() { + effectiveParent = opts.EditOf + } + if !effectiveParent.IsZero() { + data.Parent = effectiveParent } for _, child := range opts.Children { if !child.IsZero() { @@ -325,25 +337,36 @@ func (ou *OuroborosDB) GetData(ctx context.Context, key hash.Hash) (RetrievedDat return RetrievedData{}, err } - data, err := kv.ReadData(key) + resolvedKey, resolvedData, lineage, err := ou.resolveLatestEdit(ctx, kv, key) if err != nil { return RetrievedData{}, err } + originalData := resolvedData + if resolvedKey != key { + if data, readErr := kv.ReadData(key); readErr == nil { + originalData = data + } + } + // Content is stored raw in the data payload and the MIME type is stored in metadata. - content := data.Content + content := resolvedData.Content isText := false returnedMime := "" + var editOf hash.Hash - parent := data.Parent + parent := originalData.Parent if indexedParent, err := kv.GetParent(key); err == nil && !indexedParent.IsZero() { parent = indexedParent } + if parent.IsZero() { + parent = resolvedData.Parent + } var createdAt time.Time var title string - if len(data.Meta) > 0 { - md, metaErr := decodeMetadata(data.Meta) + if len(resolvedData.Meta) > 0 { + md, metaErr := decodeMetadata(resolvedData.Meta) if metaErr != nil { if ou.log != nil { ou.log.Warn("failed to decode metadata", "error", metaErr, "key", key.String()) @@ -355,18 +378,25 @@ func (ou *OuroborosDB) GetData(ctx context.Context, key hash.Hash) (RetrievedDat returnedMime = md.MimeType isText = strings.HasPrefix(returnedMime, "text/") } + if parsedEdit, err := parseEditOf(md.EditOf); err == nil { + editOf = parsedEdit + } } } + children := mergeChildrenExcludingEdits(kv, lineage, originalData.Children, resolvedData.Children) + return RetrievedData{ - Key: key, - Content: content, - MimeType: returnedMime, - IsText: isText, - Parent: parent, - Children: data.Children, - CreatedAt: createdAt, - Title: title, + Key: key, + ResolvedKey: resolvedKey, + EditOf: editOf, + Content: content, + MimeType: returnedMime, + IsText: isText, + Parent: parent, + Children: children, + CreatedAt: createdAt, + Title: title, }, nil } @@ -389,3 +419,114 @@ func decodeMetadata(raw []byte) (meta.Metadata, error) { // PHC } return md, nil } + +func parseEditOf(raw string) (hash.Hash, error) { // HC + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return hash.Hash{}, nil + } + return hash.HashHexadecimal(trimmed) +} + +func (ou *OuroborosDB) resolveLatestEdit(ctx context.Context, kv ouroboroskv.Store, start hash.Hash) (hash.Hash, ouroboroskv.Data, []hash.Hash, error) { // PAP + if err := ctx.Err(); err != nil { + return hash.Hash{}, ouroboroskv.Data{}, nil, err + } + + currentKey := start + visited := make(map[string]struct{}) + lineage := make([]hash.Hash, 0, 4) + var currentData ouroboroskv.Data + + for { + if err := ctx.Err(); err != nil { + return hash.Hash{}, ouroboroskv.Data{}, lineage, err + } + if _, seen := visited[currentKey.String()]; seen { + return hash.Hash{}, ouroboroskv.Data{}, lineage, fmt.Errorf("edit chain cycle detected for %s", currentKey.String()) + } + visited[currentKey.String()] = struct{}{} + lineage = append(lineage, currentKey) + + data, err := kv.ReadData(currentKey) + if err != nil { + return hash.Hash{}, ouroboroskv.Data{}, lineage, err + } + currentData = data + + var latestKey hash.Hash + var latestData ouroboroskv.Data + var latestCreated time.Time + + for _, child := range data.Children { + if child.IsZero() { + continue + } + childData, err := kv.ReadData(child) + if err != nil { + continue + } + md, err := decodeMetadata(childData.Meta) + if err != nil { + continue + } + editTarget, err := parseEditOf(md.EditOf) + if err != nil || editTarget.IsZero() { + continue + } + if editTarget != currentKey { + continue + } + createdAt := md.CreatedAt + if latestKey.IsZero() || createdAt.After(latestCreated) { + latestKey = child + latestData = childData + latestCreated = createdAt + } + } + + if latestKey.IsZero() { + return currentKey, currentData, lineage, nil + } + + currentKey = latestKey + currentData = latestData + } +} + +func mergeChildrenExcludingEdits(kv ouroboroskv.Store, lineage []hash.Hash, childLists ...[]hash.Hash) []hash.Hash { // HC + lineageSet := make(map[string]struct{}, len(lineage)) + for _, k := range lineage { + lineageSet[k.String()] = struct{}{} + } + + seen := make(map[string]struct{}) + merged := make([]hash.Hash, 0) + + for _, list := range childLists { + for _, child := range list { + if child.IsZero() { + continue + } + keyStr := child.String() + if _, exists := seen[keyStr]; exists { + continue + } + + if childData, err := kv.ReadData(child); err == nil { + if md, err := decodeMetadata(childData.Meta); err == nil { + if editTarget, err := parseEditOf(md.EditOf); err == nil && !editTarget.IsZero() { + if _, isEditChild := lineageSet[editTarget.String()]; isEditChild { + continue + } + } + } + } + + seen[keyStr] = struct{}{} + merged = append(merged, child) + } + } + + return merged +} diff --git a/ouroboros_test.go b/ouroboros_test.go index e0af956..a659f42 100644 --- a/ouroboros_test.go +++ b/ouroboros_test.go @@ -486,6 +486,97 @@ func TestStoreDataParentChildRelationships(t *testing.T) { // A } } +func TestGetDataResolvesLatestEdit(t *testing.T) { // A + testDir := setupTestDir(t) + t.Cleanup(func() { cleanupTestDir(t, testDir) }) + + setupTestKeyFile(t, testDir) + + db := newStartedDB(t, Config{Paths: []string{testDir}, MinimumFreeGB: 1, Logger: testLogger()}) + + ctx := context.Background() + originalKey, err := db.StoreData(ctx, []byte("v1"), StoreOptions{MimeType: "text/plain; charset=utf-8"}) + if err != nil { + t.Fatalf("failed to store original message: %v", err) + } + + time.Sleep(2 * time.Millisecond) + _, err = db.StoreData(ctx, []byte("v2"), StoreOptions{EditOf: originalKey, MimeType: "text/plain; charset=utf-8"}) + if err != nil { + t.Fatalf("failed to store first edit: %v", err) + } + + time.Sleep(2 * time.Millisecond) + latestKey, err := db.StoreData(ctx, []byte("v3"), StoreOptions{EditOf: originalKey, MimeType: "text/plain; charset=utf-8"}) + if err != nil { + t.Fatalf("failed to store second edit: %v", err) + } + + data, err := db.GetData(ctx, originalKey) + if err != nil { + t.Fatalf("GetData failed: %v", err) + } + + if data.Key != originalKey { + t.Fatalf("expected requested key %s to remain, got %s", originalKey.String(), data.Key.String()) + } + if data.ResolvedKey != latestKey { + t.Fatalf("expected resolved key %s, got %s", latestKey.String(), data.ResolvedKey.String()) + } + if data.EditOf != originalKey { + t.Fatalf("expected edit_of to point to %s, got %s", originalKey.String(), data.EditOf.String()) + } + if string(data.Content) != "v3" { + t.Fatalf("expected resolved content 'v3', got %q", string(data.Content)) + } +} + +func TestGetDataMergesChildrenAcrossEdits(t *testing.T) { // A + testDir := setupTestDir(t) + t.Cleanup(func() { cleanupTestDir(t, testDir) }) + + setupTestKeyFile(t, testDir) + db := newStartedDB(t, Config{Paths: []string{testDir}, MinimumFreeGB: 1, Logger: testLogger()}) + + ctx := context.Background() + originalKey, err := db.StoreData(ctx, []byte("root"), StoreOptions{MimeType: "text/plain; charset=utf-8"}) + if err != nil { + t.Fatalf("failed to store original message: %v", err) + } + + directChild, err := db.StoreData(ctx, []byte("reply"), StoreOptions{Parent: originalKey, MimeType: "text/plain; charset=utf-8"}) + if err != nil { + t.Fatalf("failed to store reply: %v", err) + } + + time.Sleep(2 * time.Millisecond) + editKey, err := db.StoreData(ctx, []byte("updated"), StoreOptions{EditOf: originalKey, MimeType: "text/plain; charset=utf-8"}) + if err != nil { + t.Fatalf("failed to store edit: %v", err) + } + + time.Sleep(2 * time.Millisecond) + replyToEdit, err := db.StoreData(ctx, []byte("follow-up"), StoreOptions{Parent: editKey, MimeType: "text/plain; charset=utf-8"}) + if err != nil { + t.Fatalf("failed to store reply to edit: %v", err) + } + + data, err := db.GetData(ctx, originalKey) + if err != nil { + t.Fatalf("GetData failed: %v", err) + } + + expectedChildren := map[string]struct{}{directChild.String(): {}, replyToEdit.String(): {}} + if len(data.Children) != len(expectedChildren) { + t.Fatalf("expected %d children, got %d", len(expectedChildren), len(data.Children)) + } + for _, child := range data.Children { + if _, ok := expectedChildren[child.String()]; !ok { + t.Fatalf("unexpected child %s in merged list", child.String()) + } + } +} + func TestOuroborosDB_CryptOperations(t *testing.T) { // A testDir := setupTestDir(t) t.Cleanup(func() { cleanupTestDir(t, testDir) }) diff --git a/pkg/apiServer/apiServer.go b/pkg/apiServer/apiServer.go index c6eafe8..a70bdba 100644 --- a/pkg/apiServer/apiServer.go +++ b/pkg/apiServer/apiServer.go @@ -88,7 +88,7 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { // AC // w.Header().Set("Access-Control-Allow-Credentials", "true") w.Header().Set( "Access-Control-Expose-Headers", - "Content-Type, Content-Length, X-Ouroboros-Key, X-Ouroboros-Mime, X-Ouroboros-Is-Text, X-Ouroboros-Parent, X-Ouroboros-Children, X-Ouroboros-Created-At, X-Ouroboros-Title", + "Content-Type, Content-Length, X-Ouroboros-Key, X-Ouroboros-Requested-Key, X-Ouroboros-Resolved-Key, X-Ouroboros-Edit-Of, X-Ouroboros-Mime, X-Ouroboros-Is-Text, X-Ouroboros-Parent, X-Ouroboros-Children, X-Ouroboros-Created-At, X-Ouroboros-Title", ) if r.Method == http.MethodOptions { diff --git a/pkg/apiServer/apiServer_test.go b/pkg/apiServer/apiServer_test.go index 21a80d7..b26114e 100644 --- a/pkg/apiServer/apiServer_test.go +++ b/pkg/apiServer/apiServer_test.go @@ -174,6 +174,74 @@ func TestGetBinaryData(t *testing.T) { // A } } +func TestGetReturnsLatestEdit(t *testing.T) { // A + db, cleanup := newTestDB(t) + t.Cleanup(cleanup) + + server := New(db, WithAuth(func(r *http.Request, db *ouroboros.OuroborosDB) error { return nil })) + + originalPayload := []byte("original message") + originalReq := newMultipartRequest( + t, + http.MethodPost, + "/data", + originalPayload, + "message.txt", + "text/plain; charset=utf-8", + map[string]any{"mime_type": "text/plain; charset=utf-8"}, + ) + originalRec := httptest.NewRecorder() + server.ServeHTTP(originalRec, originalReq) + if originalRec.Code != http.StatusCreated { + t.Fatalf("expected 201 for original message, got %d", originalRec.Code) + } + var createResp struct { + Key string `json:"key"` + } + decodeJSONResponse(t, originalRec, &createResp) + + time.Sleep(2 * time.Millisecond) + + editPayload := []byte("updated message") + editReq := newMultipartRequest( + t, + http.MethodPost, + "/data", + editPayload, + "message.txt", + "text/plain; charset=utf-8", + map[string]any{"mime_type": "text/plain; charset=utf-8", "edit_of": createResp.Key}, + ) + editRec := httptest.NewRecorder() + server.ServeHTTP(editRec, editReq) + if editRec.Code != http.StatusCreated { + t.Fatalf("expected 201 for edit message, got %d", editRec.Code) + } + var editResp struct { + Key string `json:"key"` + } + decodeJSONResponse(t, editRec, &editResp) + + getRec := httptest.NewRecorder() + server.ServeHTTP(getRec, httptest.NewRequest(http.MethodGet, "/data/"+createResp.Key, nil)) + if getRec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", getRec.Code) + } + + if body := getRec.Body.Bytes(); !bytes.Equal(body, editPayload) { + t.Fatalf("expected latest edit payload, got %q", string(body)) + } + if resolved := getRec.Header().Get("X-Ouroboros-Key"); resolved != editResp.Key { + t.Fatalf("expected X-Ouroboros-Key to reflect resolved edit key %s, got %s", editResp.Key, resolved) + } + if requested := getRec.Header().Get("X-Ouroboros-Requested-Key"); requested != createResp.Key { + t.Fatalf("expected X-Ouroboros-Requested-Key to be %s, got %s", createResp.Key, requested) + } + if editOf := getRec.Header().Get("X-Ouroboros-Edit-Of"); editOf != createResp.Key { + t.Fatalf("expected X-Ouroboros-Edit-Of to be %s, got %s", createResp.Key, editOf) + } +} + func TestCreateTextWithExplicitMIME(t *testing.T) { // A db, cleanup := newTestDB(t) t.Cleanup(cleanup) diff --git a/pkg/apiServer/bulk.go b/pkg/apiServer/bulk.go index 5edb8cb..66cfe9f 100644 --- a/pkg/apiServer/bulk.go +++ b/pkg/apiServer/bulk.go @@ -24,6 +24,8 @@ type bulkDataRequest struct { type bulkDataRecord struct { Key string `json:"key"` + ResolvedKey string `json:"resolvedKey,omitempty"` + EditOf string `json:"editOf,omitempty"` Found bool `json:"found"` MimeType string `json:"mimeType,omitempty"` IsText bool `json:"isText,omitempty"` @@ -125,6 +127,16 @@ func (s *Server) fetchBulkRecord(r *http.Request, keyHex string, includeBinary b SizeBytes: len(data.Content), Title: data.Title, } + resolvedKey := data.ResolvedKey + if resolvedKey.IsZero() { + resolvedKey = data.Key + } + if resolvedKey != data.Key { + record.ResolvedKey = resolvedKey.String() + } + if !data.EditOf.IsZero() { + record.EditOf = data.EditOf.String() + } if !data.CreatedAt.IsZero() { record.CreatedAt = data.CreatedAt.UTC().Format(time.RFC3339Nano) } diff --git a/pkg/apiServer/handler.go b/pkg/apiServer/handler.go index fc78658..90f4910 100644 --- a/pkg/apiServer/handler.go +++ b/pkg/apiServer/handler.go @@ -60,6 +60,12 @@ func (s *Server) handleCreate(w http.ResponseWriter, r *http.Request) { // PA return } + editOfHash, err := parseHash(meta.EditOf) + if err != nil { + http.Error(w, fmt.Sprintf("invalid edit_of hash: %v", err), http.StatusBadRequest) + return + } + childrenHashes := make([]cryptHash.Hash, 0, len(meta.Children)) for _, child := range meta.Children { h, err := parseHash(child) @@ -96,6 +102,7 @@ func (s *Server) handleCreate(w http.ResponseWriter, r *http.Request) { // PA ReedSolomonParityShards: parity, MimeType: mimeType, Title: strings.TrimSpace(meta.Title), + EditOf: editOfHash, }) if err != nil { status := http.StatusInternalServerError @@ -173,8 +180,19 @@ func (s *Server) handleGet(w http.ResponseWriter, r *http.Request) { // A } } + resolvedKey := data.ResolvedKey + if resolvedKey.IsZero() { + resolvedKey = key + } + w.Header().Set("Content-Type", mimeType) - w.Header().Set("X-Ouroboros-Key", keyHex) + w.Header().Set("X-Ouroboros-Key", resolvedKey.String()) + if resolvedKey != key { + w.Header().Set("X-Ouroboros-Requested-Key", key.String()) + } + if !data.EditOf.IsZero() { + w.Header().Set("X-Ouroboros-Edit-Of", data.EditOf.String()) + } w.Header().Set("X-Ouroboros-Is-Text", strconv.FormatBool(data.IsText)) w.Header().Set("X-Ouroboros-Mime", mimeType) if !data.CreatedAt.IsZero() { diff --git a/pkg/apiServer/meta.go b/pkg/apiServer/meta.go index e320db2..1232d16 100644 --- a/pkg/apiServer/meta.go +++ b/pkg/apiServer/meta.go @@ -20,26 +20,30 @@ const ( ) type threadSummary struct { - Key string `json:"key"` - Preview string `json:"preview"` - Title string `json:"title,omitempty"` - MimeType string `json:"mimeType"` - IsText bool `json:"isText"` - SizeBytes int `json:"sizeBytes"` - ChildCount int `json:"childCount"` - CreatedAt string `json:"createdAt,omitempty"` + Key string `json:"key"` + ResolvedKey string `json:"resolvedKey,omitempty"` + EditOf string `json:"editOf,omitempty"` + Preview string `json:"preview"` + Title string `json:"title,omitempty"` + MimeType string `json:"mimeType"` + IsText bool `json:"isText"` + SizeBytes int `json:"sizeBytes"` + ChildCount int `json:"childCount"` + CreatedAt string `json:"createdAt,omitempty"` } type threadNode struct { - Key string `json:"key"` - Parent string `json:"parent,omitempty"` - Title string `json:"title,omitempty"` - MimeType string `json:"mimeType"` - IsText bool `json:"isText"` - SizeBytes int `json:"sizeBytes"` - CreatedAt string `json:"createdAt,omitempty"` - Depth int `json:"depth"` - Children []string `json:"children"` + Key string `json:"key"` + ResolvedKey string `json:"resolvedKey,omitempty"` + EditOf string `json:"editOf,omitempty"` + Parent string `json:"parent,omitempty"` + Title string `json:"title,omitempty"` + MimeType string `json:"mimeType"` + IsText bool `json:"isText"` + SizeBytes int `json:"sizeBytes"` + CreatedAt string `json:"createdAt,omitempty"` + Depth int `json:"depth"` + Children []string `json:"children"` } func (s *Server) handleThreadSummaries(w http.ResponseWriter, r *http.Request) { @@ -112,6 +116,12 @@ func (s *Server) handleThreadSummaries(w http.ResponseWriter, r *http.Request) { SizeBytes: len(data.Content), ChildCount: len(data.Children), } + if data.ResolvedKey != data.Key { + summary.ResolvedKey = data.ResolvedKey.String() + } + if !data.EditOf.IsZero() { + summary.EditOf = data.EditOf.String() + } if !data.CreatedAt.IsZero() { summary.CreatedAt = data.CreatedAt.UTC().Format(time.RFC3339Nano) } @@ -204,6 +214,12 @@ func (s *Server) handleThreadNodeStream(w http.ResponseWriter, r *http.Request) SizeBytes: len(data.Content), Depth: item.depth, } + if data.ResolvedKey != data.Key { + node.ResolvedKey = data.ResolvedKey.String() + } + if !data.EditOf.IsZero() { + node.EditOf = data.EditOf.String() + } if !data.CreatedAt.IsZero() { node.CreatedAt = data.CreatedAt.UTC().Format(time.RFC3339Nano) } diff --git a/pkg/apiServer/types.go b/pkg/apiServer/types.go index 596b0ff..12a2ebc 100644 --- a/pkg/apiServer/types.go +++ b/pkg/apiServer/types.go @@ -32,6 +32,7 @@ type createMetadata struct { IsText *bool `json:"is_text"` Filename string `json:"filename"` Title string `json:"title,omitempty"` + EditOf string `json:"edit_of,omitempty"` } type AuthFunc func(req *http.Request, db *ouroboros.OuroborosDB) error diff --git a/pkg/meta/meta.go b/pkg/meta/meta.go index 17236bd..1d1090e 100644 --- a/pkg/meta/meta.go +++ b/pkg/meta/meta.go @@ -9,4 +9,5 @@ type Metadata struct { CreatedAt time.Time `json:"created_at"` Title string `json:"title,omitempty"` MimeType string `json:"mime_type,omitempty"` + EditOf string `json:"edit_of,omitempty"` }