clone

an offline map explorer where each world, from a game map to a city's open data, ships as one .atlas file

main
atlas / format / bundle / registry.go
registry.go 7.6K
1package bundle23import (4 "encoding/json"5 "fmt"6 "sort"7)89// The registry model: a library of immutable .atlas files, scanned and10// folded. New builds land beside old ones and nothing ever mutates a11// published bundle, so the question a registry answers -- which build of each12// volume serves? -- is a fold over descriptors, not a state machine.13//14// The fold in this file is pure: it touches no filesystem, opens no file, and15// logs nothing. Walking a directory to produce descriptors lives in scan.go,16// so a host that keeps its library somewhere other than a directory can17// supply descriptors from wherever it likes and get the same answer.1819// Descriptor is one candidate build as the fold sees it: identity, version,20// and a locator. It carries no open file, which is what lets the fold be a21// function.22//23// Locator is opaque to the fold except as the last tie-break. A filesystem24// registry puts a path there; another host may put a key, a URL, anything25// that orders stably.26type Descriptor struct {27 Locator string28 Slug string29 Title string30 Stamp string31 CreatedAt string32 Revision int33 Size int6434 Worlds int35}3637// DescriptorOf reads a descriptor off a manifest. Size is the bundle's size38// in bytes where the host knows it, and zero where it does not; it is39// reported by the index and never consulted by the fold.40func DescriptorOf(locator string, m Manifest, size int64) Descriptor {41 return Descriptor{42 Locator: locator,43 Slug: m.Volume.Slug,44 Title: m.Volume.Title,45 Stamp: m.Version.Stamp,46 CreatedAt: m.Version.CreatedAt,47 Revision: m.Version.Revision,48 Size: size,49 Worlds: len(m.Worlds),50 }51}5253// Newer reports whether a should shadow b when both claim the same volume.54//55// The build ordering, in full: creation time decides, because a newer capture56// is newer data; among builds of one capture the newer policy revision57// decides, because the data has not moved but what a producer makes of it58// has; the stamp and then the locator break the remaining ties, so the choice59// is total and two folds of the same library always agree.60//61// Creation time and stamp compare as strings. Both are written in forms where62// lexical order is the order that means something -- RFC 3339 for the time,63// hex for the stamp -- so no parsing is needed and a malformed value cannot64// make the ordering partial.65func Newer(a, b Descriptor) bool {66 if a.CreatedAt != b.CreatedAt {67 return a.CreatedAt > b.CreatedAt68 }69 if a.Revision != b.Revision {70 return a.Revision > b.Revision71 }72 if a.Stamp != b.Stamp {73 return a.Stamp > b.Stamp74 }75 return a.Locator > b.Locator76}7778// Fold picks the winning build of every volume: newest per slug under79// [Newer]. It is pure and order-independent -- the same descriptors in any80// arrangement give the same winners -- and it never mutates its input.81func Fold(candidates []Descriptor) map[string]Descriptor {82 winners := make(map[string]Descriptor, len(candidates))83 for _, candidate := range candidates {84 if standing, held := winners[candidate.Slug]; held && !Newer(candidate, standing) {85 continue86 }87 winners[candidate.Slug] = candidate88 }89 return winners90}9192// Shadowed lists the builds a fold did not pick, in the fold's own order:93// slug, then newest first. It is what a registry reports when it wants to say94// what else is in the library.95func Shadowed(candidates []Descriptor) []Descriptor {96 winners := Fold(candidates)97 var out []Descriptor98 for _, candidate := range candidates {99 if winner, held := winners[candidate.Slug]; held && winner.Locator == candidate.Locator {100 continue101 }102 out = append(out, candidate)103 }104 sortBuilds(out)105 return out106}107108// Changed names the volumes whose serving build moved between two folds:109// arrivals, departures, and winners whose stamp changed. The result is110// sorted, so a caller can compare two rescans without minding map order.111func Changed(before, after map[string]Descriptor) []string {112 var changed []string113 for slug, winner := range after {114 if standing, had := before[slug]; !had || standing.Stamp != winner.Stamp {115 changed = append(changed, slug)116 }117 }118 for slug := range before {119 if _, still := after[slug]; !still {120 changed = append(changed, slug)121 }122 }123 sort.Strings(changed)124 return changed125}126127// Index is the registry listing: every volume, every build of it, newest128// first. It is always derived from the files it sits with, never the other129// way around, so a hand-copied or hand-deleted bundle is reflected by the next130// scan rather than contradicted by a stale listing.131//132// The JSON field names are the format's history showing through: the listing133// predates the volume/world vocabulary and its wire keys were never renamed,134// because nothing reads the index that would benefit and every tool that135// writes one would have to agree at once. See docs/format.md.136type Index struct {137 Volumes []IndexVolume `json:"games"`138}139140// IndexVolume is one volume's whole build history as the index lists it.141type IndexVolume struct {142 Slug string `json:"slug"`143 Title string `json:"title"`144 Versions []IndexBuild `json:"versions"`145}146147// IndexBuild is one build of one volume as the index lists it.148type IndexBuild struct {149 File string `json:"file"`150 Stamp string `json:"stamp"`151 CreatedAt string `json:"createdAt"`152 Revision int `json:"revision,omitempty"`153 Size int64 `json:"size"`154 Worlds int `json:"maps"`155}156157// BuildIndex folds descriptors into the derived listing. It is pure: the158// caller supplies the descriptors and decides where, if anywhere, the result159// is written.160//161// Volumes sort by title, builds within a volume by the build ordering, and162// the title shown for a volume is the newest build's -- a volume that was163// renamed is called what it is called now.164//165// File names each build by the base of its locator, which for a filesystem166// registry is the file name. A host whose locators are not paths should167// expect whatever its locators look like.168func BuildIndex(candidates []Descriptor) Index {169 bySlug := make(map[string][]Descriptor)170 for _, candidate := range candidates {171 bySlug[candidate.Slug] = append(bySlug[candidate.Slug], candidate)172 }173174 listed := make([]IndexVolume, 0, len(bySlug))175 for slug, builds := range bySlug {176 sortBuilds(builds)177 volume := IndexVolume{Slug: slug, Title: builds[0].Title, Versions: make([]IndexBuild, 0, len(builds))}178 for _, build := range builds {179 volume.Versions = append(volume.Versions, IndexBuild{180 File: baseName(build.Locator),181 Stamp: build.Stamp,182 CreatedAt: build.CreatedAt,183 Revision: build.Revision,184 Size: build.Size,185 Worlds: build.Worlds,186 })187 }188 listed = append(listed, volume)189 }190 sort.Slice(listed, func(i, j int) bool { return listed[i].Title < listed[j].Title })191 return Index{Volumes: listed}192}193194// MarshalIndex encodes an index as the registry directory carries it: one195// line, newline-terminated.196func MarshalIndex(index Index) ([]byte, error) {197 data, err := json.Marshal(index)198 if err != nil {199 return nil, fmt.Errorf("marshal index: %w", err)200 }201 return append(data, '\n'), nil202}203204// sortBuilds puts the newest build first under the build ordering.205func sortBuilds(builds []Descriptor) {206 sort.Slice(builds, func(i, j int) bool { return Newer(builds[i], builds[j]) })207}208209// baseName is the last slash-separated segment of a locator, which for a path210// is its file name. It is spelled here rather than taken from path/filepath so211// that a locator is treated as an opaque string on every platform.212func baseName(locator string) string {213 for at := len(locator) - 1; at >= 0; at-- {214 if locator[at] == '/' || locator[at] == '\\' {215 return locator[at+1:]216 }217 }218 return locator219}