Module-owned versioned migrations for phoenix_kit_catalogue — the
decentralized-migrations protocol that core's mix phoenix_kit.update
discovers via migration_module/0: current_version/0 +
migrated_version_runtime/1 + idempotent up/1 + version-aware
down/1. phoenix_kit_crm (ten adopted tables) is the closest sibling
example of this exact situation, scaled up further to eighteen tables.
What V01 is
V01 is an ADOPTION step for all eighteen phoenix_kit_cat_* tables that
core already creates (V135: catalogues, categories, items,
manufacturers, manufacturer_suppliers, suppliers, folders,
item_catalogue_rules, pdfs, pdf_pages, pdf_page_contents,
pdf_extractions; V149: item_supplier_info; V173: attribute_groups,
attributes, attribute_values, item_attribute_groups; V177:
item_attribute_sets).
Those four versions only CREATE the tables; five more reshape them, and the DDL below is the sum of all nine — check every one of them when auditing a table's shape, not just its creator:
- V146 adds
items.primary_supplier_uuid(+ its partial index and FK); - V151 adds
item_supplier_info.supplier_source/is_primary(+ the source CHECK and the one-primary-per-item partial unique); - V178 adds
manufacturers.crm_company_uuid(+ the partial unique indexes on both directories'crm_company_uuid); - V179 adds
items.manufacturer_source/manufacturer_name_snapshot(+ the source CHECK) and DROPSphoenix_kit_cat_items_manufacturer_uuid_fkey; - V180 adds
manufacturer_suppliers.manufacturer_source/supplier_source(+ both source CHECKs and the current-pair unique onitem_supplier_info) and DROPS bothphoenix_kit_cat_manufacturer_suppliers_*_uuid_fkeyconstraints.
The three dropped foreign keys are the reason foreign_keys/2 does not
simply mirror V135: core carries them as :legacy_optional in
ExpectedSchema — present only on installs that stopped before V179/
V180 — so re-adding one here would pin an item's manufacturer and both
sides of the M:N graph back to a local row, undoing the CRM federation.
test/phoenix_kit_catalogue/migrations_test.exs pins that against the
manifest in both directions.
So, concretely:
- on an install already at core's V180+ shape (crm_company_uuid,
manufacturer_source/supplier_source, the item/manufacturer_suppliers
FKs V179–V180 drop) every table is already there, the
CREATE TABLE IF NOT EXISTS/ guardedDO $$ ... pg_constraint ... $$blocks all find their targets already in place and are no-ops — the only new object is thepkc_schema:1marker. From then on this chain owns every adopted table's future shape; - on a hypothetical fresh install whose core baseline no longer
creates these tables, the same statements create them —
shape-identical to core's live V182 schema (authority: a
pg_dump --schema-onlyof the live database), with core's exact table/constraint/index names.
CREATE TABLE IF NOT EXISTS adoption is a presence check only — it
does not repair a table whose columns have drifted from core's current
shape on a host stuck below core V180 (the guarded ADD CONSTRAINT
blocks below DO repair a missing constraint on an existing table, but
they cannot add a missing column like crm_company_uuid or
manufacturer_source). That risk is contained, not eliminated: core's
own migration chain always runs ahead of this one (mix phoenix_kit.update applies core's chain first), so by the time V01
runs, every adopted table is already at core's current shape on any
host this chain actually executes against — the pk_dep(:phoenix_kit, …)
floor in mix.exs (never below 2.13.11) exists precisely so a host can
always reach that shape (see the comment there for why 2.13.4–2.13.10, which
ship the shape, are excluded: V180 itself crashes on those releases).
Because V01 changes no shape, core's ExpectedSchema manifest (which
still audits these tables' shapes) stays accurate and no core release
is required for this version. A version that DOES change shape (V2+)
must follow the excluded-object protocol described in the Legal
chain's extraction report before it ships.
What V2 is
V2 adds shape on top of two core-known tables (phoenix_kit_cat_items,
phoenix_kit_cat_categories): a slug jsonb NOT NULL DEFAULT '{}'
column on each. Core's ExpectedSchema resolver only ever iterates
the manifest's declared objects — it never enumerates a table's
actual columns to notice one that isn't manifested, so an extra
column is not inspected at all (not classified as an :info
finding; simply outside what the resolver looks at) — so this is
safe without a core release. The rest of V2 (the two
phoenix_kit_cat_item_slugs / phoenix_kit_cat_category_slugs
projection tables, their sync triggers, and the attribute-set GIN
index) creates objects core's manifest never names at all, which is
outside the manifest's reach by construction. Contrast the case that
WOULD require a core release first: altering the shape of a table
column core's manifest actually declares — see
phoenix_kit_legal's dev_docs/reports/2026-08-10-consent-logs-extraction.md
for that scenario.
The two projection tables exist so a slug can be looked up (and its
per-language uniqueness enforced) without scanning every item/category
row's slug jsonb — the same shape core's own ShopSlugProjection
used before catalogue absorbed the shop tables. A trigger
(AFTER INSERT OR UPDATE OF slug) keeps each projection in sync with
its owning table; the projection's own DELETE FROM <projection> at
the top of the sync function is the one DELETE this chain is allowed
to emit (it deletes only projection rows it is about to
re-insert — never a base table row).
What down/1 is NOT
down/1 unstamps the version marker; it NEVER drops any of the
eighteen tables — catalogue, category, item, and supplier data is not
this chain's to destroy, and most of it is core-created. The ownership
test pins this by asserting no statement this module can emit matches
DROP, TRUNCATE, or DELETE.
The migrated version is tracked as a pkc_schema:<N> COMMENT ON TABLE marker on phoenix_kit_cat_catalogues (the marker convention
from the projects/Legal/CRM chains). A marker-less table reads as
version 0 — the core-baseline shape before this chain existed.
Summary
Functions
Rolls back to target (:version in opts). Never drops a table — see the moduledoc.
The SQL down/1 executes, as data (marker bookkeeping only).
The chain version read from INSIDE a migration (migration repo).
The chain version currently applied in the database, read OUTSIDE a
migration (the protocol shape core's update task calls — opts with
:prefix): the pkc_schema:<N> marker when present; a marker-less or
foreign-comment table reads as 0 (core-baseline shape — V1 is purely
adoptive, there is no pre-chain content to defend).
Applies every chain version up to target (:version in opts, default current_version/0); idempotent.
The SQL up/1 executes, as data — the testable single source. Every
statement is idempotent (IF NOT EXISTS / guarded DO $$ block /
CREATE OR REPLACE FUNCTION / DROP TRIGGER IF EXISTS + CREATE TRIGGER / COMMENT) so it is safe to replay on an install where
core already created some or all of these tables, and on a fresh
install with none of them. V1 order: every CREATE TABLE, then every
primary-key guard, then every foreign-key guard, then every
CHECK-constraint guard, then every index (table declaration order
only matters for self-documentation — FK targets are guarded, not
required to pre-exist at CREATE TABLE time; the 13 CHECK constraints
also ride inline inside their CREATE TABLE IF NOT EXISTS, harmless
since Postgres no-ops the whole statement on an existing table — the
guards are what actually repair a pre-existing table missing one).
V2 statements follow, then the version marker last.
The table carrying the pkc_schema:<N> marker (auditor contract).
Functions
@spec current_version() :: pos_integer()
Rolls back to target (:version in opts). Never drops a table — see the moduledoc.
@spec down_statements(String.t(), non_neg_integer()) :: [String.t()]
The SQL down/1 executes, as data (marker bookkeeping only).
The chain version read from INSIDE a migration (migration repo).
The chain version currently applied in the database, read OUTSIDE a
migration (the protocol shape core's update task calls — opts with
:prefix): the pkc_schema:<N> marker when present; a marker-less or
foreign-comment table reads as 0 (core-baseline shape — V1 is purely
adoptive, there is no pre-chain content to defend).
Applies every chain version up to target (:version in opts, default current_version/0); idempotent.
@spec up_statements(String.t(), pos_integer()) :: [String.t()]
The SQL up/1 executes, as data — the testable single source. Every
statement is idempotent (IF NOT EXISTS / guarded DO $$ block /
CREATE OR REPLACE FUNCTION / DROP TRIGGER IF EXISTS + CREATE TRIGGER / COMMENT) so it is safe to replay on an install where
core already created some or all of these tables, and on a fresh
install with none of them. V1 order: every CREATE TABLE, then every
primary-key guard, then every foreign-key guard, then every
CHECK-constraint guard, then every index (table declaration order
only matters for self-documentation — FK targets are guarded, not
required to pre-exist at CREATE TABLE time; the 13 CHECK constraints
also ride inline inside their CREATE TABLE IF NOT EXISTS, harmless
since Postgres no-ops the whole statement on an existing table — the
guards are what actually repair a pre-existing table missing one).
V2 statements follow, then the version marker last.
target selects how much of the chain to emit (default
current_version/0): 1 is the pure V1 adoption step (the owned
tables/keys/checks/indexes below); 2 additionally adds V2's
per-language slug column, its trigger projections, and the
attribute-set GIN index. Mirrors phoenix_kit_billing's version-aware
up_statements/2 — the wrapper migration core's update task generates
calls this with an explicit :version, and a stale wrapper asking for
1 must not receive 2's objects.
@spec version_table() :: String.t()
The table carrying the pkc_schema:<N> marker (auditor contract).