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.
153 lines
4.3 KiB
Go
153 lines
4.3 KiB
Go
package core
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
|
|
"github.com/hanzoai/dbx"
|
|
"github.com/hanzoai/orm/dialect"
|
|
)
|
|
|
|
// LocalDatabase reports whether d keeps the database in the app's data
|
|
// directory as files. SQLite does, so its size, its backup and its restore are
|
|
// all the app's to do. A server engine keeps the database in the server, which
|
|
// is where it is sized, backed up and restored instead.
|
|
func LocalDatabase(d dialect.Dialect) bool {
|
|
_, local := d.(dialect.SQLite)
|
|
|
|
return local
|
|
}
|
|
|
|
// TableColumns returns all column names of a single table by its name.
|
|
func (app *BaseApp) TableColumns(tableName string) ([]string, error) {
|
|
columns := []string{}
|
|
|
|
err := app.ConcurrentDB().Select("name").
|
|
From(app.Dialect().Columns()).
|
|
AndWhere(dbx.HashExp{"tbl_name": tableName}).
|
|
OrderBy("cid ASC").
|
|
Column(&columns)
|
|
|
|
return columns, err
|
|
}
|
|
|
|
type TableInfoRow struct {
|
|
// the `db:"pk"` tag has special semantic so we cannot rename
|
|
// the original field without specifying a custom mapper
|
|
PK int
|
|
|
|
Index int `db:"cid"`
|
|
Name string `db:"name"`
|
|
Type string `db:"type"`
|
|
NotNull bool `db:"notnull"`
|
|
DefaultValue sql.NullString `db:"dflt_value"`
|
|
}
|
|
|
|
// TableInfo returns the columns of the specified table, in declaration order.
|
|
func (app *BaseApp) TableInfo(tableName string) ([]*TableInfoRow, error) {
|
|
info := []*TableInfoRow{}
|
|
|
|
err := app.ConcurrentDB().Select("cid", "name", "type", "notnull", "dflt_value", "pk").
|
|
From(app.Dialect().Columns()).
|
|
AndWhere(dbx.HashExp{"tbl_name": tableName}).
|
|
OrderBy("cid ASC").
|
|
All(&info)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// A missing table is an empty result rather than an error, so the caller
|
|
// would otherwise read "no columns" as "a table with no columns".
|
|
if len(info) == 0 {
|
|
return nil, fmt.Errorf("empty table info probably due to invalid or missing table %s", tableName)
|
|
}
|
|
|
|
return info, nil
|
|
}
|
|
|
|
// TableIndexes returns a name grouped map with all non empty index of the specified table.
|
|
//
|
|
// Note: This method doesn't return an error on nonexisting table.
|
|
func (app *BaseApp) TableIndexes(tableName string) (map[string]string, error) {
|
|
indexes := []struct {
|
|
Name string
|
|
Sql string
|
|
}{}
|
|
|
|
err := app.ConcurrentDB().Select("name", "sql").
|
|
From(app.Dialect().Catalog()).
|
|
AndWhere(dbx.NewExp("sql is not null")).
|
|
AndWhere(dbx.HashExp{
|
|
"type": "index",
|
|
"tbl_name": tableName,
|
|
}).
|
|
All(&indexes)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
result := make(map[string]string, len(indexes))
|
|
|
|
for _, idx := range indexes {
|
|
result[idx.Name] = idx.Sql
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// DeleteTable drops the specified table.
|
|
//
|
|
// This method is a no-op if a table with the provided name doesn't exist.
|
|
//
|
|
// NB! Be aware that this method is vulnerable to SQL injection and the
|
|
// "dangerousTableName" argument must come only from trusted input!
|
|
func (app *BaseApp) DeleteTable(dangerousTableName string) error {
|
|
_, err := app.NonconcurrentDB().NewQuery(fmt.Sprintf(
|
|
"DROP TABLE IF EXISTS {{%s}}",
|
|
dangerousTableName,
|
|
)).Execute()
|
|
|
|
return err
|
|
}
|
|
|
|
// HasTable checks if a table (or view) with the provided name exists (case insensitive).
|
|
// in the data.db.
|
|
func (app *BaseApp) HasTable(tableName string) bool {
|
|
return app.hasTable(app.ConcurrentDB(), tableName)
|
|
}
|
|
|
|
// AuxHasTable checks if a table (or view) with the provided name exists (case insensitive)
|
|
// in the auixiliary.db.
|
|
func (app *BaseApp) AuxHasTable(tableName string) bool {
|
|
return app.hasTable(app.AuxConcurrentDB(), tableName)
|
|
}
|
|
|
|
func (app *BaseApp) hasTable(db dbx.Builder, tableName string) bool {
|
|
var exists int
|
|
|
|
err := db.Select("(1)").
|
|
From(app.Dialect().Catalog()).
|
|
AndWhere(dbx.HashExp{"type": []any{"table", "view"}}).
|
|
AndWhere(dbx.NewExp("LOWER([[name]])=LOWER({:tableName})", dbx.Params{"tableName": tableName})).
|
|
Limit(1).
|
|
Row(&exists)
|
|
|
|
return err == nil && exists > 0
|
|
}
|
|
|
|
// Vacuum executes VACUUM on the data.db in order to reclaim unused data db disk space.
|
|
func (app *BaseApp) Vacuum() error {
|
|
return app.vacuum(app.NonconcurrentDB())
|
|
}
|
|
|
|
// AuxVacuum executes VACUUM on the auxiliary.db in order to reclaim unused auxiliary db disk space.
|
|
func (app *BaseApp) AuxVacuum() error {
|
|
return app.vacuum(app.AuxNonconcurrentDB())
|
|
}
|
|
|
|
func (app *BaseApp) vacuum(db dbx.Builder) error {
|
|
_, err := db.NewQuery("VACUUM").Execute()
|
|
|
|
return err
|
|
}
|