Unique Constraint Migration Strategy
Overview
Current global unique constraints must be converted to tenant-scoped uniques to allow multiple tenants to use the same values (e.g., invoice numbers, quote numbers, SKUs).
Migration Strategy
The general pattern is:
- Drop the existing global unique constraint/index
- Create a new composite unique constraint/index on
(tenant_id, original_column)
Tables Requiring Unique Constraint Migration
1. invoices.invoice_number
Current State:
invoice_number text UNIQUE NOT NULL
Migration:
-- Drop existing unique constraint
ALTER TABLE invoices DROP CONSTRAINT IF EXISTS invoices_invoice_number_key;
-- Create tenant-scoped unique index
CREATE UNIQUE INDEX IF NOT EXISTS uniq_invoices_tenant_invoice_number
ON invoices(tenant_id, invoice_number);
Rationale: Multiple tenants should be able to use the same invoice numbers (e.g., both can have "INV-00001").
2. quotes.quote_number
Current State:
quote_number text UNIQUE NOT NULL
Migration:
-- Drop existing unique constraint
ALTER TABLE quotes DROP CONSTRAINT IF EXISTS quotes_quote_number_key;
-- Create tenant-scoped unique index
CREATE UNIQUE INDEX IF NOT EXISTS uniq_quotes_tenant_quote_number
ON quotes(tenant_id, quote_number);
Rationale: Multiple tenants should be able to use the same quote numbers (e.g., both can have "QUO-00001").
3. products_services.sku
Current State:
-- Partial unique index (only where sku IS NOT NULL)
CREATE UNIQUE INDEX idx_products_services_sku ON products_services(sku) WHERE sku IS NOT NULL;
Migration:
-- Drop existing partial unique index
DROP INDEX IF EXISTS idx_products_services_sku;
-- Create tenant-scoped partial unique index
CREATE UNIQUE INDEX IF NOT EXISTS uniq_products_services_tenant_sku
ON products_services(tenant_id, sku) WHERE sku IS NOT NULL;
Rationale: SKUs should be unique within a tenant, but different tenants can use the same SKU values.
Tables Keeping Global Uniques
1. employees.email
Current State:
email text UNIQUE NOT NULL
Decision: Keep global unique
Rationale:
- Email addresses are user identity, not tenant-specific business data
- A user's email should be globally unique across the system
- The
tenant_idcolumn will still be added for tenant isolation, but email uniqueness remains global
No migration needed - keep existing unique constraint.
2. profiles.email
Current State:
email text UNIQUE NOT NULL
Decision: Keep global unique
Rationale:
- Email is the authentication identity
- Must remain globally unique for auth system integrity
tenant_idwill be added for tenant isolation, but email uniqueness is global
No migration needed - keep existing unique constraint.
3. api_keys.key_hash
Current State:
key_hash text NOT NULL UNIQUE
Decision: Keep global unique
Rationale:
- API key hashes must be globally unique for security
- Cannot allow duplicate key hashes across tenants
tenant_idwill be added for tenant isolation, but key_hash uniqueness is global
No migration needed - keep existing unique constraint.
4. inventory.product_id
Current State:
product_id uuid NOT NULL UNIQUE REFERENCES products_services(id)
Decision: Keep as-is (1:1 relationship)
Rationale:
- This is a 1:1 relationship constraint, not a business uniqueness constraint
- One inventory record per product is correct
tenant_idwill be added, but the unique constraint onproduct_idremains
No migration needed - keep existing unique constraint.
5. project_employees
Current State:
UNIQUE(project_id, employee_id)
Decision: Keep as composite unique
Rationale:
- This ensures an employee can only be assigned once per project
- The composite unique is correct as-is
tenant_idwill be added for tenant isolation, but the composite unique remains
No migration needed - keep existing unique constraint.
Migration Execution Order
- Phase 1: Add nullable
tenant_idcolumns (no constraint changes yet) - Phase 2: Backfill all
tenant_idvalues - Phase 5:
- Make
tenant_idNOT NULL - Drop global unique constraints
- Create tenant-scoped unique indexes
- Make
Validation Queries
After migration, verify tenant-scoped uniqueness:
-- Verify no duplicate invoice numbers within tenants
SELECT tenant_id, invoice_number, COUNT(*)
FROM invoices
GROUP BY tenant_id, invoice_number
HAVING COUNT(*) > 1;
-- Verify no duplicate quote numbers within tenants
SELECT tenant_id, quote_number, COUNT(*)
FROM quotes
GROUP BY tenant_id, quote_number
HAVING COUNT(*) > 1;
-- Verify no duplicate SKUs within tenants (where SKU is not null)
SELECT tenant_id, sku, COUNT(*)
FROM products_services
WHERE sku IS NOT NULL
GROUP BY tenant_id, sku
HAVING COUNT(*) > 1;
All queries should return 0 rows.
Rollback Considerations
If rollback is needed:
- Drop tenant-scoped unique indexes
- Recreate global unique constraints
- Note: This will fail if multiple tenants have duplicate values, so rollback should only occur before Phase 2 backfill is complete