Documentation
¶
Overview ¶
Package storage implements the form-data storage strategies for the approval module. A FlowVersion's StorageMode selects, at publish time, where the instances' form data physically lives:
StorageJSON keeps form data only in apv_instance.form_data (JSONB). The JSONStorage strategy is a no-op for both publish and write — the command handlers already persist that column.
StorageTable additionally projects each instance's form data into dedicated physical tables generated for that version (a main table plus one child table per detail-table field, immutable once published). apv_instance.form_data stays populated so every existing read path (validation, engine, get_instance_detail) keeps working unchanged; the physical tables are the structured, queryable copy.
The Dispatcher picks the strategy from version.StorageMode and is the only type the command handlers depend on.
Index ¶
- Variables
- func ValidateTableFormSchema(fields []approval.FormFieldDefinition) error
- type Dispatcher
- func (d *Dispatcher) For(mode approval.StorageMode) FormStorage
- func (d *Dispatcher) ProvisionTable(ctx context.Context, db orm.DB, flow *approval.Flow, ...) error
- func (d *Dispatcher) RecordMetadata(ctx context.Context, db orm.DB, flow *approval.Flow, ...) error
- func (d *Dispatcher) SyncInstanceProjection(ctx context.Context, db orm.DB, instance *approval.Instance) error
- type FormStorage
- type JSONStorage
- func (*JSONStorage) ProvisionTable(context.Context, orm.DB, *approval.Flow, *approval.FlowVersion) error
- func (*JSONStorage) RecordMetadata(context.Context, orm.DB, *approval.Flow, *approval.FlowVersion) error
- func (*JSONStorage) Write(context.Context, orm.DB, *approval.Flow, *approval.FlowVersion, string, ...) error
- type TableStorage
Constants ¶
This section is empty.
Variables ¶
var ( // ErrInvalidDetailValue indicates a detail-table field's runtime value is // not the list-of-row-objects shape the projection expects. Submit // validation rejects this earlier; hitting it here means corrupt data. ErrInvalidDetailValue = errors.New("approval/storage: invalid detail-table value") // ErrUnsupportedDialect indicates the configured database dialect has no // type mapping for generating dynamic form tables. ErrUnsupportedDialect = errors.New("approval/storage: unsupported database dialect") // ErrInvalidGeneratedIdentifier indicates a generated table or column name // failed approval.ValidateBusinessIdentifier. It signals a form schema whose // field keys cannot be turned into safe SQL identifiers; the publish that // triggered generation is failed rather than emitting unsafe DDL. ErrInvalidGeneratedIdentifier = errors.New("approval/storage: generated identifier is not SQL-safe") // ErrReservedColumnName indicates a form field key maps to one of the // built-in physical columns (id / instance_id / row_index / created_at). ErrReservedColumnName = errors.New("approval/storage: field key collides with a reserved column") // ErrDuplicateColumnName indicates two distinct field keys sanitized to the // same physical column name. ErrDuplicateColumnName = errors.New("approval/storage: duplicate generated column name") // ErrFormTableMetadataMissing indicates a table-mode instance write found no // apv_form_table row for the version. Publish provisions it, so its absence // is a real inconsistency rather than an expected branch. ErrFormTableMetadataMissing = errors.New("approval/storage: form table metadata missing for version") // ErrInvalidColumnType indicates a form field declares an explicit ColumnType // that is not a defined approval.ColumnDataType. Caught at deploy so a bad // column-type override surfaces when the admin saves, not opaquely at publish. ErrInvalidColumnType = errors.New("approval/storage: field has an invalid column data type") )
var Module = fx.Module( "vef:approval:storage", fx.Provide( fx.Private, NewJSONStorage, newTableStorage, ), fx.Provide( NewDispatcher, ), )
Module provides the form-data storage strategies and their Dispatcher. The TableStorage strategy is bound to the primary data source's dialect — the same dialect the approval migration module targets — so its generated DDL matches the schema the rest of the module migrates into.
Functions ¶
func ValidateTableFormSchema ¶
func ValidateTableFormSchema(fields []approval.FormFieldDefinition) error
ValidateTableFormSchema verifies that every form field key in a table-mode field list can be turned into a safe, unique, non-reserved physical column identifier. It is the deploy-time gate for StorageTable: calling it when a version is deployed surfaces an unmappable field key as a configuration error the admin sees on save, instead of deferring the failure to publish (where it would surface opaquely). The publish-time generator re-derives and re-checks the same identifiers as defense-in-depth, so this never weakens the guarantee.
Types ¶
type Dispatcher ¶
type Dispatcher struct {
// contains filtered or unexported fields
}
Dispatcher routes FormStorage calls to the JSON or Table strategy based on a version's StorageMode. An unknown or empty StorageMode resolves to JSON so a version created before storage modes were plumbed (or with a blank value) keeps the original JSON-only behavior.
func NewDispatcher ¶
func NewDispatcher(jsonStorage *JSONStorage, tableStorage *TableStorage) *Dispatcher
NewDispatcher builds a Dispatcher over the two concrete strategies.
func (*Dispatcher) For ¶
func (d *Dispatcher) For(mode approval.StorageMode) FormStorage
For returns the FormStorage strategy for a storage mode.
func (*Dispatcher) ProvisionTable ¶
func (d *Dispatcher) ProvisionTable(ctx context.Context, db orm.DB, flow *approval.Flow, version *approval.FlowVersion) error
ProvisionTable dispatches to the strategy for version.StorageMode. The caller must invoke it OUTSIDE the publish transaction (see FormStorage.ProvisionTable).
func (*Dispatcher) RecordMetadata ¶
func (d *Dispatcher) RecordMetadata(ctx context.Context, db orm.DB, flow *approval.Flow, version *approval.FlowVersion) error
RecordMetadata dispatches to the strategy for version.StorageMode. The caller must invoke it INSIDE the publish transaction so metadata commits with the version's published state.
func (*Dispatcher) SyncInstanceProjection ¶
func (d *Dispatcher) SyncInstanceProjection(ctx context.Context, db orm.DB, instance *approval.Instance) error
SyncInstanceProjection refreshes the table-mode physical projection for an instance from its current form data. It resolves the storage mode by loading the instance's version, so a caller that has just mutated form_data need not thread the version or flow through: one call after persisting form_data keeps the physical projection in lockstep with apv_instance.form_data. JSON mode is a no-op. Every form_data mutation funnels through this single entry point, so no write path can forget to refresh the projection.
type FormStorage ¶
type FormStorage interface {
// ProvisionTable creates whatever durable physical structure the mode
// needs (TableStorage issues the DDL statements). It runs OUTSIDE the publish
// transaction, on its own connection: a DDL statement implicitly commits
// the active transaction on MySQL, so issuing it inside the publish
// transaction would silently break that transaction's atomicity. It must be
// idempotent (CREATE TABLE IF NOT EXISTS) so a retry after a rolled-back
// publish reuses the structure. JSONStorage is a no-op.
ProvisionTable(ctx context.Context, db orm.DB, flow *approval.Flow, version *approval.FlowVersion) error
// RecordMetadata persists the generated structure's metadata
// (apv_form_table / apv_form_table_column). It runs INSIDE the publish
// transaction so the version's published state and its recorded schema
// commit or roll back together. It must be idempotent: republishing — or a
// retry after a partial failure — must not error or duplicate metadata.
// JSONStorage is a no-op.
RecordMetadata(ctx context.Context, db orm.DB, flow *approval.Flow, version *approval.FlowVersion) error
// Write projects a single instance's form data into the mode's storage.
// It runs after the instance row and its form_data column are already
// persisted, so JSONStorage has nothing to do. formData is the full,
// already-validated form payload for the instance. It is idempotent per
// instance: a re-write (resubmit) replaces the prior projection rather than
// appending to it.
Write(ctx context.Context, db orm.DB, flow *approval.Flow, version *approval.FlowVersion, instanceID string, formData map[string]any) error
}
FormStorage abstracts where an approval flow's form data is physically stored. Implementations are selected per version by StorageMode.
Provisioning a version's storage is split into two phases with different transactional requirements. ProvisionTable runs OUTSIDE the publish transaction because DDL cannot take part in a transaction on MySQL (CREATE TABLE triggers an implicit commit of the in-flight transaction); RecordMetadata and Write run INSIDE their caller's transaction so metadata and projection rows commit or roll back with the surrounding operation.
type JSONStorage ¶
type JSONStorage struct{}
JSONStorage is the StorageJSON strategy. Form data lives solely in apv_instance.form_data (JSONB), which every form-writing command handler already persists, so all three strategy methods are no-ops. It exists so the Dispatcher can treat JSON and Table uniformly.
func (*JSONStorage) ProvisionTable ¶
func (*JSONStorage) ProvisionTable(context.Context, orm.DB, *approval.Flow, *approval.FlowVersion) error
ProvisionTable is a no-op: JSON mode needs no extra physical structure.
func (*JSONStorage) RecordMetadata ¶
func (*JSONStorage) RecordMetadata(context.Context, orm.DB, *approval.Flow, *approval.FlowVersion) error
RecordMetadata is a no-op: JSON mode records no projection metadata.
type TableStorage ¶
type TableStorage struct {
// contains filtered or unexported fields
}
TableStorage is the StorageTable strategy. At publish it generates the version's physical tables — a main projection table plus one child table per detail-table field — and records the generated structure in apv_form_table / apv_form_table_column. At write it projects an instance's form data into those tables: exactly one main row, plus one child row per detail line.
The strategy is dialect-aware: DDL and DML are generated for the primary data source's dialect (the same dialect the migration scripts target), mirrored from how the migration module selects scripts by dataSources.Primary().Kind.
Security: every physical table and column name is a validated SQL identifier (approval.ValidateBusinessIdentifier) before it reaches a DDL/DML string, and every form value is bound as a `?` argument — never interpolated. See ddl.go for the identifier pipeline.
func NewTableStorage ¶
func NewTableStorage(kind config.DBKind) *TableStorage
NewTableStorage constructs a TableStorage for the given database dialect.
func (*TableStorage) ProvisionTable ¶
func (s *TableStorage) ProvisionTable(ctx context.Context, db orm.DB, flow *approval.Flow, version *approval.FlowVersion) error
ProvisionTable creates the version's physical form tables. It runs outside the publish transaction (DDL implicitly commits the in-flight transaction on MySQL, which would break the publish's atomicity) and is idempotent: CREATE TABLE IF NOT EXISTS makes a republish — or a retry after a rolled-back publish that left the tables behind — a no-op at the DDL level. The table names and columns are derived from the version's parsed form fields; see ddl.go.
func (*TableStorage) RecordMetadata ¶
func (s *TableStorage) RecordMetadata(ctx context.Context, db orm.DB, flow *approval.Flow, version *approval.FlowVersion) error
RecordMetadata records the generated table's structure in apv_form_table / apv_form_table_column. It runs inside the publish transaction so the metadata commits or rolls back with the version's published state. It is idempotent: if metadata already exists for the version (a republish or a retry), it returns without re-inserting. The table name and column specs are recomputed from the version's parsed form fields — the same deterministic derivation ProvisionTable used, so the recorded metadata always describes the physical table.
func (*TableStorage) Write ¶
func (*TableStorage) Write(ctx context.Context, db orm.DB, _ *approval.Flow, version *approval.FlowVersion, instanceID string, formData map[string]any) error
Write projects an instance's form data into the version's physical form tables: exactly one row in the main table, one row per detail line in each child table. It is idempotent per instance: each table's existing rows for the instance are deleted before fresh ones are inserted, so the first projection (at start) and every later one (at resubmit) leave the tables reflecting the instance's current form data — never an accumulating history. The main table's instance_id UNIQUE constraint backs the one-row contract at the database level.
The physical table names and the set of generated columns are read from the metadata recorded at publish (the single source of truth), so Write and the publish-time generation can never disagree on identifiers. Only columns sourced from a form field are populated from formData; instance_id is set from the argument and created_at defaults at the database. Every value is bound as a `?` argument.