Files
zeekayandHanzo Dev cbc640291d The permission table decides every document right, at creation too
Four rules that were each half-stated.

Manager answered true ahead of the table, so a DocType could withhold
nothing from the org owner: a row naming System Manager with read alone
granted nothing and withheld nothing, and an append-only trail had a writer.
Manager is the meta-permission — who defines a DocType and installs a module
— and resolveManager still asks it. A caller holding System Manager carries
the role, so every DocType naming it resolves through the same table as
every other role.

A DocType can now declare itself immutable (doctype v0.1.4), which the
store keeps in a column of its own and Grants answers write and delete for
nobody on. The in-process API, which the owning subsystem writes through,
still reaches it.

A read-only field was held on update and free at creation, so a document
could be born carrying the values its own machine exists to land. fixed()
now runs at both moments: against what the DocType declares before a
document exists, and against what the document carries once one does.

An update replaced the whole document from the body, so a partial body
erased every field it did not mention — a 200 saying a site kept its town
and lost the panel account an activation matches on. The body now says what
CHANGES; a field is cleared by naming it empty.

And a bounded in-process Search took the OLDEST rows, where the HTTP list
already answered most-recently-updated first for an unordered query. Both
now take the newest.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-16 15:02:10 -07:00

272 lines
9.1 KiB
Go

package framework
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"strings"
"github.com/hanzoai/doctype"
)
// upgrade.go moves a database from the name-keyed schema to the address-keyed
// one, once.
//
// Before, a DocType was keyed by name alone and a lane kept its module OUT of
// collisions by writing the module into the name as well: "kb-page" in module
// "kb", "hd-ticket" in module "help". The module was therefore stored twice, and
// the two copies could disagree. Now the key IS the pair, the name is the bare
// kind, and the address module.name is the single rendering of it.
//
// The rename is derived from the REGISTRY, never from a table of old spellings:
// a stored name whose leading dash-token can be dropped to reach a fixture the
// row's own module declares becomes that fixture. "kb-page" in module kb reaches
// "page"; "erp-stock-item" reaches "stock-item" and not "item", because the strip
// is at the FIRST dash, not the longest suffix. A name that reaches nothing —
// "Page" in cms, or an org's own "Test Space" — is left exactly as it is, which
// is why a lane that never prefixed anything needs no exception here.
//
// It runs before the DDL and is a no-op twice over: on a database that has no
// old table, and on one already carrying the module column.
// upgrade rebuilds fw_doctypes and fw_documents on the address key. SQLite
// cannot ALTER a primary key, so this is the create-copy-drop-rename recipe, in
// one transaction: either the whole estate moves or none of it does.
func (s *Store) upgrade() error {
ctx := context.Background()
old, err := s.stale(ctx)
if err != nil || !old {
return err
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("upgrade: begin: %w", err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx, fmt.Sprintf(doctypesDDL, "fw_doctypes_next")+fmt.Sprintf(documentsDDL, "fw_documents_next")); err != nil {
return fmt.Errorf("upgrade: staging tables: %w", err)
}
moved, err := s.upgradeDocTypes(ctx, tx)
if err != nil {
return err
}
if err := s.upgradeDocuments(ctx, tx, moved); err != nil {
return err
}
for _, stmt := range []string{
`DROP TABLE fw_doctypes`,
`DROP TABLE fw_documents`,
`ALTER TABLE fw_doctypes_next RENAME TO fw_doctypes`,
`ALTER TABLE fw_documents_next RENAME TO fw_documents`,
} {
if _, err := tx.ExecContext(ctx, stmt); err != nil {
return fmt.Errorf("upgrade: %s: %w", stmt, err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("upgrade: commit: %w", err)
}
return nil
}
// stale reports that the database holds the old schema: fw_documents exists and
// has no module column. Reading the shape is the whole version signal — it says
// what is actually there, which a version counter only claims.
func (s *Store) stale(ctx context.Context) (bool, error) {
cols, err := s.columns(ctx, "fw_documents")
if err != nil {
return false, fmt.Errorf("upgrade: %w", err)
}
return len(cols) > 0 && !cols["module"], nil
}
// upgradeDocTypes copies every DocType onto the address key, renaming it and
// rewriting its Link/Table targets. It returns what each (org, old name) became,
// which is the only thing that can tell the documents where they went.
func (s *Store) upgradeDocTypes(ctx context.Context, tx *sql.Tx) (map[[2]string]doctype.ID, error) {
rows, err := tx.QueryContext(ctx,
`SELECT org,name,module,is_single,is_submittable,autoname,title_field,fields,permissions,created_at,updated_at FROM fw_doctypes`)
if err != nil {
return nil, fmt.Errorf("upgrade: read doctypes: %w", err)
}
type row struct {
org string
id doctype.ID
single, submittable int
autoname, titleField string
fieldsJSON, permsJSON string
created, updated int64
}
var (
kept []row
moved = map[[2]string]doctype.ID{}
)
for rows.Next() {
var (
r row
old string
)
if err := rows.Scan(&r.org, &old, &r.id.Module, &r.single, &r.submittable, &r.autoname,
&r.titleField, &r.fieldsJSON, &r.permsJSON, &r.created, &r.updated); err != nil {
_ = rows.Close()
return nil, fmt.Errorf("upgrade: scan doctype: %w", err)
}
r.id.Name = kind(r.id.Module, old)
moved[[2]string{r.org, old}] = r.id
r.fieldsJSON = retarget(r.id.Module, r.fieldsJSON)
kept = append(kept, r)
}
if err := rows.Err(); err != nil {
_ = rows.Close()
return nil, err
}
_ = rows.Close()
for _, r := range kept {
if _, err := tx.ExecContext(ctx,
`INSERT INTO fw_doctypes_next (org,name,module,is_single,is_submittable,autoname,title_field,fields,permissions,created_at,updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
r.org, r.id.Name, r.id.Module, r.single, r.submittable, r.autoname, r.titleField,
r.fieldsJSON, r.permsJSON, r.created, r.updated); err != nil {
return nil, fmt.Errorf("upgrade: write doctype %s: %w", r.id, err)
}
}
return moved, nil
}
// upgradeDocuments copies every document under its DocType's new address.
//
// A document whose org has no stored DocType row still has a home: an always-on
// lane's schema is virtual and never had a row, and an org can outlive a
// definition. Those resolve through the registry (claimed), which is the one
// place a name is matched without a module and the only place it is safe —
// the old data offers nothing else to match on, and a name two lanes both claim
// is left alone rather than guessed at.
func (s *Store) upgradeDocuments(ctx context.Context, tx *sql.Tx, moved map[[2]string]doctype.ID) error {
rows, err := tx.QueryContext(ctx, `SELECT org,doctype,name,docstatus,data,created_at,updated_at FROM fw_documents`)
if err != nil {
return fmt.Errorf("upgrade: read documents: %w", err)
}
type row struct {
org, name string
id doctype.ID
status int
data string
created, updated int64
}
var kept []row
for rows.Next() {
var (
r row
old string
)
if err := rows.Scan(&r.org, &old, &r.name, &r.status, &r.data, &r.created, &r.updated); err != nil {
_ = rows.Close()
return fmt.Errorf("upgrade: scan document: %w", err)
}
id, ok := moved[[2]string{r.org, old}]
if !ok {
id, ok = claimed(old)
}
if !ok {
// The DocType is gone and no lane claims the name. The row is already
// unreachable; carrying it forward under an empty module keeps it
// readable by hand rather than deleting somebody's data on a guess.
id = doctype.ID{Name: old}
}
r.id = id
kept = append(kept, r)
}
if err := rows.Err(); err != nil {
_ = rows.Close()
return err
}
_ = rows.Close()
for _, r := range kept {
if _, err := tx.ExecContext(ctx,
`INSERT INTO fw_documents_next (org,module,doctype,name,docstatus,data,created_at,updated_at)
VALUES (?,?,?,?,?,?,?,?)`,
r.org, r.id.Module, r.id.Name, r.name, r.status, r.data, r.created, r.updated); err != nil {
return fmt.Errorf("upgrade: write document %s/%s: %w", r.id, r.name, err)
}
}
return nil
}
// kind is the rename: the bare kind a stored name became within its module.
// Unprefixed when the module already declares that name, the tail after the
// first dash when the module declares THAT, and otherwise unchanged — an org's
// own DocType is nobody's fixture and keeps the name its owner chose.
func kind(module, name string) string {
if declares(module, name) {
return name
}
if _, tail, ok := strings.Cut(name, "-"); ok && declares(module, tail) {
return tail
}
return name
}
// declares reports that a module's fixtures include this name.
func declares(module, name string) bool {
for _, dt := range doctype.Fixtures(module) {
if dt.Name == name {
return true
}
}
return false
}
// retarget rewrites the Link and Table targets inside a stored fields blob from
// bare names to addresses. A target is resolved in the DECLARING module first,
// then across the registry, and left alone when nothing claims it — an
// unresolvable target then fails validation on the next write, which is where a
// broken reference should become visible.
func retarget(module, fieldsJSON string) string {
var fields []doctype.DocField
if err := json.Unmarshal([]byte(fieldsJSON), &fields); err != nil {
return fieldsJSON
}
changed := false
for i, f := range fields {
if f.Fieldtype != doctype.FieldLink && f.Fieldtype != doctype.FieldTable {
continue
}
if _, err := doctype.ParseID(f.Options); err == nil {
continue // already an address
}
if k := kind(module, f.Options); declares(module, k) {
fields[i].Options, changed = doctype.ID{Module: module, Name: k}.String(), true
continue
}
if id, ok := claimed(f.Options); ok {
fields[i].Options, changed = id.String(), true
}
}
if !changed {
return fieldsJSON
}
out, err := json.Marshal(fields)
if err != nil {
return fieldsJSON
}
return string(out)
}
// claimed resolves a stored name across every registered lane. Unique or
// nothing: two lanes claiming one name is exactly the ambiguity the address
// removes, and guessing between them would hand an org the other lane's schema.
func claimed(name string) (doctype.ID, bool) {
var (
got doctype.ID
n int
)
for _, m := range doctype.RegisteredModules() {
if k := kind(m, name); declares(m, k) {
got, n = doctype.ID{Module: m, Name: k}, n+1
}
}
return got, n == 1
}