Documentation
¶
Overview ¶
Package contributions implements the SDK's host-side contribution slots: a host module declares an extension point with ms.Provide, the SDK auto-mounts HTTP endpoints for register/unregister/list, and other modules register payloads against the slot via the Contribute call (or direct HTTP).
Storage shape (per host module):
<modulePrefix>_contributions (
slot text NOT NULL,
contribution_id text NOT NULL,
payload jsonb NOT NULL,
registered_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (slot, contribution_id)
)
The host names the table prefix once (Config.ID); the table itself is per-APP, because the rows are (one app's copy of) the contributions registered into that app's installs. So it is created per (app, module) by the platform's lifecycle install/upgrade hook — see core.Module's lifecycleProvisioner — never once per process: one deployed container serves many apps and many schemas. Storage.WithTable heals an install that predates that hook on first use.
Index ¶
- Constants
- Variables
- func CompilePayloadValidator(schemaDocument json.RawMessage) (func(json.RawMessage) error, error)
- type Contribution
- type DBFunc
- type Handlers
- type Registry
- type Slot
- type SlotInfo
- type Storage
- func (s *Storage) Delete(ctx context.Context, q db.Querier, slot, id string) error
- func (s *Storage) EnsureTable(ctx context.Context, q db.Querier) error
- func (s *Storage) List(ctx context.Context, q db.Querier, slot string) ([]Contribution, error)
- func (s *Storage) TableName() string
- func (s *Storage) Upsert(ctx context.Context, q db.Querier, slot, id string, payload json.RawMessage) error
- func (s *Storage) WithTable(ctx context.Context, q db.Querier, op func() error) error
Constants ¶
const MaxPayloadBytes = 16 * 1024
MaxPayloadBytes caps the body size for register requests. Slot payloads are config metadata (icon, label, login_path) — small. A 16 KB cap keeps a misbehaving contributor from blowing up the host's DB row size while leaving headroom for moderately rich shapes (e.g. arrays of column definitions for users-table-columns).
Variables ¶
Functions ¶
func CompilePayloadValidator ¶
func CompilePayloadValidator(schemaDocument json.RawMessage) (func(json.RawMessage) error, error)
CompilePayloadValidator turns a manifest payload schema into the validator used by both host registration and SDK compatibility tests.
Types ¶
type Contribution ¶
type Contribution struct {
// OwnerModuleID is the canonical UUID of the module that registered the
// contribution. The platform supplies this install identity; it is not a
// contributor-controlled field inside Payload.
OwnerModuleID string `json:"ownerModuleId"`
// ID is the compatibility alias for OwnerModuleID. Both are populated from
// the same stored key.
// Deprecated: Use OwnerModuleID.
ID string `json:"id"`
Payload json.RawMessage `json:"payload"`
RegisteredAt time.Time `json:"registered_at"`
}
Contribution is a stored row, returned by the list endpoint.
func (Contribution) Decode ¶
func (c Contribution) Decode(target any) error
Decode strictly decodes the contribution payload into target, rejecting undeclared fields and a second/trailing JSON value.
type DBFunc ¶
DBFunc supplies a database connection scoped to the request. The SDK already exposes ms.DB; the handlers take it as an interface so tests can inject a fake.
type Handlers ¶
type Handlers struct {
// contains filtered or unexported fields
}
Handlers wires the registry + storage + DB accessor into chi handlers the SDK can mount under /__mirrorstack/contrib.
func NewHandlers ¶
NewHandlers constructs a handler set.
type Registry ¶
type Registry struct {
// contains filtered or unexported fields
}
Registry holds all declared slots for one module. Populated at Provide time; consulted by the HTTP handlers + auto-mount path to figure out which routes to expose.
func (*Registry) Define ¶
Define stores the slot. Re-defining the same key panics — slot declarations are static at module init.
type Slot ¶
type Slot struct {
Key string
// contains filtered or unexported fields
}
Slot is one declared contribution point. The validator closure is produced by Provide generic so the SDK can typecheck incoming payloads against the host's declared T at register time — without the storage layer needing to know what T is.
func NewSlot ¶
func NewSlot(key, schemaTag string, payloadSchema json.RawMessage, validate func(json.RawMessage) error) Slot
NewSlot constructs a Slot with a validator closure. Generic Provide[T] in the parent package builds the closure where T is in scope.
type SlotInfo ¶
type SlotInfo struct {
Key string `json:"key"`
SchemaTag string `json:"schemaTag,omitempty"`
// Payload is the full recursive JSON Schema of the slot's payload type.
// The validator is a closure that lives only in the host process; without
// this manifest metadata a contributor can discover the slot but not what
// payload to send.
Payload json.RawMessage `json:"payload,omitempty"`
}
SlotInfo is the manifest-shaped projection of a Slot — no closure, just metadata the manifest serializes.
type Storage ¶
type Storage struct {
// contains filtered or unexported fields
}
Storage wraps the contributions table CRUD. Sanitized name is computed once at construction so the SQL builders don't pay the identifier-escape cost per call.
func NewStorage ¶
NewStorage constructs a Storage rooted at the given module ID. modulePrefix is validated upstream against moduleIDPattern in core.New(), so Sanitize is belt-and-suspenders.
func (*Storage) Delete ¶
Delete removes a contribution. Returns pgx.ErrNoRows when the row didn't exist; the HTTP layer maps that to 404 (double-delete is not silently idempotent here).
func (*Storage) EnsureTable ¶
EnsureTable creates the store in whatever schema q's search_path resolves to. The store is per-APP (one table per module per app schema), so every caller must already be scoped to one app — there is no once-per-process shortcut: a deployed module's container serves many apps.
Callers: the lifecycle install/upgrade hook (the platform's per-(app, module) window, which is what provisions a deployed install), the per-app lazy dev provisioner, and WithTable's retry (which heals an install that predates the hook). All are idempotent.
CONCURRENCY. CREATE TABLE IF NOT EXISTS is idempotent but NOT atomic — the existence check and the catalog insert race, so two cold containers provisioning the same app at the same instant can both pass the check and then collide in pg_class. Two things prevent that, deliberately both:
- The advisory lock serializes provisioning per SCHEMA. Keyed on the resolved schema rather than on the module because the store is per-app: two apps must not queue behind each other, two cold starts for the SAME app must. It is the xact-scoped variant so it is released by the implicit transaction that ends this Exec, never leaked onto a pooled connection.
- pgerr.RelationAlreadyExists is the backstop for what the lock cannot cover — a psql session, or any future non-SDK writer — at the cost of one error-code comparison.
The three statements MUST stay in one argument-less Exec: pgx sends that over the simple protocol, which Postgres runs as a single implicit transaction, and that is the only thing making pg_advisory_xact_lock outlive its own statement and still cover the CREATEs.
func (*Storage) List ¶
List returns up to listLimit contributions registered against `slot`, newest first.
func (*Storage) TableName ¶
TableName returns the SQL-safe (already sanitized + quoted) contributions table name. Exposed for test diagnostics.
func (*Storage) Upsert ¶
func (s *Storage) Upsert(ctx context.Context, q db.Querier, slot, id string, payload json.RawMessage) error
Upsert writes a contribution. Idempotent on (slot, contribution_id).
func (*Storage) WithTable ¶
WithTable runs op and, when op failed because the store does not exist in this app's schema, creates it and runs op once more.
This is the heal path for an install that predates the lifecycle provisioning hook: the platform only opens an install/upgrade window when a migration watermark moves, so an app already installed at the current version gets no further lifecycle call and would otherwise never acquire the table. 42P01 is the only error it acts on — the one code that means "never provisioned for this app" rather than "this query is wrong" — so a provisioned store pays nothing and a permission failure still surfaces to the caller.
q MUST NOT be inside a caller-owned transaction: the failed statement aborts it, so neither the CREATE nor the retry could run. Both call sites (the /contrib handlers and Module.StoredContributions) hold a plain pooled connection for exactly that reason.