package service import ( "context" "fmt" "sync" "github.com/go-git/go-git/v5/plumbing" "sourcecraft.dev/bigbes/sr-ht-spec/core" "sourcecraft.dev/bigbes/sr-ht-spec/doc" "sourcecraft.dev/bigbes/sr-ht-spec/gitx" ) // archiveRenderer is shared across calls: doc.Renderer is documented as // reusable and concurrency-safe, and building one per archive would rebuild the // whole goldmark pipeline on every read. search/ shares one the same way. var archiveRenderer = sync.OnceValue(doc.NewRenderer) // Archive builds a space's addressable document set at a revision, together // with every document's bytes, from one tree walk. // // This is the read every surface above this layer needs: the archive owns the // design's addressing rule (a document's id when it is well-formed and unique // in the space, otherwise its path), resolves the [[wikilinks]] between // documents, and carries the link graph backlinks are read from. Before it // existed, web/ scanned the git tree itself — reaching past this layer into // gitx, which the layering rule forbids — and mcpsrv/ converted this package's // documents back into git ones. Two workarounds around one missing accessor is // how three agent-facing surfaces stop being behaviourally identical. // // The revision is resolved first and the documents are read at the resolved // sha, so the archive and the bodies are the same revision by construction: a // merge landing mid-read cannot make one answer describe two revisions. The // resolved sha is the archive's Rev, and is what a caller hands back as the // pinned ?rev=. // // bodies is keyed by tree path — the key doc.Page.Path carries — and holds the // whole document, frontmatter included. func (s *Service) Archive(ctx context.Context, sp *Space, rev string) (*doc.Archive, map[string][]byte, error) { commit, resolved, err := s.resolveRev(ctx, sp, rev) if err != nil { return nil, nil, err } docs, err := sp.Repo.ListDocuments(ctx, resolved) if err != nil { return nil, nil, readErr(err, "list documents at %s in %s", resolved, sp.Ref) } return archiveOf(sp.Ref, commit.String(), docs) } // ArchiveFrom builds the same archive out of documents this package already // returned, without touching git. // // It is the seam for a caller that holds a [Document] set — a test double // standing in for this service, a future surface that has already listed a // revision — and it exists so that the one conversion from a hex object name // back to a git hash lives here rather than in each of them. A malformed sha is // an error, not a zero hash: plumbing.NewHash silently yields the zero value // for anything it cannot parse, and a document whose render-cache key is zero // is a cache collision waiting to happen. // // rev must be the resolved commit the documents were read at, for the same // reason [Service.Archive] resolves before it reads. func ArchiveFrom(sp core.SpaceRef, rev string, docs []Document) (*doc.Archive, map[string][]byte, error) { converted := make([]gitx.Document, 0, len(docs)) for _, d := range docs { if !plumbing.IsHash(d.Blob) { return nil, nil, fmt.Errorf("service: document %q carries a malformed blob id %q", d.Path, d.Blob) } converted = append(converted, gitx.Document{Path: d.Path, Blob: plumbing.NewHash(d.Blob), Data: d.Data}) } return archiveOf(sp, rev, converted) } // archiveOf assembles the archive, the bodies and the link graph out of one // revision's documents. // // The link pass runs here rather than at each caller. doc.Page.Links is what // Archive.Backlinks reads, and an archive handed out without it looks like a // space where nothing links to anything — which is what every surface saw // until web/ grew its own pass and re-rendered the revision on each page view. // Filling it once, in the accessor, is the difference between a link graph and // a per-surface convention. It costs one render of each document; at the // confirmed volume — tens of documents a day — that is the same walk's data // being parsed once more, not a second read. func archiveOf(sp core.SpaceRef, rev string, docs []gitx.Document) (*doc.Archive, map[string][]byte, error) { arc := doc.FromDocuments(sp, rev, docs) bodies := make(map[string][]byte, len(docs)) for _, d := range docs { bodies[d.Path] = d.Data } if err := arc.LinkPass(archiveRenderer(), bodies); err != nil { return nil, nil, fmt.Errorf("service: archive of %s at %s: %w", sp, rev, err) } return arc, bodies, nil }