Documentation
¶
Overview ¶
Package framework is the Hanzo Framework: a metadata-driven DocType engine, native Go on Base/SQLite, mounted in the unified cloud binary at /v1/framework/*. It is the rebuilt-in-Go successor to Frappe's DocType/metadata core — the FOUNDATION on which CMS content-types, ERPNext DocTypes, and Helpdesk become "just DocTypes", so ONE engine + ONE generic UI renders every business app (maximal DRY). There is NO Frappe/Python runtime dependency; the engine itself is pure Go.
The model (faithful to Frappe, flattened onto SQLite) ¶
- A DocType is a metadata definition: {name, module, fields[], permissions[], isSingle, isSubmittable, autoname, titleField}. It is data, stored per-org.
- A DocField mirrors Frappe's DocField: {fieldname, fieldtype, label, reqd, options, default, ...}. The fieldtype set is Data, Int, Float, Currency, Check, Date, Datetime, Text, SmallText, LongText, Select, Link, Table, Attach, JSON, Password.
- A Document is a schemaless record validated against its DocType at write and persisted as a JSON blob keyed by (org, doctype, name). docstatus is the Frappe lifecycle: 0=draft, 1=submitted, 2=cancelled.
Tenant isolation (the security boundary) ¶
Every request resolves its org through the ONE boundary — clients/principal .Tenant — which returns the org ONLY for a VALIDATED principal (a gateway- or BFF-minted X-User-Id from a verified IAM credential), verbatim and cloned. A forged X-Org-Id with no validated principal is refused 403 before any store access. Every doctype, document, series counter, and role row carries an `org` column and every query filters WHERE org=?, so org A's schema and data are physically invisible to org B. There is no second org derivation path anywhere in this package.
Surface (all org-scoped; /v1 only) ¶
GET /v1/framework/doctypes DocType registry list -> {data:[…]}
POST /v1/framework/doctypes define a DocType -> DocType (201)
GET /v1/framework/doctypes/:name DocType definition -> DocType
PUT /v1/framework/doctypes/:name replace a DocType -> DocType
DELETE /v1/framework/doctypes/:name delete a DocType (+ its docs)
GET /v1/framework/roles per-org role assignments -> {data:[…]}
POST /v1/framework/roles assign (user,role) -> Role (201)
DELETE /v1/framework/roles/:user/:role revoke (user,role)
GET /v1/framework/:doctype list documents -> {data:[…]}
?filters=&fields=&limit=&order_by=
POST /v1/framework/:doctype create a document -> Document (201)
GET /v1/framework/:doctype/:name document detail -> Document
PUT /v1/framework/:doctype/:name update a document -> Document
DELETE /v1/framework/:doctype/:name delete a document
POST /v1/framework/:doctype/:name/submit docstatus 0→1 (submittable) -> Document
POST /v1/framework/:doctype/:name/cancel docstatus 1→2 (submittable) -> Document
serve.go auto-registers GET /v1/framework/health. Order 129 binds the surface before the AI subsystem's /v1/* catch-all (150); the static /doctypes + /roles routes register before the generic /:doctype routes so Fiber's first-match scan resolves them unambiguously (and those keywords are reserved DocType names).
Index ¶
- Constants
- func FindByField(ctx context.Context, org, doctype, field, value string) (string, error)
- func Installed(ctx context.Context, org, doctype string) bool
- func Mount(app *zip.App, deps cloud.Deps) error
- func RegisterHook(doctype, action string, fn Hook)
- func RegisterModule(module string, fixtures []DocType)
- func Shutdown() error
- func UpdateData(ctx context.Context, org, doctype, name string, data map[string]any) error
- type DocField
- type DocPerm
- type DocType
- type Document
- type Event
- type Hook
- type Ingested
- type ListOpts
- type Role
- type Store
- func (s *Store) AssignRole(ctx context.Context, org, user, role string) error
- func (s *Store) Close() error
- func (s *Store) CountDocuments(ctx context.Context, org, doctype string) (int, error)
- func (s *Store) CreateDocType(ctx context.Context, org string, dt DocType) (DocType, error)
- func (s *Store) CreateDocument(ctx context.Context, org string, dt *DocType, data map[string]any, ...) (Document, error)
- func (s *Store) DeleteDocType(ctx context.Context, org, name string) (bool, error)
- func (s *Store) DeleteDocument(ctx context.Context, org, doctype, name string) (bool, error)
- func (s *Store) GetDocType(ctx context.Context, org, name string) (DocType, error)
- func (s *Store) GetDocument(ctx context.Context, org, doctype, name string) (Document, error)
- func (s *Store) ListDocTypes(ctx context.Context, org string) ([]DocType, error)
- func (s *Store) ListDocuments(ctx context.Context, org, doctype string, opts ListOpts) ([]Document, error)
- func (s *Store) ListRoles(ctx context.Context, org string) ([]Role, error)
- func (s *Store) ReplaceDocType(ctx context.Context, org string, dt DocType) (DocType, error)
- func (s *Store) RevokeRole(ctx context.Context, org, user, role string) (bool, error)
- func (s *Store) RolesFor(ctx context.Context, org, user string) ([]string, error)
- func (s *Store) SeedOwnerIfUnowned(ctx context.Context, org, user string) (bool, error)
- func (s *Store) SetDocStatus(ctx context.Context, org, doctype, name string, from, to int) (Document, error)
- func (s *Store) UpdateDocument(ctx context.Context, org string, dt *DocType, name string, data map[string]any) (Document, error)
- func (s *Store) UpsertSingle(ctx context.Context, org string, dt *DocType, data map[string]any) (Document, error)
Constants ¶
const ( FieldData = "Data" FieldInt = "Int" FieldFloat = "Float" FieldCurrency = "Currency" FieldCheck = "Check" FieldDate = "Date" FieldDatetime = "Datetime" FieldText = "Text" FieldSmall = "SmallText" FieldLong = "LongText" FieldRichText = "RichText" // WYSIWYG body; value is a Lexical EditorState JSON string FieldSelect = "Select" FieldLink = "Link" FieldTable = "Table" FieldAttach = "Attach" FieldJSON = "JSON" FieldPassword = "Password" )
Fieldtype constants mirror Frappe's DocField.fieldtype. This is the closed set the engine validates; a DocField with any other fieldtype is rejected at define time (fail closed — an unknown type has no validation and is a hole).
const ( ActionBeforeInsert = "before_insert" ActionBeforeSave = "before_save" // runs on BOTH create and update ActionAfterSave = "after_save" ActionOnSubmit = "on_submit" ActionOnCancel = "on_cancel" ActionOnTrash = "on_trash" // before delete )
Hooks — the DocType lifecycle extension contract.
A sibling app lane (CMS, ERP, CRM, Helpdesk) attaches server-side behavior to a DocType by registering a Go Hook against a (doctype, action) pair at process init. This is the pure-Go path, live now. A gpython/goja SCRIPT runner is a LATER, orthogonal add that registers a Hook closure implementing THIS SAME interface — the engine never grows a second hook path. That is the whole point of defining the interface now: one seam, many implementations.
Trust & scope
A Hook is trusted FIRST-PARTY Go compiled into the binary — like the engine's own logic, inside the trust boundary. It is keyed by doctype NAME and applied per-org: the Event carries the VALIDATED org (never a client header), and any sibling data a hook reads/writes through ev.Store MUST be scoped with ev.Org, so a hook stays in its tenant's lane. (When the script runner lands, untrusted scripts run sandboxed and org-pinned — that isolation is designed there, not here.)
When hooks run (deadlock-safe, gate-style)
Hooks run OUTSIDE the store's write transaction. The store uses a single SQLite connection (MaxOpenConns(1)); running a hook that touches ev.Store inside an open transaction would deadlock on that one connection. So the service orchestrates phases and the store performs each state change as its own atomic statement:
create: validate → BeforeInsert → BeforeSave → INSERT → AfterSave update: validate → BeforeSave → UPDATE → AfterSave submit: load(0) → OnSubmit(gate) → docstatus 0→1 cancel: load(1) → OnCancel(gate) → docstatus 1→2 delete: load → OnTrash(gate) → DELETE
A gate hook (BeforeInsert/BeforeSave/OnSubmit/OnCancel/OnTrash) that returns a non-nil error ABORTS the operation BEFORE the state change — nothing is written (mapped to HTTP 422). BeforeInsert/BeforeSave may MUTATE ev.Doc.Data (e.g. compute a total); the mutated document is what gets persisted. AfterSave runs after the row is written; its error is surfaced but the write already landed.
const ( // RoleSystemManager is the admin role: it manages DocTypes + role assignments // and is granted every document right. Mirrors Frappe's System Manager. RoleSystemManager = "System Manager" // RoleAll is the implicit role every validated org member holds (Frappe "All"). RoleAll = "All" )
Permissions — per-org, DocType perms by role, enforced on every operation.
The role source. IAM's JWT carries no per-user role set into the cloud binary (SanitizeIdentity restores only user/email/org/isAdmin), so the framework owns its role model per-org, exactly as Frappe records "Has Role" within a site: the fw_roles table maps (org, user) → roles, managed at /v1/framework/roles.
The gate. Every handler resolves an `access` through ONE seam (resolveAccess), which begins with the ONE tenant derivation (principal.Tenant) and never a second path. From the validated principal it derives the caller's effective roles and whether they are a manager, then answers can(doctype, right).
Variables ¶
This section is empty.
Functions ¶
func FindByField ¶ added in v1.786.72
FindByField returns the name of the first document in (org, doctype) whose `field` equals `value`, or "" if none. A connector uses it to find an existing kb-source by external_id (idempotent re-sync: update in place vs. create new). `field` is validated against the doctype schema by ListDocuments' bound json path.
func Installed ¶ added in v1.786.72
Installed reports whether `doctype` exists in `org` (i.e. the module was installed). A connector checks this before a sync so it can return an honest "install the kb module first" rather than a doctype-not-found error mid-sync.
func RegisterHook ¶
RegisterHook attaches fn to (doctype, action). Multiple hooks for the same key run in registration order; the first error aborts the operation. Register from a package init() so the wiring is declared once at build time. This is the ONE way a DocType gains server-side behavior.
func RegisterModule ¶
RegisterModule declares the DocType fixtures a module installs. Call from a package init() so the content model is declared once at build time. The module name is stamped onto every fixture at install so a lane's DocTypes are always discoverable by `module`. Fixtures are cloned on registration so a caller's slice can never mutate the registry.
func UpdateData ¶ added in v1.786.72
UpdateData replaces the data of an existing document (draft only), running the before_save + after_save hooks — the in-process twin of the HTTP PUT. A connector uses it to refresh an already-ingested kb-source (same external_id) on re-sync so the vector point is updated in place rather than duplicated. `name` is the engine-assigned document name from a prior Ingest.
Types ¶
type DocField ¶
type DocField struct {
Fieldname string `json:"fieldname"`
Fieldtype string `json:"fieldtype"`
Label string `json:"label,omitempty"`
Reqd bool `json:"reqd,omitempty"`
// Options is fieldtype-dependent: Select → newline-separated choices; Link →
// target DocType name; Table → child DocType name. Empty otherwise.
Options string `json:"options,omitempty"`
// Default is applied when a create omits the field (before validation).
Default string `json:"default,omitempty"`
Unique bool `json:"unique,omitempty"`
ReadOnly bool `json:"readOnly,omitempty"`
Hidden bool `json:"hidden,omitempty"`
// InListView flags the field for the generic list UI (metadata only).
InListView bool `json:"inListView,omitempty"`
// FetchFrom auto-populates this field from a linked document, in the form
// "link_fieldname.source_fieldname": on save the engine loads the doc named by
// the Link field `link_fieldname` and copies its `source_fieldname` here.
FetchFrom string `json:"fetchFrom,omitempty"`
}
DocField is one field in a DocType, faithful to Frappe's DocField. JSON tags are the wire contract the sibling app lanes (CMS/ERP/CRM) and the generic @hanzo/ui DocType renderer build to.
type DocPerm ¶
type DocPerm struct {
Role string `json:"role"`
Read bool `json:"read,omitempty"`
Write bool `json:"write,omitempty"`
Create bool `json:"create,omitempty"`
Delete bool `json:"delete,omitempty"`
Submit bool `json:"submit,omitempty"`
Cancel bool `json:"cancel,omitempty"`
}
DocPerm is a role's rights on a DocType, faithful to Frappe's DocPerm. The engine enforces these per-org against the caller's resolved roles.
type DocType ¶
type DocType struct {
Name string `json:"name"`
Module string `json:"module,omitempty"`
IsSingle bool `json:"isSingle,omitempty"`
IsSubmittable bool `json:"isSubmittable,omitempty"`
// Autoname is the naming rule (see naming.go): "" or "hash" → random id;
// "field:fieldname" → value of that field; "prompt" → client supplies name;
// any other value is a series pattern, e.g. "INV-.YYYY.-.#####".
Autoname string `json:"autoname,omitempty"`
TitleField string `json:"titleField,omitempty"`
Fields []DocField `json:"fields"`
Perms []DocPerm `json:"permissions,omitempty"`
CreatedAt int64 `json:"createdAt,omitempty"`
UpdatedAt int64 `json:"updatedAt,omitempty"`
}
DocType is a metadata definition. It is per-org data: the same DocType `name` may exist independently in many orgs with different fields, and one org's definition is invisible to another.
type Document ¶
type Document struct {
Name string
DocType string
DocStatus int
Data map[string]any
CreatedAt int64
UpdatedAt int64
}
Document is a schemaless record: Data holds the validated field values, keyed by fieldname. The HTTP layer serializes it via wireDoc (Data + envelope keys), so this internal shape is the ONE representation the store works with.
func Search ¶ added in v1.786.72
func Search(ctx context.Context, org, doctype string, filters map[string]string, limit int) ([]Document, error)
Search is the in-process, org-scoped document list a first-party subsystem (the KB retrieval surface) uses to hydrate search hits or count ingested docs without re-implementing the store query. It is a thin, validated pass-through to ListDocuments — every result is physically scoped to `org`.
type Event ¶
type Event struct {
// Action is one of the Action* constants.
Action string
// Org is the VALIDATED tenant (clients/principal.Tenant). A hook that touches
// sibling data MUST scope every query by it.
Org string
// DocType is the document's DocType name.
DocType string
// Doc is the document being acted on. In BeforeInsert/BeforeSave a hook may
// mutate Doc.Data and the mutation is persisted; elsewhere treat it as read.
Doc *Document
// Prev is the previous persisted state on update/submit/cancel; nil on insert.
Prev *Document
// Meta is the document's DocType definition (fields, perms, flags).
Meta *DocType
// Store is in-org data access for hooks that read or write sibling documents.
Store *Store
// Logger is the framework subsystem logger.
Logger luxlog.Logger
}
Event is the value that flows through a lifecycle Hook.
type Hook ¶
Hook is a server-side lifecycle handler. Returning a non-nil error from a gate phase aborts the operation (HTTP 422) before any state change.
type Ingested ¶ added in v1.786.72
Ingested is the minimal result of an in-process create: the org, doctype, and the engine-assigned document name (a connector records this to key incremental re-syncs).
func Ingest ¶ added in v1.786.72
func Ingest(ctx context.Context, org, doctype string, data map[string]any, requestedName string) (Ingested, error)
Ingest creates a document of `doctype` in `org` from already-trusted field data, running the full validate + lifecycle-hook pipeline (so the after_save indexing hook fires exactly as it does for an HTTP create). `org` MUST be a validated tenant the caller already resolved — Ingest does not derive it. `requestedName` is used only for prompt/single naming (empty for the common autoname/hash case).
It errors ("framework: not mounted") if called before the subsystem is mounted, and surfaces validation/lifecycle/store errors verbatim so a connector can record them on its connection status.
type ListOpts ¶
type ListOpts struct {
Filters map[string]string
OrderField string // "" → updated_at
Desc bool
Limit int
}
ListOpts is a parsed, validated list query. Filters keys are declared field names (or the special "name"/"docstatus"); OrderField is a resolved sort key.
type Store ¶
type Store struct {
// contains filtered or unexported fields
}
Store is the framework database. ONE SQLite file ({DataDir}/framework.db) holds every org's DocTypes, documents, naming counters, and role assignments; tenant isolation is the `org` column, present on EVERY table and EVERY query. This is the ONE storage pattern (mirrors clients/crm, clients/cms). MaxOpenConns(1) serializes writes against the single-writer SQLite file.
func (*Store) AssignRole ¶
func (*Store) CountDocuments ¶
CountDocuments returns the org's document count for a doctype (real, per-org).
func (*Store) CreateDocType ¶
func (*Store) CreateDocument ¶
func (s *Store) CreateDocument(ctx context.Context, org string, dt *DocType, data map[string]any, requestedName string) (Document, error)
CreateDocument names and inserts a validated document at docstatus 0. `data` is the canonical, already-validated field map (the service calls validateDoc first). Naming follows dt.Autoname; series naming increments a per-org counter inside the same transaction so concurrent creates never collide.
func (*Store) DeleteDocType ¶
DeleteDocType removes a DocType and ALL of its documents in one transaction — there are no orphaned documents without a schema. Returns false if absent.
func (*Store) DeleteDocument ¶
DeleteDocument removes a document by key. Returns false if absent. The service enforces the lifecycle guard (a submitted doc must be cancelled first).
func (*Store) GetDocType ¶
func (*Store) GetDocument ¶
func (*Store) ListDocTypes ¶
func (*Store) ListDocuments ¶
func (s *Store) ListDocuments(ctx context.Context, org, doctype string, opts ListOpts) ([]Document, error)
ListDocuments returns the org's documents of a doctype, applying equality filters (via json_extract, all values BOUND), an order key, and a bounded limit. Every value is a bound parameter and every field name is validated against the doctype's schema before it reaches a json path.
func (*Store) ReplaceDocType ¶
ReplaceDocType replaces an existing DocType's definition (PUT semantics). The documents already stored under it are left intact (a schema change never silently drops data); the next write validates against the new schema.
func (*Store) RevokeRole ¶
func (*Store) SeedOwnerIfUnowned ¶
SeedOwnerIfUnowned atomically grants `user` the System Manager role IFF the org has NO role assignment yet — the trust-on-first-use owner seed. It is a SINGLE conditional INSERT (INSERT ... SELECT ... WHERE NOT EXISTS), so the "is the org unowned?" test and the insert are one statement: under concurrency EXACTLY ONE caller's row lands (the rest match an org that now has a role and insert nothing). Returns whether THIS caller became the seeded owner (RowsAffected==1).
This replaces a check-then-insert (a SELECT for existing roles, then an INSERT) whose window let several simultaneous first-callers each seed themselves System Manager (Red measured 3–6). A UNIQUE index is deliberately NOT used: multiple SMs are legitimate later, granted explicitly via AssignRole — only the AUTOMATIC first-seed must be singular.
func (*Store) SetDocStatus ¶
func (s *Store) SetDocStatus(ctx context.Context, org, doctype, name string, from, to int) (Document, error)
SetDocStatus transitions a document from `from` to `to` atomically, verifying the current status equals `from` (else errBadState). This is the ONE path for submit (0→1) and cancel (1→2); the check-and-set is inside a transaction so two concurrent submits can't both win.
func (*Store) UpdateDocument ¶
func (s *Store) UpdateDocument(ctx context.Context, org string, dt *DocType, name string, data map[string]any) (Document, error)
UpdateDocument replaces a document's data. Only a DRAFT (docstatus 0) is editable — a submitted/cancelled document is immutable (errBadState) so the submit lifecycle can't be bypassed by a plain PUT. `data` is already validated.