Files
meshcore-analyzer/internal/dbschema/dbschema_test.go
T
Kpa-clawbot d9ba9937a6 fix(dbschema): canonical source for optional column migrations — fixes startup race (closes #1321) (#1322)
Red commit `2a8102b9` (failing test) → green commit `bb957c9f`. CI:
https://github.com/Kpa-clawbot/CoreScope/actions/workflows/ci.yml?query=branch%3Afix%2Fissue-1321

Fixes #1321.

## Why

On staging `/api/scope-stats` 500'd with `scope_name column not present`
despite the ingestor adding the column ~0.5s after server startup.
`cmd/server/db.go detectSchema()` runs in `OpenDB` and caches
`hasScopeName`/`hasDefaultScope`/`hasObsRawHex` booleans. With
supervisord launching server + ingestor simultaneously, the server's
PRAGMA can fire BEFORE the ingestor's `ALTER TABLE` completes — and the
boolean stays false until the server restarts. Same race class as #1283;
#1289 moved server-side ensures to `dbschema` but the optional columns
the ingestor still owned were left out.

## Fix — option (c) from the issue

Made `internal/dbschema/dbschema.go` the single source of truth for the
optional columns the server detects.

**Migrations moved from `cmd/ingestor/db.go applySchema` into
`dbschema.Apply`:**
- `transmissions.scope_name` + `idx_tx_scope_name` partial index
- `nodes.default_scope`
- `inactive_nodes.default_scope`
- `observations.raw_hex`

**`AssertReady` now asserts** every one of those columns. The server
cannot start with stale-false booleans because `AssertReady` will fatal
first if the columns are missing. The ingestor's old gated blocks are
replaced with pointer comments so anyone hunting for them lands in
`dbschema.go`. The `_migrations` marker rows are preserved (`INSERT OR
IGNORE`) to keep legacy DBs idempotent.

**Documented invariant** in the package doc: any new optional column the
server PRAGMA-detects belongs in `internal/dbschema/dbschema.go`, NOT in
`cmd/ingestor/db.go applySchema`.

## Tests

Added `internal/dbschema/dbschema_test.go` (RED in `2a8102b9`):
- `TestApplyAddsOptionalColumns_CanonicalSource` — post-`Apply`, all
four columns must exist.
- `TestAssertReady_RequiresOptionalColumns` — `AssertReady` must refuse
a DB missing them AND pass after full `Apply`.

`cmd/ingestor` and `cmd/server` full suites green.

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-05-23 08:33:21 -07:00

163 lines
4.4 KiB
Go

package dbschema
import (
"database/sql"
"path/filepath"
"testing"
_ "modernc.org/sqlite"
)
// minimalDB bootstraps a SQLite DB with just enough tables for the
// ensure_* helpers to run against, but WITHOUT any of the optional
// columns that dbschema.Apply is responsible for ensuring.
func minimalDB(t *testing.T) *sql.DB {
t.Helper()
dir := t.TempDir()
dbPath := filepath.Join(dir, "schema.db")
db, err := sql.Open("sqlite", "file:"+dbPath+"?_journal_mode=WAL")
if err != nil {
t.Fatal(err)
}
stmts := []string{
// Bare-bones tables, mirroring the legacy/empty fixture shape
// pre-migration. Intentionally omit columns we expect Apply to add.
`CREATE TABLE transmissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
raw_hex TEXT NOT NULL,
hash TEXT NOT NULL UNIQUE,
first_seen TEXT NOT NULL,
route_type INTEGER,
payload_type INTEGER,
payload_version INTEGER,
decoded_json TEXT
)`,
`CREATE TABLE observations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
transmission_id INTEGER NOT NULL,
observer_idx INTEGER,
direction TEXT,
snr REAL,
rssi REAL,
score INTEGER,
path_json TEXT,
timestamp INTEGER NOT NULL
)`,
`CREATE TABLE observers (
id TEXT PRIMARY KEY,
name TEXT
)`,
`CREATE TABLE nodes (
public_key TEXT PRIMARY KEY,
name TEXT
)`,
`CREATE TABLE inactive_nodes (
public_key TEXT PRIMARY KEY,
name TEXT,
last_seen TEXT
)`,
}
for _, s := range stmts {
if _, err := db.Exec(s); err != nil {
t.Fatalf("bootstrap: %v", err)
}
}
return db
}
// TestApplyAddsOptionalColumns_CanonicalSource is the regression gate
// for issue #1321: dbschema.Apply must be the single source of truth
// for ALL optional columns the server PRAGMA-detects. Previously
// scope_name/default_scope/observations.raw_hex lived ONLY in
// cmd/ingestor/db.go applySchema, so the server (which runs
// detectSchema AFTER dbschema.AssertReady) could race the writer and
// cache stale false values when ingestor hadn't yet finished its
// applySchema migrations.
func TestApplyAddsOptionalColumns_CanonicalSource(t *testing.T) {
db := minimalDB(t)
defer db.Close()
if err := Apply(db, nil); err != nil {
t.Fatalf("Apply: %v", err)
}
cases := []struct {
table, col string
}{
{"transmissions", "scope_name"},
{"nodes", "default_scope"},
{"inactive_nodes", "default_scope"},
{"observations", "raw_hex"},
}
for _, c := range cases {
has, err := TableHasColumn(db, c.table, c.col)
if err != nil {
t.Fatalf("probe %s.%s: %v", c.table, c.col, err)
}
if !has {
t.Errorf("after Apply: %s.%s missing — dbschema must be the source of truth for this optional column (#1321)", c.table, c.col)
}
}
}
// TestAssertReady_RequiresOptionalColumns enforces that AssertReady
// REFUSES a DB missing the optional columns the server depends on —
// proving dbschema.AssertReady (not server-side PRAGMA detection) is
// the gate.
func TestAssertReady_RequiresOptionalColumns(t *testing.T) {
db := minimalDB(t)
defer db.Close()
// Run pre-existing ensures so we only fail on the new ones.
noop := Logger(func(string, ...interface{}) {})
if err := ensureNeighborEdgesTable(db); err != nil {
t.Fatal(err)
}
if err := ensureResolvedPathColumn(db, noop); err != nil {
t.Fatal(err)
}
if err := ensureObserverInactiveColumn(db, noop); err != nil {
t.Fatal(err)
}
if err := ensureLastPacketAtColumn(db, noop); err != nil {
t.Fatal(err)
}
if err := ensureObserverIATAColumn(db, noop); err != nil {
t.Fatal(err)
}
if err := ensureForeignAdvertColumn(db, noop); err != nil {
t.Fatal(err)
}
if err := ensureFromPubkeyColumn(db, noop); err != nil {
t.Fatal(err)
}
// At this point the OLD AssertReady set is satisfied but the NEW
// columns are NOT — AssertReady must still fail.
err := AssertReady(db)
if err == nil {
t.Fatal("AssertReady should fail when scope_name/default_scope/observations.raw_hex are missing (#1321)")
}
for _, must := range []string{"scope_name", "default_scope", "raw_hex"} {
if !contains(err.Error(), must) {
t.Errorf("AssertReady error should mention missing %q; got: %v", must, err)
}
}
// After full Apply, AssertReady passes.
if err := Apply(db, nil); err != nil {
t.Fatalf("Apply: %v", err)
}
if err := AssertReady(db); err != nil {
t.Fatalf("AssertReady after full Apply: %v", err)
}
}
func contains(haystack, needle string) bool {
for i := 0; i+len(needle) <= len(haystack); i++ {
if haystack[i:i+len(needle)] == needle {
return true
}
}
return false
}