Skip to content
Open
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
6 changes: 6 additions & 0 deletions frontend/src/lib/apiClient.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
export type ThreadSummaryPayload = {
key: string;
resolvedKey?: string;
editOf?: string;
preview: string;
title?: string;
mimeType: string;
Expand All @@ -11,6 +13,8 @@ export type ThreadSummaryPayload = {

export type ThreadNodePayload = {
key: string;
resolvedKey?: string;
editOf?: string;
parent?: string;
title?: string;
mimeType: string;
Expand All @@ -23,6 +27,8 @@ export type ThreadNodePayload = {

export type BulkDataRecord = {
key: string;
resolvedKey?: string;
editOf?: string;
found: boolean;
mimeType?: string;
isText?: boolean;
Expand Down
189 changes: 165 additions & 24 deletions ouroboros.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
Expand All @@ -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() {
Expand Down Expand Up @@ -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())
Expand All @@ -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
}

Expand All @@ -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
}
91 changes: 91 additions & 0 deletions ouroboros_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) })
Expand Down
2 changes: 1 addition & 1 deletion pkg/apiServer/apiServer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading