feat(admin): online dictionary update — upload archive, preview word diff, install & activate
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 23s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 45s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m5s

Replace the dictionary hot-reload with an online update flow in the GM console
(/_gm/dictionary): the operator uploads scrabble-dawg-vX.Y.Z.tar.gz, previews the
per-variant words added/removed against the active dictionary, and confirms to
install + activate it. Versions are immutable; in-progress games keep their pinned
version while new games use the new one.

- engine: DiffWords (enumerate both DAWGs, decode only the differences), OpenFinder,
  Registry.Finder, DictFiles; OpenWithVersions skips the .staging area.
- dictadmin: hardened release-archive validation + extraction (path-traversal,
  symlink, oversize, entry-count rejection) and staging -> install (atomic rename).
- game: active dictionary version persisted in the dictionary_state singleton
  (single source of truth, restored on boot), concurrency-safe accessor.
- storage: BACKEND_DICT_DIR is a named volume seeded from the image (nonroot-owned),
  so uploaded versions persist across redeploys; the build's DICT_VERSION labels the
  seed and equals the resident tag (BACKEND_DICT_VERSION).
- docs: ARCHITECTURE §5, FUNCTIONAL (+ru), backend README, TESTING, PRERELEASE.

Tests: engine + dictadmin unit; integration upload->preview->install->activate->
restart->pin->immutability->CSRF.
This commit is contained in:
Ilia Denisov
2026-06-13 23:29:06 +02:00
parent 5ff07da025
commit 0f3671f42d
31 changed files with 1590 additions and 78 deletions
+4 -1
View File
@@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"sort"
"strings"
"sync"
dawg "github.com/iliadenisov/dafsa"
@@ -83,7 +84,9 @@ func OpenWithVersions(dir, bootVersion string) (*Registry, error) {
return nil, fmt.Errorf("engine: scan dictionary dir %s: %w", dir, err)
}
for _, e := range entries {
if !e.IsDir() || e.Name() == bootVersion {
// Skip non-directories, the boot version (already loaded as the flat dir)
// and dot-prefixed directories (the upload staging area, dir/.staging/).
if !e.IsDir() || e.Name() == bootVersion || strings.HasPrefix(e.Name(), ".") {
continue
}
if _, err := r.LoadAvailable(filepath.Join(dir, e.Name()), e.Name()); err != nil {