mirror of
https://github.com/hanzoai/base.git
synced 2026-10-21 03:41:50 +00:00
The admin had no storage surface at all: nothing in ui-react named an engine, a file or a size, so the one question a growing Base raises — how much is in here and where is it — could only be answered by finding the process's data directory. `/v1/database` answers it, and Settings -> Data renders it. core.DescribeDatabase measures the engine, the files while the app holds them, and a count per collection through CountRecords, so both engines answer the same questions and the answer has one shape. A collection the engine refuses to count reads as unknown rather than as empty, and is logged, so one broken view leaves the rest of the report standing rather than taking the page down. core.ReclaimDatabase is the one thing the page can DO, and it does something real: it rewrites both databases without the pages deleted records left behind, folds the log back into them, refreshes the statistics, and returns the size either side so the page reports what was freed rather than claiming something was. Driven in a browser it took a database from 9.0 MB to 1.1 MB with the record counts unchanged. The rewrite goes BEFORE the fold. A VACUUM lands through the log like any other write, so folding first leaves the whole rewrite sitting in the log and the app holding more than it started with — 2,068,480 bytes to 2,665,912 that way, and to 593,920 this way. Both orders shrink something, which is why TestDatabaseReclaim measures each database separately: an earlier version was satisfied by 8 KB off the auxiliary log and went on passing with the rewrite removed entirely. There is no switch, because whether an instance belongs on a server is the host's question and Base stopped having an opinion about it. The page states which of the two it is on and what the other would mean, and it does not name a backend Base cannot open — a disabled control for one would be the datastore tier constant again, in a place a customer can see. core.LocalDatabase is now the single place that asks whether the app holds the database; backup asked it inline and now asks it there. tests/engine gains the cross-engine cases, which CI already runs against PostgreSQL 18 — the LLM.md line saying it does not was two releases stale. The third copy of a byte formatter went into ui-react/src/lib/format.ts and the two existing pages now read from it.
149 lines
4.9 KiB
Go
149 lines
4.9 KiB
Go
package core
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
)
|
|
|
|
// The two databases a Base keeps in its data directory while the engine keeps
|
|
// them in files. "auxiliary" rather than "aux" because the latter is a reserved
|
|
// Windows filename (hanzoai/base#5607).
|
|
const (
|
|
dataFile = "data.db"
|
|
auxFile = "auxiliary.db"
|
|
)
|
|
|
|
// Database is what a Base can measure about where it keeps records. Engine
|
|
// differences are the dialect's, so both engines answer the same questions and
|
|
// the answer has one shape.
|
|
//
|
|
// Path and size are the app's to report only while the engine keeps the
|
|
// database in the data directory. A server keeps it in the server, which is
|
|
// also where it is sized, so on one of those they read empty rather than
|
|
// naming a file that isn't there.
|
|
type Database struct {
|
|
Engine string `json:"engine"`
|
|
Local bool `json:"local"`
|
|
Data DatabaseFile `json:"data"`
|
|
Aux DatabaseFile `json:"aux"`
|
|
Collections []DatabaseCollection `json:"collections"`
|
|
}
|
|
|
|
// DatabaseFile is one database and what it occupies.
|
|
type DatabaseFile struct {
|
|
Path string `json:"path"`
|
|
Size int64 `json:"size"`
|
|
}
|
|
|
|
// DatabaseCollection is one collection and how many records are in it.
|
|
//
|
|
// Records is null for a collection the engine refused to count — a view over a
|
|
// table that no longer exists is the usual reason — so that one of those reads
|
|
// as unknown rather than as empty.
|
|
type DatabaseCollection struct {
|
|
Records *int64 `json:"records"`
|
|
Id string `json:"id"`
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
System bool `json:"system"`
|
|
}
|
|
|
|
// DescribeDatabase measures where the app keeps its records and how much is
|
|
// there. A collection that cannot be counted is reported as uncounted and
|
|
// logged, so one broken view leaves the rest of the answer standing.
|
|
func DescribeDatabase(app App) (*Database, error) {
|
|
collections, err := app.FindAllCollections()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
db := &Database{
|
|
Engine: app.Dialect().Name(),
|
|
Local: LocalDatabase(app.Dialect()),
|
|
Collections: make([]DatabaseCollection, 0, len(collections)),
|
|
}
|
|
|
|
if db.Local {
|
|
db.Data = databaseFile(app, dataFile)
|
|
db.Aux = databaseFile(app, auxFile)
|
|
}
|
|
|
|
for _, c := range collections {
|
|
entry := DatabaseCollection{Id: c.Id, Name: c.Name, Type: c.Type, System: c.System}
|
|
|
|
total, countErr := app.CountRecords(c)
|
|
if countErr != nil {
|
|
app.Logger().Warn("Failed to count a collection's records", "collection", c.Name, "error", countErr.Error())
|
|
} else {
|
|
entry.Records = &total
|
|
}
|
|
|
|
db.Collections = append(db.Collections, entry)
|
|
}
|
|
|
|
return db, nil
|
|
}
|
|
|
|
// ReclaimDatabase rewrites the database without the pages that deleted rows
|
|
// left behind, folds the write-ahead log back into it, and refreshes the
|
|
// planner's statistics. It returns what the app kept on disk either side of
|
|
// that, so a caller reports what was freed rather than claiming something was.
|
|
//
|
|
// The rewrite lands through the log like any other write, so the fold comes
|
|
// after it — the other order leaves the whole rewrite sitting in the log and
|
|
// the app holding more than it started with, which is measured in
|
|
// TestDatabaseReclaim. The dialect spells each step for the connected engine;
|
|
// an engine with no log to fold spells that one empty and it is skipped.
|
|
func ReclaimDatabase(app App) (before, after int64, err error) {
|
|
before = databaseOnDisk(app)
|
|
|
|
if err = app.Vacuum(); err != nil {
|
|
return before, databaseOnDisk(app), err
|
|
}
|
|
|
|
if err = app.AuxVacuum(); err != nil {
|
|
return before, databaseOnDisk(app), err
|
|
}
|
|
|
|
if checkpoint := app.Dialect().Checkpoint(); checkpoint != "" {
|
|
if _, err = app.NonconcurrentDB().NewQuery(checkpoint).Execute(); err != nil {
|
|
return before, databaseOnDisk(app), err
|
|
}
|
|
if _, err = app.AuxNonconcurrentDB().NewQuery(checkpoint).Execute(); err != nil {
|
|
return before, databaseOnDisk(app), err
|
|
}
|
|
}
|
|
|
|
if _, err = app.NonconcurrentDB().NewQuery(app.Dialect().Optimize()).Execute(); err != nil {
|
|
return before, databaseOnDisk(app), err
|
|
}
|
|
|
|
return before, databaseOnDisk(app), nil
|
|
}
|
|
|
|
// databaseOnDisk is what the app keeps in its data directory, and 0 on a server
|
|
// engine, which keeps nothing there. Unexported because a caller that wants it
|
|
// has a Database, whose two files carry the same number.
|
|
func databaseOnDisk(app App) int64 {
|
|
if !LocalDatabase(app.Dialect()) {
|
|
return 0
|
|
}
|
|
|
|
return databaseFile(app, dataFile).Size + databaseFile(app, auxFile).Size
|
|
}
|
|
|
|
// databaseFile sizes one database, counting the write-ahead log and its index
|
|
// alongside it — a database that has just been written to keeps most of its
|
|
// recent bytes there, so the file on its own reads smaller than the database is.
|
|
func databaseFile(app App, name string) DatabaseFile {
|
|
file := DatabaseFile{Path: filepath.Join(app.DataDir(), name)}
|
|
|
|
for _, path := range []string{file.Path, file.Path + "-wal", file.Path + "-shm"} {
|
|
if stat, statErr := os.Stat(path); statErr == nil {
|
|
file.Size += stat.Size()
|
|
}
|
|
}
|
|
|
|
return file
|
|
}
|