635 lines
19 KiB
Go
635 lines
19 KiB
Go
// Package storage implements the flat-file persistence engine behind go-doc.
|
||
//
|
||
// Documents live on disk as raw Markdown files at data/docs/<slug>.md and
|
||
// audience feedback is stored as JSON arrays at data/comments/<slug>-comments.json.
|
||
//
|
||
// Every mutation is written with a temp-file + fsync + rename sequence so a
|
||
// concurrent reader can never observe a half-written file, and read-modify-write
|
||
// cycles on the same document are serialised by a keyed mutex. That combination
|
||
// is what lets the engine stay correct while the seed script floods it with
|
||
// thousands of concurrent comments.
|
||
package storage
|
||
|
||
import (
|
||
"errors"
|
||
"fmt"
|
||
"io/fs"
|
||
"os"
|
||
"path/filepath"
|
||
"regexp"
|
||
"sort"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
"unicode"
|
||
"unicode/utf8"
|
||
)
|
||
|
||
const (
|
||
// DocExt is the file extension used for every Markdown document.
|
||
DocExt = ".md"
|
||
|
||
// maxSlugLen caps generated slugs so a pathological title cannot produce an
|
||
// unusable filename.
|
||
maxSlugLen = 120
|
||
|
||
// maxTitleScanBytes limits how much of a document is scanned when extracting
|
||
// its title from the leading heading.
|
||
maxTitleScanBytes = 64 << 10
|
||
|
||
docsDirName = "docs"
|
||
commentsDirName = "comments"
|
||
)
|
||
|
||
// Errors returned by the engine. Callers map these onto HTTP status codes.
|
||
var (
|
||
// ErrNotFound is returned when a document or comment does not exist.
|
||
ErrNotFound = errors.New("storage: not found")
|
||
// ErrInvalidSlug is returned for slugs that cannot be safely slugified.
|
||
ErrInvalidSlug = errors.New("storage: invalid slug")
|
||
// ErrInvalidRole is returned when a comment carries an unknown role.
|
||
ErrInvalidRole = errors.New("storage: invalid role")
|
||
// ErrInvalidComment is returned when a comment payload is unusable.
|
||
ErrInvalidComment = errors.New("storage: invalid comment")
|
||
)
|
||
|
||
// Engine owns a data directory and all flat-file operations against it.
|
||
// Engine is safe for concurrent use by multiple goroutines.
|
||
type Engine struct {
|
||
root string
|
||
docsDir string
|
||
commentsDir string
|
||
|
||
locksMu sync.Mutex
|
||
locks map[string]*sync.Mutex
|
||
|
||
// Now is injectable so tests can assert deterministic timestamps.
|
||
Now func() time.Time
|
||
}
|
||
|
||
// New returns an Engine rooted at root and creates docs/ and comments/ beneath
|
||
// it when they are missing.
|
||
func New(root string) (*Engine, error) {
|
||
if strings.TrimSpace(root) == "" {
|
||
return nil, fmt.Errorf("storage: empty data root")
|
||
}
|
||
abs, err := filepath.Abs(root)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("storage: resolve data root: %w", err)
|
||
}
|
||
e := &Engine{
|
||
root: abs,
|
||
docsDir: filepath.Join(abs, docsDirName),
|
||
commentsDir: filepath.Join(abs, commentsDirName),
|
||
locks: make(map[string]*sync.Mutex),
|
||
Now: time.Now,
|
||
}
|
||
if err := os.MkdirAll(e.docsDir, 0o755); err != nil {
|
||
return nil, fmt.Errorf("storage: create docs dir: %w", err)
|
||
}
|
||
if err := os.MkdirAll(e.commentsDir, 0o755); err != nil {
|
||
return nil, fmt.Errorf("storage: create comments dir: %w", err)
|
||
}
|
||
return e, nil
|
||
}
|
||
|
||
// Root returns the absolute data directory backing the engine.
|
||
func (e *Engine) Root() string { return e.root }
|
||
|
||
// DocsDir returns the absolute directory holding Markdown documents.
|
||
func (e *Engine) DocsDir() string { return e.docsDir }
|
||
|
||
// CommentsDir returns the absolute directory holding comment JSON files.
|
||
func (e *Engine) CommentsDir() string { return e.commentsDir }
|
||
|
||
func (e *Engine) now() time.Time {
|
||
if e.Now != nil {
|
||
return e.Now()
|
||
}
|
||
return time.Now()
|
||
}
|
||
|
||
// lockFor returns the mutex guarding a single slug. RLock-free by design: the
|
||
// critical sections are short, and per-slug granularity is what allows writes to
|
||
// different documents to proceed fully in parallel.
|
||
func (e *Engine) lockFor(slug string) *sync.Mutex {
|
||
e.locksMu.Lock()
|
||
defer e.locksMu.Unlock()
|
||
m, ok := e.locks[slug]
|
||
if !ok {
|
||
m = &sync.Mutex{}
|
||
e.locks[slug] = m
|
||
}
|
||
return m
|
||
}
|
||
|
||
// ResolveRoot discovers the data directory without any configuration. It honours
|
||
// GO_DOC_DATA_DIR first, then walks up from the working directory looking for an
|
||
// existing data/ folder, and finally falls back to a relative "data".
|
||
func ResolveRoot() string {
|
||
if v := strings.TrimSpace(os.Getenv("GO_DOC_DATA_DIR")); v != "" {
|
||
return v
|
||
}
|
||
dir, err := os.Getwd()
|
||
if err == nil {
|
||
for i := 0; i < 8; i++ {
|
||
candidate := filepath.Join(dir, "data")
|
||
if info, err := os.Stat(filepath.Join(candidate, docsDirName)); err == nil && info.IsDir() {
|
||
return candidate
|
||
}
|
||
parent := filepath.Dir(dir)
|
||
if parent == dir {
|
||
break
|
||
}
|
||
dir = parent
|
||
}
|
||
}
|
||
return "data"
|
||
}
|
||
|
||
var (
|
||
defaultOnce sync.Once
|
||
defaultEng *Engine
|
||
defaultErr error
|
||
)
|
||
|
||
// Default returns the process-wide engine rooted at ResolveRoot().
|
||
func Default() (*Engine, error) {
|
||
defaultOnce.Do(func() {
|
||
defaultEng, defaultErr = New(ResolveRoot())
|
||
})
|
||
return defaultEng, defaultErr
|
||
}
|
||
|
||
// ResetDefault drops the cached engine so the next Default call re-resolves the
|
||
// root. Tests use it between suites that point at different temp directories.
|
||
func ResetDefault() {
|
||
defaultOnce = sync.Once{}
|
||
defaultEng = nil
|
||
defaultErr = nil
|
||
}
|
||
|
||
// SetDefault installs root as the process-wide engine.
|
||
func SetDefault(root string) (*Engine, error) {
|
||
e, err := New(root)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
ResetDefault()
|
||
defaultOnce.Do(func() {})
|
||
defaultEng, defaultErr = e, nil
|
||
return e, nil
|
||
}
|
||
|
||
// latinFold maps lower-case Latin letters carrying diacritics, plus common
|
||
// ligatures, onto their ASCII skeleton. Folding happens after ToLower, so the
|
||
// keys are always lower case.
|
||
var latinFold = map[rune]rune{
|
||
'á': 'a', 'à': 'a', 'â': 'a', 'ä': 'a', 'ã': 'a', 'å': 'a', 'ā': 'a', 'ă': 'a', 'ą': 'a',
|
||
'ç': 'c', 'ć': 'c', 'č': 'c', 'ĉ': 'c', 'ċ': 'c',
|
||
'ď': 'd', 'đ': 'd',
|
||
'é': 'e', 'è': 'e', 'ê': 'e', 'ë': 'e', 'ē': 'e', 'ė': 'e', 'ę': 'e', 'ě': 'e',
|
||
'ğ': 'g', 'ĝ': 'g', 'ġ': 'g', 'ģ': 'g',
|
||
'í': 'i', 'ì': 'i', 'î': 'i', 'ï': 'i', 'ī': 'i', 'į': 'i', 'ı': 'i',
|
||
'ĵ': 'j',
|
||
'ķ': 'k',
|
||
'ł': 'l', 'ľ': 'l', 'ĺ': 'l',
|
||
'ñ': 'n', 'ń': 'n', 'ň': 'n',
|
||
'ó': 'o', 'ò': 'o', 'ô': 'o', 'ö': 'o', 'õ': 'o', 'ø': 'o', 'ō': 'o', 'ő': 'o',
|
||
'ŕ': 'r', 'ř': 'r',
|
||
'ś': 's', 'š': 's', 'ş': 's', 'ș': 's',
|
||
'ß': 's', 'þ': 't', 'ť': 't',
|
||
'ú': 'u', 'ù': 'u', 'û': 'u', 'ü': 'u', 'ū': 'u', 'ů': 'u', 'ű': 'u',
|
||
'ŵ': 'w', 'ý': 'y', 'ÿ': 'y', 'ŷ': 'y',
|
||
'ź': 'z', 'ż': 'z', 'ž': 'z',
|
||
}
|
||
|
||
// Slugify converts an arbitrary human title into a lower-case, hyphen-separated
|
||
// slug: "Core Service API" becomes "core-service-api". Diacritics are folded to
|
||
// ASCII, every other separator run collapses into a single hyphen, and the result
|
||
// is trimmed and length-capped.
|
||
func Slugify(title string) string {
|
||
var b strings.Builder
|
||
b.Grow(len(title))
|
||
|
||
pendingSep := true // suppresses a leading hyphen
|
||
for _, r := range strings.ToLower(title) {
|
||
if folded, ok := latinFold[r]; ok {
|
||
r = folded
|
||
}
|
||
switch {
|
||
case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'):
|
||
b.WriteRune(r)
|
||
pendingSep = false
|
||
default:
|
||
// Everything else -- punctuation, whitespace, non-ASCII letters that
|
||
// carry no ASCII skeleton, and invalid UTF-8 -- collapses into a single
|
||
// separator so slugs stay safe in URLs and filenames everywhere.
|
||
if !pendingSep {
|
||
b.WriteByte('-')
|
||
pendingSep = true
|
||
}
|
||
}
|
||
}
|
||
|
||
slug := strings.Trim(b.String(), "-")
|
||
if utf8.RuneCountInString(slug) > maxSlugLen {
|
||
truncated := slug[:maxSlugLen]
|
||
for len(truncated) > 0 && truncated[len(truncated)-1] == '-' {
|
||
truncated = truncated[:len(truncated)-1]
|
||
}
|
||
slug = truncated
|
||
}
|
||
return slug
|
||
}
|
||
|
||
// NormalizeSlug validates and canonicalises a slug coming from a URL path.
|
||
// Path separators, traversal segments and control characters are rejected
|
||
// outright with ErrInvalidSlug; anything else is slugified so that
|
||
// "Core Service API" and "core-service-api" address the same document.
|
||
func NormalizeSlug(slug string) (string, error) {
|
||
trimmed := strings.TrimSpace(slug)
|
||
if trimmed == "" {
|
||
return "", fmt.Errorf("%w: slug is empty", ErrInvalidSlug)
|
||
}
|
||
if len(trimmed) > 2*maxSlugLen {
|
||
return "", fmt.Errorf("%w: slug exceeds %d bytes", ErrInvalidSlug, 2*maxSlugLen)
|
||
}
|
||
if strings.ContainsAny(trimmed, "/\\\x00") || strings.Contains(trimmed, "..") {
|
||
return "", fmt.Errorf("%w: slug %q contains path separators", ErrInvalidSlug, slug)
|
||
}
|
||
for _, r := range trimmed {
|
||
if unicode.IsControl(r) {
|
||
return "", fmt.Errorf("%w: slug %q contains control characters", ErrInvalidSlug, slug)
|
||
}
|
||
}
|
||
|
||
normalized := Slugify(trimmed)
|
||
if normalized == "" {
|
||
return "", fmt.Errorf("%w: slug %q has no usable characters", ErrInvalidSlug, slug)
|
||
}
|
||
return normalized, nil
|
||
}
|
||
|
||
var strictSlug = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
|
||
|
||
// IsCanonicalSlug reports whether slug is already in the canonical form produced
|
||
// by Slugify. Handlers use it to decide whether a rename touched the filesystem.
|
||
func IsCanonicalSlug(slug string) bool { return strictSlug.MatchString(slug) }
|
||
|
||
func (e *Engine) docPath(slug string) (string, error) {
|
||
normalized, err := NormalizeSlug(slug)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return filepath.Join(e.docsDir, normalized+DocExt), nil
|
||
}
|
||
|
||
// SaveDoc writes content to data/docs/<slug>.md, creating the document when it
|
||
// does not exist and replacing it wholesale otherwise. The write is atomic and
|
||
// therefore safe to call concurrently with readers.
|
||
func (e *Engine) SaveDoc(slug, content string) (string, error) {
|
||
normalized, err := NormalizeSlug(slug)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
path := filepath.Join(e.docsDir, normalized+DocExt)
|
||
|
||
mu := e.lockFor(normalized)
|
||
mu.Lock()
|
||
defer mu.Unlock()
|
||
|
||
normalizedContent := NormalizeMarkdown(content)
|
||
if err := writeFileAtomic(path, []byte(normalizedContent), 0o644); err != nil {
|
||
return "", err
|
||
}
|
||
return normalized, nil
|
||
}
|
||
|
||
// NormalizeMarkdown converts CRLF/CR line endings to LF, guarantees a single
|
||
// trailing newline, and leaves the author's content otherwise untouched.
|
||
func NormalizeMarkdown(content string) string {
|
||
if content == "" {
|
||
return ""
|
||
}
|
||
normalized := strings.ReplaceAll(content, "\r\n", "\n")
|
||
normalized = strings.ReplaceAll(normalized, "\r", "\n")
|
||
normalized = strings.TrimRight(normalized, "\n") + "\n"
|
||
return normalized
|
||
}
|
||
|
||
// ReadDoc returns the raw Markdown stored at data/docs/<slug>.doc.
|
||
// A missing document yields ErrNotFound.
|
||
func (e *Engine) ReadDoc(slug string) (string, error) {
|
||
path, err := e.docPath(slug)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
data, err := os.ReadFile(path)
|
||
if err != nil {
|
||
if errors.Is(err, fs.ErrNotExist) {
|
||
return "", fmt.Errorf("%w: doc %q", ErrNotFound, slug)
|
||
}
|
||
return "", fmt.Errorf("storage: read doc %q: %w", slug, err)
|
||
}
|
||
return string(data), nil
|
||
}
|
||
|
||
// DeleteDoc permanently removes data/docs/<slug>.md together with the document's
|
||
// comment file. Removing a document that does not exist yields ErrNotFound.
|
||
func (e *Engine) DeleteDoc(slug string) error {
|
||
normalized, err := NormalizeSlug(slug)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
path := filepath.Join(e.docsDir, normalized+DocExt)
|
||
|
||
mu := e.lockFor(normalized)
|
||
mu.Lock()
|
||
defer mu.Unlock()
|
||
|
||
if err := os.Remove(path); err != nil {
|
||
if errors.Is(err, fs.ErrNotExist) {
|
||
return fmt.Errorf("%w: doc %q", ErrNotFound, slug)
|
||
}
|
||
return fmt.Errorf("storage: delete doc %q: %w", slug, err)
|
||
}
|
||
|
||
// Cascade the comment file so a recreated slug does not inherit stale history.
|
||
commentPath := e.commentPath(normalized)
|
||
if err := os.Remove(commentPath); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||
return fmt.Errorf("storage: delete comments for %q: %w", slug, err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// DocMeta is the index entry describing one stored document.
|
||
type DocMeta struct {
|
||
Slug string `json:"slug"`
|
||
Title string `json:"title"`
|
||
Size int64 `json:"size"`
|
||
Comments int `json:"comments"`
|
||
Modified time.Time `json:"modified"`
|
||
}
|
||
|
||
var headingPattern = regexp.MustCompile(`(?m)^#{1,6}[ \t]+(.+?)[ \t]*#*[ \t]*$`)
|
||
|
||
// ExtractTitle returns the first Markdown heading in content, falling back to a
|
||
// title-cased rendering of the slug.
|
||
func ExtractTitle(content, slug string) string {
|
||
if len(content) > maxTitleScanBytes {
|
||
content = content[:maxTitleScanBytes]
|
||
}
|
||
if m := headingPattern.FindStringSubmatch(content); m != nil {
|
||
if title := strings.TrimSpace(m[1]); title != "" {
|
||
return title
|
||
}
|
||
}
|
||
return TitleizeSlug(slug)
|
||
}
|
||
|
||
// TitleizeSlug renders a slug as a human title: "core-service-api" becomes
|
||
// "Core Service API".
|
||
func TitleizeSlug(slug string) string {
|
||
words := strings.FieldsFunc(slug, func(r rune) bool { return r == '-' || r == '_' })
|
||
for i, w := range words {
|
||
if w == "" {
|
||
continue
|
||
}
|
||
runes := []rune(strings.ToLower(w))
|
||
runes[0] = unicode.ToUpper(runes[0])
|
||
words[i] = string(runes)
|
||
}
|
||
if len(words) == 0 {
|
||
return slug
|
||
}
|
||
return strings.Join(words, " ")
|
||
}
|
||
|
||
// DocExists reports whether a document is present on disk.
|
||
func (e *Engine) DocExists(slug string) (bool, error) {
|
||
path, err := e.docPath(slug)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
info, err := os.Stat(path)
|
||
if err != nil {
|
||
if errors.Is(err, fs.ErrNotExist) {
|
||
return false, nil
|
||
}
|
||
return false, fmt.Errorf("storage: stat doc %q: %w", slug, err)
|
||
}
|
||
return !info.IsDir(), nil
|
||
}
|
||
|
||
// Stat returns index metadata for a single document.
|
||
func (e *Engine) Stat(slug string) (DocMeta, error) {
|
||
normalized, err := NormalizeSlug(slug)
|
||
if err != nil {
|
||
return DocMeta{}, err
|
||
}
|
||
path := filepath.Join(e.docsDir, normalized+DocExt)
|
||
info, err := os.Stat(path)
|
||
if err != nil {
|
||
if errors.Is(err, fs.ErrNotExist) {
|
||
return DocMeta{}, fmt.Errorf("%w: doc %q", ErrNotFound, slug)
|
||
}
|
||
return DocMeta{}, fmt.Errorf("storage: stat doc %q: %w", slug, err)
|
||
}
|
||
|
||
meta := DocMeta{Slug: normalized, Size: info.Size(), Modified: info.ModTime().UTC()}
|
||
if head, err := os.ReadFile(path); err == nil {
|
||
meta.Title = ExtractTitle(string(head), normalized)
|
||
} else {
|
||
meta.Title = TitleizeSlug(normalized)
|
||
}
|
||
if comments, err := e.ListComments(normalized); err == nil {
|
||
meta.Comments = len(comments)
|
||
}
|
||
return meta, nil
|
||
}
|
||
|
||
// ListDocs indexes every Markdown document on disk, newest modification first.
|
||
// It reads only the head of each file to recover the document title, so listing
|
||
// hundreds of documents stays cheap; use ListDocsDetailed when comment counts are
|
||
// needed as well.
|
||
func (e *Engine) ListDocs() ([]DocMeta, error) { return e.listDocs(false) }
|
||
|
||
// ListDocsDetailed is ListDocs with per-document comment counts filled in.
|
||
func (e *Engine) ListDocsDetailed() ([]DocMeta, error) { return e.listDocs(true) }
|
||
|
||
func (e *Engine) listDocs(withComments bool) ([]DocMeta, error) {
|
||
entries, err := os.ReadDir(e.docsDir)
|
||
if err != nil {
|
||
if errors.Is(err, fs.ErrNotExist) {
|
||
return []DocMeta{}, nil
|
||
}
|
||
return nil, fmt.Errorf("storage: list docs: %w", err)
|
||
}
|
||
|
||
metas := make([]DocMeta, 0, len(entries))
|
||
for _, entry := range entries {
|
||
if entry.IsDir() {
|
||
continue
|
||
}
|
||
name := entry.Name()
|
||
if !strings.HasSuffix(name, DocExt) || strings.HasPrefix(name, ".") {
|
||
continue
|
||
}
|
||
slug := strings.TrimSuffix(name, DocExt)
|
||
if !IsCanonicalSlug(slug) {
|
||
continue
|
||
}
|
||
info, err := entry.Info()
|
||
if err != nil {
|
||
continue
|
||
}
|
||
meta := DocMeta{
|
||
Slug: slug,
|
||
Title: e.readTitle(filepath.Join(e.docsDir, name), slug),
|
||
Size: info.Size(),
|
||
Modified: info.ModTime().UTC(),
|
||
}
|
||
if withComments {
|
||
if comments, err := e.ListComments(slug); err == nil {
|
||
meta.Comments = len(comments)
|
||
}
|
||
}
|
||
metas = append(metas, meta)
|
||
}
|
||
|
||
sort.Slice(metas, func(i, j int) bool {
|
||
if metas[i].Modified.Equal(metas[j].Modified) {
|
||
return metas[i].Slug < metas[j].Slug
|
||
}
|
||
return metas[i].Modified.After(metas[j].Modified)
|
||
})
|
||
return metas, nil
|
||
}
|
||
|
||
// Search returns documents whose slug or extracted title contains query,
|
||
// case-insensitively. An empty query lists everything.
|
||
func (e *Engine) Search(query string) ([]DocMeta, error) {
|
||
metas, err := e.ListDocs()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
needle := strings.ToLower(strings.TrimSpace(query))
|
||
if needle == "" {
|
||
return metas, nil
|
||
}
|
||
matches := make([]DocMeta, 0, len(metas))
|
||
for _, meta := range metas {
|
||
if strings.Contains(strings.ToLower(meta.Slug), needle) ||
|
||
strings.Contains(strings.ToLower(meta.Title), needle) {
|
||
matches = append(matches, meta)
|
||
}
|
||
}
|
||
return matches, nil
|
||
}
|
||
|
||
// readTitle recovers a document title from the head of the file, reading at most
|
||
// maxTitleScanBytes so a huge document cannot slow the index down. Any failure
|
||
// degrades to a title derived from the slug.
|
||
func (e *Engine) readTitle(path, slug string) string {
|
||
file, err := os.Open(path)
|
||
if err != nil {
|
||
return TitleizeSlug(slug)
|
||
}
|
||
defer file.Close()
|
||
|
||
head := make([]byte, maxTitleScanBytes)
|
||
n, err := file.Read(head)
|
||
if n <= 0 || (err != nil && n == 0) {
|
||
return TitleizeSlug(slug)
|
||
}
|
||
return ExtractTitle(string(head[:n]), slug)
|
||
}
|
||
|
||
// writeFileAtomic writes data to path via a temporary file in the same directory,
|
||
// fsyncs it, renames it into place, and fsyncs the parent directory so the
|
||
// rename survives a crash. Readers therefore see either the old or the new file.
|
||
func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
|
||
dir := filepath.Dir(path)
|
||
tmp, err := os.CreateTemp(dir, ".godoc-tmp-*")
|
||
if err != nil {
|
||
return fmt.Errorf("storage: create temp file: %w", err)
|
||
}
|
||
tmpName := tmp.Name()
|
||
|
||
cleanup := func() {
|
||
tmp.Close()
|
||
os.Remove(tmpName)
|
||
}
|
||
|
||
if _, err := tmp.Write(data); err != nil {
|
||
cleanup()
|
||
return fmt.Errorf("storage: write temp file: %w", err)
|
||
}
|
||
if err := tmp.Sync(); err != nil {
|
||
cleanup()
|
||
return fmt.Errorf("storage: sync temp file: %w", err)
|
||
}
|
||
if err := tmp.Close(); err != nil {
|
||
os.Remove(tmpName)
|
||
return fmt.Errorf("storage: close temp file: %w", err)
|
||
}
|
||
if err := os.Chmod(tmpName, perm); err != nil {
|
||
os.Remove(tmpName)
|
||
return fmt.Errorf("storage: chmod temp file: %w", err)
|
||
}
|
||
if err := os.Rename(tmpName, path); err != nil {
|
||
os.Remove(tmpName)
|
||
return fmt.Errorf("storage: rename into place: %w", err)
|
||
}
|
||
if err := syncDir(dir); err != nil {
|
||
return fmt.Errorf("storage: sync dir: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// syncDir flushes a directory entry so a completed rename is durable.
|
||
func syncDir(dir string) error {
|
||
f, err := os.Open(dir)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer f.Close()
|
||
return f.Sync()
|
||
}
|
||
|
||
// SaveDoc writes content to data/docs/<slug>.md through the process-wide engine.
|
||
func SaveDoc(slug, content string) (string, error) {
|
||
e, err := Default()
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return e.SaveDoc(slug, content)
|
||
}
|
||
|
||
// ReadDoc reads data/docs/<slug>.md through the process-wide engine.
|
||
func ReadDoc(slug string) (string, error) {
|
||
e, err := Default()
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return e.ReadDoc(slug)
|
||
}
|
||
|
||
// DeleteDoc removes data/docs/<slug>.md through the process-wide engine.
|
||
func DeleteDoc(slug string) error {
|
||
e, err := Default()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return e.DeleteDoc(slug)
|
||
}
|
||
|
||
// ListDocs indexes documents through the process-wide engine.
|
||
func ListDocs() ([]DocMeta, error) {
|
||
e, err := Default()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return e.ListDocs()
|
||
}
|