PhoenixKitCatalogue.Migrations (PhoenixKitCatalogue v0.44.2)

Copy Markdown View Source

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 DROPS phoenix_kit_cat_items_manufacturer_uuid_fkey;
  • V180 adds manufacturer_suppliers.manufacturer_source / supplier_source (+ both source CHECKs and the current-pair unique on item_supplier_info) and DROPS both phoenix_kit_cat_manufacturer_suppliers_*_uuid_fkey constraints.

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 / guarded DO $$ ... pg_constraint ... $$ blocks all find their targets already in place and are no-ops — the only new object is the pkc_schema:1 marker. 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-only of 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

current_version()

@spec current_version() :: pos_integer()

down(opts \\ [])

Rolls back to target (:version in opts). Never drops a table — see the moduledoc.

down_statements(prefix \\ "public", target \\ 0)

@spec down_statements(String.t(), non_neg_integer()) :: [String.t()]

The SQL down/1 executes, as data (marker bookkeeping only).

migrated_version(opts \\ [])

The chain version read from INSIDE a migration (migration repo).

migrated_version_runtime(opts \\ [])

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).

up(opts \\ [])

Applies every chain version up to target (:version in opts, default current_version/0); idempotent.

up_statements(prefix \\ "public", target \\ 2)

@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.

version_table()

@spec version_table() :: String.t()

The table carrying the pkc_schema:<N> marker (auditor contract).