Files
vim-ale/internal/storage/fsstore/fsstore.go
AJ ONeal 631147901a feat: add Go release cache daemon (webicached)
Rewrites the Node.js release classification pipeline in Go. webicached
fetches upstream releases (GitHub, Gitea, GitLab, HashiCorp, custom
sources), classifies assets by OS/arch/variant, and writes legacy-format
JSON caches compatible with the existing webinstall.dev API.

Git-clone packages emit git_tag and git_commit_hash from real repo
clones — no fabricated refs.
2026-05-16 21:22:38 -06:00

208 lines
5.4 KiB
Go

// Package fsstore implements [storage.Store] on the local filesystem.
//
// Directory layout:
//
// {root}/
// {package}.json # asset list
// {package}.updated.txt # unix timestamp (seconds.millis)
//
// Write transactions build the new JSON in memory, then atomically
// rename into place so readers never see a partial file.
package fsstore
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"github.com/webinstall/webi-installers/internal/lexver"
"github.com/webinstall/webi-installers/internal/storage"
)
// Store is a filesystem-backed asset store.
type Store struct {
root string
}
// Root returns the store's root directory path.
func (s *Store) Root() string {
return s.root
}
// New creates a Store rooted at the given directory.
// The directory is created if it doesn't exist.
func New(root string) (*Store, error) {
if err := os.MkdirAll(root, 0o755); err != nil {
return nil, fmt.Errorf("fsstore: create root: %w", err)
}
return &Store{root: root}, nil
}
// ListPackages returns the names of all cached packages.
func (s *Store) ListPackages(_ context.Context) ([]string, error) {
dir := s.root
entries, err := os.ReadDir(dir)
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("fsstore: list packages: %w", err)
}
var pkgs []string
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".json") {
pkgs = append(pkgs, strings.TrimSuffix(e.Name(), ".json"))
}
}
return pkgs, nil
}
// Load reads a package's cached assets from disk.
// Returns nil (not an error) if the package is not cached.
func (s *Store) Load(_ context.Context, pkg string) (*storage.PackageData, error) {
jsonPath := filepath.Join(s.root, pkg+".json")
data, err := os.ReadFile(jsonPath)
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("fsstore: read %s: %w", pkg, err)
}
// Decode via legacy format (Node.js compat: "releases", "name", "ext").
var lc storage.LegacyCache
if err := json.Unmarshal(data, &lc); err != nil {
return nil, fmt.Errorf("fsstore: decode %s: %w", pkg, err)
}
pd := storage.ImportLegacy(lc)
// Read the timestamp file.
tsPath := filepath.Join(s.root, pkg+".updated.txt")
if tsData, err := os.ReadFile(tsPath); err == nil {
pd.UpdatedAt = parseTimestamp(strings.TrimSpace(string(tsData)))
}
return &pd, nil
}
// BeginRefresh starts a write transaction for a package.
func (s *Store) BeginRefresh(_ context.Context, pkg string) (storage.RefreshTx, error) {
return &refreshTx{
store: s,
pkg: pkg,
}, nil
}
type refreshTx struct {
store *Store
pkg string
assets []storage.Asset
}
func (tx *refreshTx) Put(assets []storage.Asset) error {
tx.assets = append(tx.assets, assets...)
return nil
}
func (tx *refreshTx) Commit(_ context.Context) error {
now := time.Now()
dir := tx.store.root
// Sort assets: stable/lts first, then beta, then rc, then alpha;
// within each channel, newest version first.
// The Node.js resolver picks the first matching entry, so stable[0] = latest stable
// must come before beta of a higher version number.
sort.SliceStable(tx.assets, func(i, j int) bool {
ri, rj := channelRank(tx.assets[i].Channel), channelRank(tx.assets[j].Channel)
if ri != rj {
return ri < rj
}
return lexver.Compare(lexver.Parse(tx.assets[i].Version), lexver.Parse(tx.assets[j].Version)) > 0
})
// Encode via legacy format (Node.js compat: "releases", "name", "ext").
// ExportLegacy applies per-package field backports and drops assets that
// can't be expressed in the legacy format (variants, unsupported formats).
lc, drops := storage.ExportLegacy(tx.pkg, storage.PackageData{Assets: tx.assets})
if drops.Variants > 0 || drops.Formats > 0 {
log.Printf(" %s: legacy export dropped %d variant assets, %d unsupported-format assets",
tx.pkg, drops.Variants, drops.Formats)
}
data, err := json.MarshalIndent(lc, "", " ")
if err != nil {
return fmt.Errorf("fsstore: encode %s: %w", tx.pkg, err)
}
// Write JSON atomically via temp file + rename.
jsonPath := filepath.Join(dir, tx.pkg+".json")
if err := atomicWrite(jsonPath, data); err != nil {
return err
}
// Write timestamp file.
tsPath := filepath.Join(dir, tx.pkg+".updated.txt")
ts := fmt.Sprintf("%.3f", float64(now.UnixMilli())/1000.0)
if err := atomicWrite(tsPath, []byte(ts)); err != nil {
return err
}
tx.assets = nil
return nil
}
func (tx *refreshTx) Rollback() error {
tx.assets = nil
return nil
}
// atomicWrite writes data to path via a temp file + rename.
func atomicWrite(path string, data []byte) error {
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return fmt.Errorf("fsstore: write tmp: %w", err)
}
if err := os.Rename(tmp, path); err != nil {
os.Remove(tmp)
return fmt.Errorf("fsstore: rename: %w", err)
}
return nil
}
// channelRank returns a sort key for release channels so stable sorts first.
// Lower rank = sorted earlier (stable/lts before beta/rc/alpha).
func channelRank(channel string) int {
switch channel {
case "", "stable", "lts":
return 0
case "rc":
return 1
case "beta":
return 2
case "alpha":
return 3
default:
return 4
}
}
// parseTimestamp parses the "seconds.millis" format from .updated.txt files.
func parseTimestamp(s string) time.Time {
f, err := strconv.ParseFloat(s, 64)
if err != nil || f == 0 {
return time.Time{}
}
sec := int64(f)
nsec := int64((f - float64(sec)) * 1e9)
return time.Unix(sec, nsec)
}