contributions

package
v0.4.17 Latest Latest
Warning

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

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

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

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

View Source
var (
	ErrSlotNotDefined = errors.New("contributions: slot not defined")
	ErrInvalidPayload = errors.New("contributions: invalid payload")
	ErrEmptyKey       = errors.New("contributions: slot key is empty")
	ErrEmptyID        = errors.New("contributions: contribution id is empty")
)

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

type DBFunc func(r *http.Request) (db.Querier, func(), error)

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

func NewHandlers(registry *Registry, storage *Storage, openDB DBFunc) *Handlers

NewHandlers constructs a handler set.

func (*Handlers) Routes

func (h *Handlers) Routes() chi.Router

Routes returns a chi router scoped under /__mirrorstack/contrib. Caller is responsible for applying auth middleware (Internal scope is the expected wrapper — contributions move trusted module-to- module so the secret gate guards the write path).

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 NewRegistry

func NewRegistry() *Registry

NewRegistry returns an empty Registry.

func (*Registry) Define

func (r *Registry) Define(s Slot) error

Define stores the slot. Re-defining the same key panics — slot declarations are static at module init.

func (*Registry) Get

func (r *Registry) Get(key string) (Slot, bool)

Get looks up a slot by key. Returns (Slot{}, false) if undefined.

func (*Registry) Len

func (r *Registry) Len() int

Len returns the number of declared slots. Lets callers decide "is this module accepting any contributions?" without paying the allocation cost of List() — used in core.Module.Start to skip the EnsureTable round-trip when no slots are declared.

func (*Registry) List

func (r *Registry) List() []SlotInfo

List returns every declared slot's manifest projection, sorted by key for stable output. Single RLock for the whole walk so no concurrent Define can change the map between key collection and row read.

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.

func (Slot) SchemaTag

func (s Slot) SchemaTag() string

SchemaTag returns the Go-type-name hint surfaced on the manifest.

func (Slot) Validate

func (s Slot) Validate(payload json.RawMessage) error

Validate runs the per-slot JSON validator against an incoming payload. Wraps ErrInvalidPayload so handlers can sniff for a 400 without leaking internal type info to the caller.

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

func NewStorage(modulePrefix string) *Storage

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

func (s *Storage) Delete(ctx context.Context, q db.Querier, slot, id string) error

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

func (s *Storage) EnsureTable(ctx context.Context, q db.Querier) error

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

func (s *Storage) List(ctx context.Context, q db.Querier, slot string) ([]Contribution, error)

List returns up to listLimit contributions registered against `slot`, newest first.

func (*Storage) TableName

func (s *Storage) TableName() string

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

func (s *Storage) WithTable(ctx context.Context, q db.Querier, op func() error) error

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.

Jump to

Keyboard shortcuts

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