framework

package
v1.786.72 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 4, 2026 License: Apache-2.0 Imports: 25 Imported by: 0

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

View Source
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).

View Source
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
)
  • Trust & scope
  • When hooks run (deadlock-safe, gate-style)

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.

View Source
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

func FindByField(ctx context.Context, org, doctype, field, value string) (string, error)

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

func Installed(ctx context.Context, org, doctype string) bool

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 Mount

func Mount(app *zip.App, deps cloud.Deps) error

Mount wires the framework surface onto app per HIP-0106.

func RegisterHook

func RegisterHook(doctype, action string, fn Hook)

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

func RegisterModule(module string, fixtures []DocType)

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 Shutdown

func Shutdown() error

Shutdown closes the framework store. Idempotent.

func UpdateData added in v1.786.72

func UpdateData(ctx context.Context, org, doctype, name string, data map[string]any) error

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.

func (*DocType) Validate

func (d *DocType) Validate() error

Validate checks a DocType definition is well-formed. A malformed schema is rejected at define time (400) so the document validator can trust it later — validation is done once, at the boundary, not re-litigated per document.

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(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

type Hook func(ctx context.Context, ev *Event) error

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

type Ingested struct {
	Org     string
	DocType string
	Name    string
}

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 Role

type Role struct {
	User string `json:"user"`
	Role string `json:"role"`
}

Role is a (user, role) assignment within an org.

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 (s *Store) AssignRole(ctx context.Context, org, user, role string) error

func (*Store) Close

func (s *Store) Close() error

Close closes the underlying database. Idempotent-safe via sql.DB.

func (*Store) CountDocuments

func (s *Store) CountDocuments(ctx context.Context, org, doctype string) (int, error)

CountDocuments returns the org's document count for a doctype (real, per-org).

func (*Store) CreateDocType

func (s *Store) CreateDocType(ctx context.Context, org string, dt DocType) (DocType, error)

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

func (s *Store) DeleteDocType(ctx context.Context, org, name string) (bool, error)

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

func (s *Store) DeleteDocument(ctx context.Context, org, doctype, name string) (bool, error)

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 (s *Store) GetDocType(ctx context.Context, org, name string) (DocType, error)

func (*Store) GetDocument

func (s *Store) GetDocument(ctx context.Context, org, doctype, name string) (Document, error)

func (*Store) ListDocTypes

func (s *Store) ListDocTypes(ctx context.Context, org string) ([]DocType, error)

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) ListRoles

func (s *Store) ListRoles(ctx context.Context, org string) ([]Role, error)

func (*Store) ReplaceDocType

func (s *Store) ReplaceDocType(ctx context.Context, org string, dt DocType) (DocType, error)

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 (s *Store) RevokeRole(ctx context.Context, org, user, role string) (bool, error)

func (*Store) RolesFor

func (s *Store) RolesFor(ctx context.Context, org, user string) ([]string, error)

RolesFor returns the roles assigned to a user in an org.

func (*Store) SeedOwnerIfUnowned

func (s *Store) SeedOwnerIfUnowned(ctx context.Context, org, user string) (bool, error)

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.

func (*Store) UpsertSingle

func (s *Store) UpsertSingle(ctx context.Context, org string, dt *DocType, data map[string]any) (Document, error)

UpsertSingle writes THE single document of a Single DocType (name == doctype name), creating or replacing it. A Single never has more than one row per org.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL