storage

package
v0.32.2 Latest Latest
Warning

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

Go to latest
Published: Jun 30, 2026 License: Apache-2.0 Imports: 12 Imported by: 0

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 existing start/resubmit code already persists that column.

  • StorageTable additionally projects each instance's form data into a dedicated physical table generated for that version (one table per published version, immutable). apv_instance.form_data stays populated so every existing read path (validation, engine, get_instance_detail) keeps working unchanged; the physical table is the structured, queryable copy.

The Dispatcher picks the strategy from version.StorageMode and is the only type the command handlers depend on.

Index

Constants

This section is empty.

Variables

View Source
var (
	// 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 / 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")
)
View Source
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 migration.go targets — so its generated DDL matches the schema the rest of the module migrates into.

Functions

func ValidateTableFormSchema

func ValidateTableFormSchema(schema *approval.FormDefinition) error

ValidateTableFormSchema verifies that every form field key in a table-mode schema 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) behaves exactly as today.

func NewDispatcher

func NewDispatcher(jsonStorage *JSONStorage, tableStorage *TableStorage) *Dispatcher

NewDispatcher builds a Dispatcher over the two concrete strategies.

func (*Dispatcher) For

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 CREATE TABLE). 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 a second row.
	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 the start/resubmit handlers already write, so both lifecycle hooks are no-ops. It exists so the Dispatcher can treat JSON and Table uniformly.

func NewJSONStorage

func NewJSONStorage() *JSONStorage

NewJSONStorage constructs a JSONStorage.

func (*JSONStorage) ProvisionTable

ProvisionTable is a no-op: JSON mode needs no extra physical structure.

func (*JSONStorage) RecordMetadata

RecordMetadata is a no-op: JSON mode records no projection metadata.

func (*JSONStorage) Write

Write is a no-op: the instance's form_data column is the storage.

type TableStorage

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

TableStorage is the StorageTable strategy. At publish it generates a dedicated physical table for the version's form schema and records the generated structure in apv_form_table / apv_form_table_column. At write it projects an instance's form data into that table as exactly one row.

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 migration.go 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 table. 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 table behind — a no-op at the DDL level. The table name and columns are derived from the version's form schema; 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 form schema — 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 table as exactly one row. It is idempotent per instance: any existing row for the instance is deleted before the fresh row is inserted, so the first projection (at start) and every later one (at resubmit) leave the table reflecting the instance's current form data — never an accumulating history. The instance_id UNIQUE constraint backs this contract at the database level.

The physical table name and the set of generated columns are read from the metadata recorded at publish (the single source of truth), so Write and OnVersionPublished 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.

Jump to

Keyboard shortcuts

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