Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions migrations/000018_plugin_listings.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
-- 000018_plugin_listings.down.sql
--
-- Reverse of 000018_plugin_listings.up.sql.
--
-- The trigger is dropped implicitly with the table. The trigger
-- function is shared with future marketplace tables (000019–000022),
-- so we drop it here only because this is the lowest-numbered table
-- that depends on it — when the migration tree is unwound in order,
-- 000022 down → 000021 down → … → 000018 down, the function has no
-- remaining users by the time this file runs.
--
-- IF EXISTS so a partial rollback after a failed intermediate state
-- still completes cleanly.

DROP TABLE IF EXISTS plugin_listings;
DROP FUNCTION IF EXISTS marketplace_touch_updated_at();
141 changes: 141 additions & 0 deletions migrations/000018_plugin_listings.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
-- 000018_plugin_listings.up.sql
--
-- Marketplace data model — listings.
--
-- The plugin runtime (Waves D–G) ships a working installer that takes a
-- bundle from disk and lands it in the `plugins` table. That's enough
-- for a local CMS to side-load a plugin a developer hands over. It is
-- emphatically NOT enough for a community marketplace, where multiple
-- third parties publish, version, rate, and install plugins through a
-- shared catalogue.
--
-- This migration and its four siblings (000019–000022) put the
-- marketplace data model in place so that:
--
-- * Publishers can register a *listing* — the public, human-facing
-- identity of a plugin (slug, name, author, license, category).
-- * A listing can have many *versions* — the binary artefacts plus
-- their manifest, integrity hash, and optional signature.
-- * Each version can declare a *compatibility matrix* — the host
-- ABI ranges it's been tested against.
-- * Users can leave *ratings* — one per (version, user), 1–5 stars.
-- * The platform records *install events* — append-only telemetry
-- used by the future marketplace UI for popularity ranking.
--
-- The marketplace UI itself lands later (see the marketplace tracker
-- issue). This PR is data-model + Go store layer only.
--
-- Depends on:
-- * 000001_init — for gen_uuid_v7() and the pgcrypto extension.
-- * 000002_users — for the author_id FK target.

-- =============================================================================
-- plugin_listings
-- =============================================================================
--
-- One row per published-or-publishable plugin. The slug is the
-- public-facing handle that appears in URLs ("/marketplace/gn-seo")
-- and is the join key for every other marketplace table — it lives
-- alongside a UUID PK (per ADR 0003) rather than replacing it, because
-- slugs occasionally need to be renamed and we'd rather keep the FKs
-- pointing at a stable identifier.

CREATE TABLE plugin_listings (
-- Time-sortable UUID v7. Matches the platform's PK convention so
-- joins against `users` (also UUID v7) keep clustering behaviour
-- predictable.
id UUID PRIMARY KEY DEFAULT gen_uuid_v7(),

-- Public-facing handle. Lowercase + hyphens by convention; we don't
-- enforce the shape in the column because the application layer
-- already validates against the manifest schema before insert.
-- UNIQUE so URL lookups can use it as a natural key.
slug TEXT NOT NULL UNIQUE
CHECK (length(slug) > 0 AND length(slug) <= 128),

-- Human-facing display name. Distinct from slug so a listing can
-- rebrand without breaking URLs. Required at insert time; an empty
-- listing card would be useless.
name TEXT NOT NULL
CHECK (length(name) > 0 AND length(name) <= 256),

-- One-line description for catalogue cards. Optional — a brand-new
-- draft may not have copy yet.
summary TEXT,

-- The publishing user. ON DELETE SET NULL so deleting a user
-- preserves the catalogue (the listing becomes "unowned" and the
-- moderation team can re-assign or delist it).
author_id UUID
REFERENCES users(id) ON DELETE SET NULL,

-- Optional project page. Plain text rather than a constrained URL
-- type because the validation surface lives in the application.
homepage_url TEXT,

-- SPDX licence identifier ("MIT", "Apache-2.0", "GPL-3.0-only", …).
-- Stored as opaque text — the SPDX catalogue is large and evolves
-- faster than schema migrations.
license_spdx TEXT,

-- Primary category for catalogue browsing ("seo", "analytics",
-- "editor-extension", …). Free-form text rather than an enum so
-- new categories can be introduced without a schema change.
primary_category TEXT,

-- Lifecycle:
-- draft — owner is still preparing the listing; not visible
-- in the catalogue.
-- listed — visible in the catalogue, installable.
-- delisted — temporarily hidden by the owner or platform; the
-- existing installs continue to work but no new
-- discovery happens.
-- banned — permanent moderation action. Distinct from delisted
-- so the audit trail records intent.
status TEXT NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft','listed','delisted','banned')),

created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

COMMENT ON TABLE plugin_listings IS
'Public-facing plugin catalogue row. Owns the slug, name, author, and lifecycle status.';
COMMENT ON COLUMN plugin_listings.slug IS 'Public handle, unique. Appears in marketplace URLs.';
COMMENT ON COLUMN plugin_listings.status IS 'Lifecycle: draft | listed | delisted | banned.';
COMMENT ON COLUMN plugin_listings.primary_category IS 'Free-form category string for catalogue browsing; not an enum.';

-- Browsing the catalogue by category is the dominant read pattern;
-- the partial index covers only listed rows because draft/delisted/
-- banned are out of the catalogue's main view.
CREATE INDEX plugin_listings_category_idx
ON plugin_listings (primary_category)
WHERE status = 'listed';

-- "Show me everything by this author" — feeds the publisher dashboard.
CREATE INDEX plugin_listings_author_idx
ON plugin_listings (author_id)
WHERE author_id IS NOT NULL;

-- updated_at touch trigger. We keep a marketplace-local trigger
-- function rather than reusing touch_updated_at_and_version() because
-- the listing row doesn't carry a `version` column (the OCC token only
-- matters for tables that admins read-modify-write through the API; the
-- marketplace store does plain UPDATEs).
CREATE OR REPLACE FUNCTION marketplace_touch_updated_at()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
NEW.updated_at := now();
RETURN NEW;
END;
$$;

COMMENT ON FUNCTION marketplace_touch_updated_at() IS
'BEFORE UPDATE trigger body for marketplace tables that need updated_at but not a version counter.';

CREATE TRIGGER plugin_listings_touch_updated_at
BEFORE UPDATE ON plugin_listings
FOR EACH ROW
EXECUTE FUNCTION marketplace_touch_updated_at();
8 changes: 8 additions & 0 deletions migrations/000019_plugin_versions.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
-- 000019_plugin_versions.down.sql
--
-- Reverse of 000019_plugin_versions.up.sql. The indexes are owned by
-- the table; DROP TABLE removes them. No CASCADE — if a later
-- migration adds an unexpected dependency we want the down to surface
-- the bug rather than silently take the dependents with it.

DROP TABLE IF EXISTS plugin_versions;
91 changes: 91 additions & 0 deletions migrations/000019_plugin_versions.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
-- 000019_plugin_versions.up.sql
--
-- Marketplace data model — versions.
--
-- A listing has one or more versions. The version row is the durable
-- record of an individual artefact upload: the wasm binary's SHA-256
-- digest, the manifest blob that travelled with it, and the optional
-- detached signature that proves provenance.
--
-- The wasm bytes themselves are NOT stored in this table. Production
-- deployments push artefacts to object storage (see the media package
-- + S3/Minio integration) and key the upload by the SHA-256 digest in
-- `wasm_sha256` — the digest is the artefact's content address. Storing
-- multi-megabyte BYTEAs in a relational table is the wrong shape for
-- both rep and query patterns; the digest gives us integrity and
-- deduplication for free.
--
-- Depends on:
-- * 000018_plugin_listings — for the listing_id FK target.

CREATE TABLE plugin_versions (
-- UUID v7 PK. Versions are time-sortable in their own right (newer
-- versions sort after older ones) which lines up with the v7
-- ordering, but the (listing_id, version) UNIQUE below is the
-- natural key callers join on.
id UUID PRIMARY KEY DEFAULT gen_uuid_v7(),

-- The listing this version belongs to. ON DELETE CASCADE because a
-- listing's versions are a tightly-bound child collection — there
-- is no meaningful "orphan version" state.
listing_id UUID NOT NULL
REFERENCES plugin_listings(id) ON DELETE CASCADE,

-- Semantic version string ("1.4.2", "2.0.0-beta.1"). Stored as text
-- because semver's grammar is richer than any single numeric type
-- can express; comparison is done at the application layer using
-- the standard semver library.
version TEXT NOT NULL
CHECK (length(version) > 0 AND length(version) <= 64),

-- SHA-256 digest of the wasm artefact, 32 raw bytes. BYTEA (not
-- TEXT) so equality checks are byte-exact and the row uses 32 + 4
-- bytes of storage rather than 64 + 4 for hex. The Go store
-- computes the digest from the supplied bytes — see Publish in
-- versions.go.
wasm_sha256 BYTEA NOT NULL
CHECK (octet_length(wasm_sha256) = 32),

-- Manifest blob exactly as parsed at publish time. JSONB so we can
-- query it later ("show me every version that declares the `kv`
-- capability") without re-parsing. Default '{}'::JSONB rather than
-- NULL so the column is always projectable.
manifest JSONB NOT NULL DEFAULT '{}'::jsonb,

-- Optional detached signature, hex-encoded. NULL when the publisher
-- didn't sign the artefact (allowed in v1; the marketplace UI will
-- surface a "signed by X" badge only when this column is non-null).
-- TEXT rather than BYTEA because hex is what the manifest carries
-- and what verifier callers want to see in logs.
signature_hex TEXT,

published_at TIMESTAMPTZ NOT NULL DEFAULT now(),

-- When the publisher (or platform) marked this version as
-- deprecated. NULL = current. Deprecated versions remain
-- installable for compat reasons but are surfaced with a banner
-- in the catalogue.
deprecated_at TIMESTAMPTZ,

-- A listing cannot publish the same version string twice. This is
-- both a publisher-facing invariant ("you already shipped 1.4.2")
-- and the join key the compat matrix table uses.
UNIQUE (listing_id, version)
);

COMMENT ON TABLE plugin_versions IS
'One row per published artefact. Owns the integrity digest and manifest, not the wasm bytes themselves.';
COMMENT ON COLUMN plugin_versions.wasm_sha256 IS 'SHA-256 of the wasm artefact. Content-addresses the upload in object storage.';
COMMENT ON COLUMN plugin_versions.signature_hex IS 'Hex-encoded detached signature, optional. Absence means unsigned, not invalid.';
COMMENT ON COLUMN plugin_versions.deprecated_at IS 'NULL = current. Deprecated versions remain installable but flagged in the catalogue.';

-- "Show me every version of listing X" — the dominant read pattern,
-- ordered by published_at DESC so the most recent release is the first
-- row. Compound (listing_id, published_at) so the order is index-served.
CREATE INDEX plugin_versions_listing_published_idx
ON plugin_versions (listing_id, published_at DESC);

-- Reverse lookup by content hash: "is this exact artefact already
-- published anywhere?" Used by the dedupe check in Publish.
CREATE INDEX plugin_versions_sha256_idx
ON plugin_versions (wasm_sha256);
6 changes: 6 additions & 0 deletions migrations/000020_plugin_compat_matrix.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
-- 000020_plugin_compat_matrix.down.sql
--
-- Reverse of 000020_plugin_compat_matrix.up.sql. DROP TABLE takes the
-- index with it.

DROP TABLE IF EXISTS plugin_compat_matrix;
65 changes: 65 additions & 0 deletions migrations/000020_plugin_compat_matrix.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
-- 000020_plugin_compat_matrix.up.sql
--
-- Marketplace data model — compatibility matrix.
--
-- A plugin version declares which host ABI ranges it has been tested
-- against. The marketplace UI surfaces this so a user running host
-- ABI 3 can see at a glance whether a plugin "Works", "Should work
-- (untested)", or "Not compatible".
--
-- A version may declare multiple ranges — e.g. tested against 1.x and
-- 3.x but not 2.x (perhaps because of a known regression that was
-- fixed in 3.0). Hence the composite PK on (plugin_version_id,
-- host_min, host_max) rather than a single row per version.
--
-- Depends on:
-- * 000019_plugin_versions — for the plugin_version_id FK target.

CREATE TABLE plugin_compat_matrix (
-- The version this row describes. ON DELETE CASCADE because compat
-- claims have no meaning outside the context of their version.
plugin_version_id UUID NOT NULL
REFERENCES plugin_versions(id) ON DELETE CASCADE,

-- Minimum host ABI version this range applies to (inclusive).
-- TEXT rather than INT because the host versioning scheme is
-- semver-shaped and we want operators to be able to declare
-- "1.0.0" vs "1.4.2" granularity in the future.
host_min TEXT NOT NULL
CHECK (length(host_min) > 0 AND length(host_min) <= 64),

-- Maximum host ABI version (inclusive). Empty string is rejected;
-- "any" callers should use a high sentinel like "999.0.0".
host_max TEXT NOT NULL
CHECK (length(host_max) > 0 AND length(host_max) <= 64),

-- Whether the publisher actually ran the plugin against this
-- range. FALSE means "the range is declared compatible but we
-- haven't exercised it under CI". The marketplace UI shows this
-- as a distinct badge.
tested BOOLEAN NOT NULL DEFAULT FALSE,

-- Compound PK: a version may declare multiple ranges, but each
-- (min, max) tuple appears at most once per version. This lets
-- callers UPSERT on the same (min, max) when they re-publish the
-- matrix.
PRIMARY KEY (plugin_version_id, host_min, host_max),

-- Sanity: host_min <= host_max under lexicographic comparison.
-- Lex isn't perfect for semver ("10" sorts before "9") — the Go
-- store performs the real semver comparison before insert. This
-- CHECK catches the obvious operator-typo class only.
CHECK (host_min <= host_max)
);

COMMENT ON TABLE plugin_compat_matrix IS
'Per-version host ABI compatibility claims. Composite key allows multiple disjoint ranges per version.';
COMMENT ON COLUMN plugin_compat_matrix.tested IS
'TRUE = exercised under CI by the publisher. FALSE = declared compatible but unverified.';

-- Reverse lookup: "given a host running version X, which plugin
-- versions claim to support it?" This is the query the marketplace
-- filter UI runs when the user toggles "compatible with my host".
-- (host_min, host_max) is the natural index for the range check.
CREATE INDEX plugin_compat_matrix_range_idx
ON plugin_compat_matrix (host_min, host_max);
5 changes: 5 additions & 0 deletions migrations/000021_plugin_ratings.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- 000021_plugin_ratings.down.sql
--
-- Reverse of 000021_plugin_ratings.up.sql.

DROP TABLE IF EXISTS plugin_ratings;
Loading
Loading