-- 0002_products.sql — SD-0002 §6.3 data model (SLICE-5). Forward-only (INV-7): -- never edit once merged; add a new numbered migration. -- product — one catalog product per (storefront, handle) (INV-13/14). Option *names* -- live here; product_type's kit values are schema-open but a service-layer rule -- rejects non-'standalone' until #15 (same pattern as INV-4). CREATE TABLE product ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, storefront_id BIGINT NOT NULL REFERENCES storefront (id), handle TEXT NOT NULL, title TEXT NOT NULL, description_html TEXT, vendor TEXT, product_type TEXT NOT NULL DEFAULT 'standalone' CHECK (product_type IN ('standalone', 'kit_virtual', 'kit_assembled')), google_product_category TEXT, tags TEXT[] NOT NULL DEFAULT '{}', status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('draft', 'active', 'archived')), published BOOLEAN NOT NULL DEFAULT TRUE, option1_name TEXT, option2_name TEXT, option3_name TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE UNIQUE INDEX product_handle_key ON product (storefront_id, handle); -- INV-13 -- variant — one purchasable form, identified by its option-value combo (INV-13). -- SKU is indexed data, never identity. image_id FK is added after product_image. CREATE TABLE variant ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, product_id BIGINT NOT NULL REFERENCES product (id), position INTEGER NOT NULL, option1_value TEXT, option2_value TEXT, option3_value TEXT, sku TEXT, barcode TEXT, price NUMERIC, cost NUMERIC, weight NUMERIC, weight_unit TEXT, volume NUMERIC, volume_unit TEXT, tax_id_1 TEXT, tax_id_2 TEXT, inventory_tracker TEXT, inventory_qty INTEGER, image_id BIGINT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -- Postgres 16 (compose + Cloud SQL pin): NULLS NOT DISTINCT makes the all-NULL -- no-option combo unique too (INV-13). CREATE UNIQUE INDEX variant_option_combo_key ON variant (product_id, option1_value, option2_value, option3_value) NULLS NOT DISTINCT; CREATE INDEX variant_sku_idx ON variant (sku); -- product_image — identity within a product is source_url (§6.3); bytes live in -- object storage from SLICE-7 (keys nullable until fetched). status starts 'pending'; -- SLICE-5 stubs the fetch phase so rows simply stay pending. CREATE TABLE product_image ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, product_id BIGINT NOT NULL REFERENCES product (id), position INTEGER NOT NULL, source_url TEXT NOT NULL, alt_text TEXT, status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'fetched', 'rejected_low_res', 'rejected_not_image', 'failed')), failure_reason TEXT, key_original TEXT, key_thumb TEXT, key_card TEXT, key_detail TEXT, import_run_id BIGINT, fetched_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE UNIQUE INDEX product_image_src_key ON product_image (product_id, source_url); ALTER TABLE variant ADD CONSTRAINT variant_image_fk FOREIGN KEY (image_id) REFERENCES product_image (id); -- import_draft — the preview's server side (INV-11). file_bytes is the SLICE-5 -- interim home for the upload (objectstore key from SLICE-7). Deleted outright on -- cancel/expiry — drafts never appear in history (PUC-3a). CREATE TABLE import_draft ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, storefront_id BIGINT NOT NULL REFERENCES storefront (id), account_id BIGINT NOT NULL REFERENCES account (id), file_name TEXT NOT NULL, dialect TEXT NOT NULL, file_bytes BYTEA NOT NULL, summary JSONB NOT NULL, records JSONB NOT NULL, fingerprint TEXT NOT NULL, unknown_columns TEXT[] NOT NULL DEFAULT '{}', expires_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -- import_run — the durable record of one confirmed import (PUC-8); created only at -- confirm (PUC-4). CREATE TABLE import_run ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, storefront_id BIGINT NOT NULL REFERENCES storefront (id), account_id BIGINT NOT NULL REFERENCES account (id), file_name TEXT NOT NULL, dialect TEXT NOT NULL, products_added INTEGER NOT NULL, products_updated INTEGER NOT NULL, rows_errored INTEGER NOT NULL, status TEXT NOT NULL CHECK (status IN ('applying', 'fetching_images', 'complete', 'complete_with_problems')), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), completed_at TIMESTAMPTZ ); CREATE INDEX import_run_history_idx ON import_run (storefront_id, created_at DESC); -- import_run_error — one row per rejected CSV row (PUC-5), merchant-language message. CREATE TABLE import_run_error ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, run_id BIGINT NOT NULL REFERENCES import_run (id), line_number INTEGER NOT NULL, column_name TEXT, message TEXT NOT NULL ); ALTER TABLE product_image ADD CONSTRAINT product_image_run_fk FOREIGN KEY (import_run_id) REFERENCES import_run (id);