framework

package
v1.801.256 Latest Latest
Warning

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

Go to latest
Published: Jul 27, 2026 License: Apache-2.0 Imports: 26 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.Org) 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

View Source
var (
	ErrNotFound = errNotFound
	ErrConflict = errConflict
	ErrBadRef   = errBadRef
)

Exported error sentinels let an in-process caller (e.g. the content lane) classify the errors Ingest/Get/UpdateData/Search return WITHOUT string-matching. They alias the package's internal sentinels — the ONE definition stays in store.go, these are just the public handles.

Functions

func AlwaysOnModules added in v1.801.191

func AlwaysOnModules() []string

AlwaysOnModules returns the sorted set of modules marked always-on — introspection for a catalog / link-guard test.

func Delete added in v1.800.1

func Delete(ctx context.Context, org, doctype, name string) error

Delete removes a document in-process — the twin of the HTTP DELETE: it loads the document, runs the on_trash gate hooks (a returned error aborts the delete), then removes the row. `org` MUST be a validated tenant the caller already resolved. A first-party subsystem (the KB lane reconciling wikilink edges when a page is re-saved or trashed) uses it so edge cleanup runs the SAME lifecycle path as any other delete — never a forked write path. It returns ErrNotFound when the document does not exist (idempotent from the caller's view: a missing edge is a no-op).

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 IsValidationError added in v1.786.216

func IsValidationError(err error) bool

IsValidationError reports whether err is a document-schema violation (the 400-class error validateDoc returns), so an in-process caller maps it to Bad Request rather than a 500 — the same *validationError the HTTP layer maps via mapErr.

func MarkAlwaysOn added in v1.801.191

func MarkAlwaysOn(module string)

MarkAlwaysOn marks a registered module as ALWAYS-ON: its DocType fixtures resolve for EVERY org without a per-org install — GetDocType, ListDocTypes, and the Installed predicate all satisfy them for an org that never ran the install step. A standard lane (the Guide's marketing content model) calls this from its init() right after RegisterModule, the same self-declaration pattern, so a fresh org's journey works with no manual setup.

SCOPE — it changes only SCHEMA availability, NEVER tenant data isolation. Every document row stays physically org-scoped (the `org` column on every query); a resolved always-on DocType only says "this schema exists for you", it exposes no other org's records. A per-org stored DocType (a customization) always overrides the fixture (see GetDocType). See always_on_isolation_test.go for the cross-tenant proof.

func ModuleInstalled added in v1.801.218

func ModuleInstalled(ctx context.Context, org, module string) bool

ModuleInstalled reports whether the content model of `module` resolves for `org` — the org installed the lane (or the lane is always-on). It is the module-granularity sibling of Installed: an observe/growth reader asks "does this org run cms/erp?" without importing the framework store. A module is present when ANY of its registered DocType fixtures resolves for the org (GetDocType satisfies an always-on fixture for every org and an opt-in fixture only where the org ran the install). Org-scoped (each GetDocType keys on `org`) and nil-safe: an unmounted framework, an unknown module, or a store miss all yield false — never a spurious true and never any org's data (only the boolean).

func Mount

func Mount(app cloud.Router, 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 RegisteredHookCount added in v1.786.216

func RegisteredHookCount() int

RegisteredHookCount returns the number of (doctype, action) keys carrying at least one registered hook. Exported so the composition root's link-guard test can assert an app lane's lifecycle hooks — notably erp's ledger-posting hooks — are compiled into the binary. Hooks register from a package init() SEPARATELY from module DocTypes; asserting the module alone would stay green if a future refactor split registerHooks() into its own file whose blank import got dropped.

func RegisteredModules added in v1.786.216

func RegisteredModules() []string

RegisteredModules returns the sorted set of registered content-module names. Exported so the composition root's link-guard test can assert the app lanes (cms/erp/help) are compiled into the binary — their fixtures and hooks register from a package init(), so a missing blank import silently empties this set.

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 Get added in v1.786.216

func Get(ctx context.Context, org, doctype, name string) (Document, error)

Get returns a single document by name in (org, doctype) — the read-one twin of Search for a first-party in-process reader (the content lane reads the current document to compute a lifecycle transition). `org` MUST be a validated tenant the caller already resolved. It returns ErrNotFound when the document does not exist, so the caller can answer 404 rather than 500.

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.Org). 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 Lease added in v1.786.216

type Lease struct {
	// contains filtered or unexported fields
}

Lease is an acquired exclusive claim on (org, key). Release it when the guarded critical section completes; the TTL is only the crash safety net.

func AcquireLease added in v1.786.216

func AcquireLease(ctx context.Context, org, key string, ttl, wait time.Duration) (*Lease, bool, error)

AcquireLease takes an exclusive lease on (org, key) for up to `ttl`, polling with bounded backoff until it wins or `wait` elapses. It returns:

  • (lease, true, nil) — acquired; the caller owns the critical section and MUST Release when done (a defer is idiomatic);
  • (nil, false, nil) — a LIVE lease stayed held by someone else for the whole `wait` window; the caller answers an honest "in progress"/"retry", never a 5xx;
  • (nil, false, err) — a genuine store failure (or ctx cancellation).

`ttl` MUST exceed the guarded section so a live holder is never pre-empted mid-flight.

func (*Lease) Release added in v1.786.216

func (l *Lease) Release(ctx context.Context) error

Release frees the lease IFF this holder still owns it. Best-effort and idempotent: a lease already reclaimed by TTL is a no-op (holder-scoped delete), and a store error is returned for the caller to log — never to fail the already-completed work. Release with a detached context so a cancelled request still frees the row promptly (rather than leaving it to TTL).

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