Claude-skill-registry laneweaver-database-design
Design PostgreSQL 17 schemas for laneweaverTMS using Supabase conventions - UUIDs, ENUMs, audit trails, soft deletes, triggers, functions, views, and atomic migration patterns.
git clone https://github.com/majiayu000/claude-skill-registry
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/laneweaver-database-design" ~/.claude/skills/majiayu000-claude-skill-registry-laneweaver-database-design && rm -rf "$T"
skills/data/laneweaver-database-design/SKILL.mdDatabase Design - PostgreSQL 17 + Supabase for laneweaverTMS
When to Use This Skill
Use when:
- Designing new database tables and schemas
- Creating database migrations
- Planning table relationships and foreign keys
- Defining indexes for query optimization
- Creating database constraints and validation rules
- Implementing audit trails and soft deletes
- Writing database functions and triggers
- Designing views for calculated data
- Setting up row-level security (RLS) policies
- Implementing polymorphic relationships
Primary Keys & IDs
UUID Pattern (Standard for All Tables)
-- ✅ Correct: UUID primary key CREATE TABLE public.loads ( id UUID DEFAULT gen_random_uuid() NOT NULL, load_number TEXT NOT NULL, -- ... CONSTRAINT loads_pkey PRIMARY KEY (id) );
Why UUIDs?
- Global uniqueness across distributed systems
- No sequential guessing of IDs (security)
- Enables data federation and merging
- Works with Supabase realtime subscriptions
Exception: users Table
-- ✅ Exception: users table uses INT4 CREATE TABLE public.users ( id INT4 GENERATED ALWAYS AS IDENTITY PRIMARY KEY, email TEXT UNIQUE NOT NULL, -- ... );
Impact: All audit columns (
created_by, updated_by, deleted_by) use INT4 to reference users.id.
Required Audit Columns
Every table MUST include these audit columns:
CREATE TABLE public.loads ( id UUID DEFAULT gen_random_uuid() NOT NULL, -- Business fields... -- Standard audit columns (REQUIRED ON ALL TABLES) created_at TIMESTAMPTZ DEFAULT now() NOT NULL, updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, created_by INT4, -- References users.id updated_by INT4, -- References users.id deleted_at TIMESTAMPTZ, -- Soft delete: NULL = active deleted_by INT4, -- User who deleted the record CONSTRAINT loads_pkey PRIMARY KEY (id) );
Soft Delete Pattern
- Pattern:
(NULL = active, non-NULL = deleted)deleted_at TIMESTAMPTZ - NEVER hard delete - always use
UPDATE SET deleted_at = now() - Query active records:
WHERE deleted_at IS NULL
-- ✅ Correct: Soft delete UPDATE loads SET deleted_at = now(), deleted_by = $1 WHERE id = $2; -- ❌ Wrong: Hard delete DELETE FROM loads WHERE id = $1; -- ✅ Correct: Query active records only SELECT * FROM loads WHERE deleted_at IS NULL;
Data Types (Required)
laneweaverTMS follows PostgreSQL best practices with these domain-specific conventions:
| Data | Type | laneweaverTMS Convention |
|---|---|---|
| IDs | | All tables except users (uses INT4) |
| User References | | audit columns (created_by, updated_by, deleted_by) |
| Timestamps | | All temporal data |
| Money | | customer_rate, carrier_rate |
| Strings | | load_number, notes, etc. |
These conventions align with PostgreSQL best practices for production databases.
NEVER Use These Types
-- ❌ NEVER use these types: TIMESTAMP -- Missing timezone → Use TIMESTAMPTZ VARCHAR(n) -- Arbitrary limits → Use TEXT CHAR(n) -- Fixed length → Use TEXT MONEY -- Currency type → Use NUMERIC(10,2) SERIAL -- Auto-increment → Use UUID or GENERATED ALWAYS AS IDENTITY BIGSERIAL -- Auto-increment → Use UUID or GENERATED ALWAYS AS IDENTITY JSON -- Slower than JSONB → Use JSONB REAL -- Imprecise for money → Use NUMERIC FLOAT -- Imprecise for money → Use NUMERIC
ENUM Types
When to Use ENUMs
Use PostgreSQL ENUMs for:
- Small, stable value sets (status workflows, categories)
- Type safety at the database level
- Performance (ENUMs stored as integers internally)
laneweaverTMS has 32+ ENUMs defined:
,load_status
,tender_status
,invoice_statuscarrier_bill_status
,call_outcome
,task_status_enumtask_priority_enum
,mode_of_transport_list
,stop_type_listtrailer_requirements_list
,accessorial_category_type
,email_statusfeed_item_type
Creating ENUMs
-- Migration: Create load_status ENUM CREATE TYPE public.load_status AS ENUM ( 'uncovered', 'assigned', 'dispatched', 'at_origin', 'in_transit', 'at_destination', 'delivered' ); COMMENT ON TYPE public.load_status IS 'Load lifecycle: uncovered → assigned → dispatched → at_origin → in_transit → at_destination → delivered';
Pattern:
- Values use
(e.g.,snake_case
,'in_transit'
)'left_voicemail' - Create ENUM in separate migration file BEFORE table creation
- Always include COMMENT explaining lifecycle or valid values
Using ENUMs in Tables
CREATE TABLE public.loads ( id UUID DEFAULT gen_random_uuid() NOT NULL, load_status public.load_status DEFAULT 'uncovered'::public.load_status NOT NULL, -- ... );
Modifying ENUMs
-- ✅ Safe: Adding values (no table rewrite) ALTER TYPE load_status ADD VALUE 'cancelled'; -- ❌ Risky: Removing values (requires recreation) -- Must create new type, migrate data, drop old type, rename new type
Constraints
CHECK Constraints
Use for business rules and validation:
-- ✅ Positive amounts ALTER TABLE loads ADD CONSTRAINT chk_loads_customer_rate_positive CHECK (customer_rate > 0); -- ✅ Valid ranges ALTER TABLE load_cognition ADD CONSTRAINT chk_load_cognition_latitude_range CHECK (latitude >= -90 AND latitude <= 90); -- ✅ Logical consistency ALTER TABLE stops ADD CONSTRAINT chk_stops_appointment_logic CHECK ( (appointment_required = false AND appointment_time IS NULL) OR (appointment_required = true AND appointment_time IS NOT NULL) ); -- ✅ JSONB structure validation ALTER TABLE customer_invoices ADD CONSTRAINT chk_invoices_line_items_object CHECK (jsonb_typeof(line_items) = 'object'); -- ✅ ENUM validation for polymorphic types ALTER TABLE documents ADD CONSTRAINT chk_documents_documentable_type CHECK (documentable_type = ANY (ARRAY['load'::text, 'account'::text, 'carrier'::text, 'facility'::text, 'rfp'::text]));
UNIQUE Constraints
-- ✅ Natural keys ALTER TABLE loads ADD CONSTRAINT loads_load_number_key UNIQUE (load_number); -- ✅ Business uniqueness ALTER TABLE carriers ADD CONSTRAINT carriers_mc_number_key UNIQUE (mc_number); -- ✅ Composite uniqueness ALTER TABLE load_references ADD CONSTRAINT load_references_load_reference_unique UNIQUE (load_id, reference_type_id); -- ✅ One NULL allowed (PostgreSQL 15+) ALTER TABLE carriers ADD CONSTRAINT uq_carriers_dot_number UNIQUE NULLS NOT DISTINCT (dot_number);
Foreign Key Constraints
-- ✅ CASCADE: Delete children when parent deleted ALTER TABLE stops ADD CONSTRAINT stops_load_id_fkey FOREIGN KEY (load_id) REFERENCES loads(id) ON DELETE CASCADE; -- ✅ SET NULL: Preserve record, nullify FK ALTER TABLE loads ADD CONSTRAINT loads_tender_id_fkey FOREIGN KEY (tender_id) REFERENCES tenders(id) ON DELETE SET NULL; -- ✅ RESTRICT: Prevent deletion if children exist ALTER TABLE loads ADD CONSTRAINT loads_account_id_fkey FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE RESTRICT; -- ✅ Audit columns: Always SET NULL ALTER TABLE loads ADD CONSTRAINT loads_created_by_fkey FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL;
Indexing Strategy
Critical Rule: Manual FK Indexes
PostgreSQL does NOT auto-index foreign keys. You MUST create indexes manually.
-- ✅ Required: Index all foreign keys CREATE INDEX idx_loads_tender_id ON public.loads(tender_id); CREATE INDEX idx_loads_carrier_id ON public.loads(carrier_id); CREATE INDEX idx_stops_load_id ON public.stops(load_id); CREATE INDEX idx_stops_facility_id ON public.stops(facility_id);
Partial Indexes
-- ✅ Soft deletes: Index active records only CREATE INDEX idx_loads_deleted_at ON public.loads(deleted_at) WHERE deleted_at IS NULL; -- ✅ Nullable FKs: Index non-null values only CREATE INDEX idx_loads_tender_id ON public.loads(tender_id) WHERE tender_id IS NOT NULL; -- ✅ Conditional indexes for specific queries CREATE INDEX idx_carrier_bills_quick_pay ON public.carrier_bills(quick_pay_requested) WHERE quick_pay_requested = true;
Status and Timestamp Indexes
-- ✅ Status columns (for filtering) CREATE INDEX idx_loads_load_status ON public.loads(load_status); CREATE INDEX idx_carrier_bills_bill_status ON public.carrier_bills(bill_status); -- ✅ Timestamp columns (for sorting, filtering, range queries) CREATE INDEX idx_loads_created_at ON public.loads(created_at); CREATE INDEX idx_calls_called_at ON public.calls(called_at); CREATE INDEX idx_carrier_bills_scheduled_payment_date ON public.carrier_bills(scheduled_payment_date);
Audit Column Indexes
-- ✅ Partial indexes for audit columns CREATE INDEX idx_loads_created_by ON public.loads(created_by) WHERE created_by IS NOT NULL; CREATE INDEX idx_loads_updated_by ON public.loads(updated_by) WHERE updated_by IS NOT NULL;
GIN Indexes (JSONB, Arrays)
-- ✅ JSONB containment queries CREATE INDEX idx_facilities_operating_hours ON public.facilities USING GIN (operating_hours); -- ✅ Array containment CREATE INDEX idx_loads_equipment_types ON public.loads USING GIN (equipment_types); -- ✅ Full-text search CREATE INDEX idx_accounts_search ON public.accounts USING GIN (to_tsvector('english', name || ' ' || COALESCE(domain_name, '')));
Index Naming Convention
Pattern:
idx_[table]_[column(s)]
CREATE INDEX idx_loads_account_id ON loads(account_id); CREATE INDEX idx_loads_load_status ON loads(load_status); CREATE INDEX idx_carrier_bounces_carrier_id ON carrier_bounces(carrier_id);
Database Functions
Security Pattern (REQUIRED)
All functions MUST follow Supabase security best practices:
laneweaverTMS Convention:
All functions follow Supabase security best practices with:
- ensures function runs with caller's privilegesSECURITY INVOKER
- prevents schema search path attacksSET search_path = 'public'
CREATE OR REPLACE FUNCTION public.function_name() RETURNS type LANGUAGE plpgsql SECURITY INVOKER SET search_path = 'public' AS $$ -- Function body $$;
Trigger Functions
-- ✅ Updated_at trigger function (reusable) CREATE OR REPLACE FUNCTION public.update_timestamp() RETURNS TRIGGER LANGUAGE plpgsql SECURITY INVOKER SET search_path = 'public' AS $$ BEGIN NEW.updated_at = now(); RETURN NEW; END; $$; -- ✅ Sync trigger for denormalization CREATE OR REPLACE FUNCTION public.sync_load_cancelled_status() RETURNS TRIGGER LANGUAGE plpgsql SECURITY INVOKER SET search_path = 'public' AS $$ BEGIN IF TG_OP = 'INSERT' THEN UPDATE loads SET is_cancelled = true WHERE id = NEW.load_id; ELSIF TG_OP = 'DELETE' THEN UPDATE loads SET is_cancelled = false WHERE id = OLD.load_id; END IF; RETURN NULL; END; $$; COMMENT ON FUNCTION public.sync_load_cancelled_status() IS 'Syncs loads.is_cancelled when load_cancellations records are inserted/deleted'; -- ✅ Validation trigger CREATE OR REPLACE FUNCTION public.validate_commodity_temperature() RETURNS TRIGGER LANGUAGE plpgsql SECURITY INVOKER SET search_path = 'public' AS $$ BEGIN IF NEW.temperature_min IS NOT NULL AND NEW.temperature_max IS NOT NULL THEN IF NEW.temperature_min > NEW.temperature_max THEN RAISE EXCEPTION 'temperature_min cannot be greater than temperature_max'; END IF; END IF; IF NEW.temperature_unit IS NOT NULL AND NEW.temperature_unit NOT IN ('F', 'C') THEN RAISE EXCEPTION 'temperature_unit must be F or C'; END IF; RETURN NEW; END; $$;
Business Logic Functions
CREATE OR REPLACE FUNCTION public.create_load_from_tender( p_tender_id UUID, p_user_id UUID, p_carrier_id UUID ) RETURNS UUID LANGUAGE plpgsql SECURITY INVOKER SET search_path = 'public' AS $$ DECLARE v_load_id UUID; v_load_number TEXT; BEGIN -- Generate load number v_load_number := public.generate_load_number(); -- Create load from tender INSERT INTO loads ( id, load_number, tender_id, carrier_id, load_status, created_by, updated_by ) VALUES ( gen_random_uuid(), v_load_number, p_tender_id, p_carrier_id, 'assigned'::load_status, p_user_id, p_user_id ) RETURNING id INTO v_load_id; -- Update tender status UPDATE tenders SET tender_status = 'planned'::tender_status, planned_at = now(), updated_by = p_user_id WHERE id = p_tender_id; RETURN v_load_id; END; $$; COMMENT ON FUNCTION public.create_load_from_tender(UUID, UUID, UUID) IS 'Creates load from tender, generates L-XXXXXX number, updates tender status to planned';
Triggers
Standard Patterns
Every table should have:
1. Updated_at Trigger
CREATE TRIGGER trg_loads_updated_at BEFORE UPDATE ON public.loads FOR EACH ROW EXECUTE FUNCTION public.update_timestamp();
2. Audit Log Trigger
CREATE TRIGGER audit_loads_trigger AFTER INSERT OR UPDATE OR DELETE ON public.loads FOR EACH ROW EXECUTE FUNCTION public.audit_trigger_function();
Sync Triggers (Denormalization)
-- Sync load billing flags to loads table CREATE TRIGGER trg_sync_load_billing_flags AFTER INSERT OR UPDATE OF pod_received, carrier_bill_received ON public.load_billing FOR EACH ROW EXECUTE FUNCTION public.sync_load_billing_flags(); -- Sync cancelled status from load_cancellations CREATE TRIGGER trg_sync_load_cancelled_status AFTER INSERT OR DELETE ON public.load_cancellations FOR EACH ROW EXECUTE FUNCTION public.sync_load_cancelled_status();
Validation Triggers
-- Validate commodity temperature range CREATE TRIGGER validate_commodity_temperature BEFORE INSERT OR UPDATE ON public.commodities FOR EACH ROW EXECUTE FUNCTION public.validate_commodity_temperature(); -- Enforce driver title requirement CREATE TRIGGER enforce_driver_title_trigger BEFORE INSERT OR UPDATE ON public.load_cognition FOR EACH ROW EXECUTE FUNCTION public.validate_driver_title();
Auto-Generated Values
-- Auto-generate tender number CREATE TRIGGER trg_set_tender_number BEFORE INSERT ON public.tenders FOR EACH ROW EXECUTE FUNCTION public.set_tender_number(); -- Auto-create load versions CREATE TRIGGER trg_load_versioning AFTER INSERT OR UPDATE ON public.loads FOR EACH ROW EXECUTE FUNCTION public.create_load_version();
Views
RLS-Aware Views (Required Pattern)
All views MUST use
for Row-Level Security compatibility:WITH (security_invoker='on')
CREATE OR REPLACE VIEW public.loads_with_financials WITH (security_invoker = on) AS SELECT l.id, l.load_number, l.load_status, l.customer_rate, l.carrier_rate, -- Calculated financial metrics (l.customer_rate - COALESCE(l.carrier_rate, 0)) AS gross_profit, CASE WHEN l.customer_rate > 0 THEN ((l.customer_rate - COALESCE(l.carrier_rate, 0)) / l.customer_rate * 100) ELSE 0 END AS profit_margin_percent, -- Aggregate accessorials (SELECT COALESCE(SUM(amount), 0) FROM customer_accessorials WHERE load_id = l.id AND deleted_at IS NULL) AS customer_accessorials_total, (SELECT COALESCE(SUM(amount), 0) FROM carrier_accessorials WHERE load_id = l.id AND deleted_at IS NULL) AS carrier_accessorials_total, -- Net profit ((l.customer_rate + (SELECT COALESCE(SUM(amount), 0) FROM customer_accessorials WHERE load_id = l.id AND deleted_at IS NULL)) - (COALESCE(l.carrier_rate, 0) + (SELECT COALESCE(SUM(amount), 0) FROM carrier_accessorials WHERE load_id = l.id AND deleted_at IS NULL))) AS net_profit FROM public.loads l WHERE l.deleted_at IS NULL; COMMENT ON VIEW public.loads_with_financials IS 'Loads with calculated financial metrics (gross profit, margin %, accessorials, net profit)';
Lifecycle Views
CREATE OR REPLACE VIEW public.life_of_load_flow WITH (security_invoker = on) AS SELECT -- Load l.id AS load_id, l.load_number, l.load_status, -- Tender t.id AS tender_id, t.tender_number, t.tender_status, -- Quote cq.id AS quote_id, cq.quote_number, -- Account a.id AS account_id, a.name AS account_name, -- Financial summary l.customer_rate, l.carrier_rate, (l.customer_rate - COALESCE(l.carrier_rate, 0)) AS gross_profit, -- Billing status lb.pod_received, lb.carrier_bill_received, lb.invoice_ready FROM public.loads l LEFT JOIN public.tenders t ON l.tender_id = t.id LEFT JOIN public.customer_quotes cq ON t.quote_id = cq.id LEFT JOIN public.accounts a ON t.account_id = a.id LEFT JOIN public.load_billing lb ON l.id = lb.load_id WHERE l.deleted_at IS NULL; COMMENT ON VIEW public.life_of_load_flow IS 'Complete load lifecycle: quote → tender → load → billing with financial metrics';
Migration Patterns
Atomic Migration Strategy
One logical change per migration file:
- Create ENUM (separate file)
- Create table (separate file)
- Add indexes (separate file)
- Add foreign keys (inline or separate file)
- Add triggers (separate file)
- Add functions (separate file)
- Add views (separate file)
- Add RLS policies (separate file)
Migration File Naming
Format:
[YYYYMMDDHHMMSS]_[descriptive_name].sql
Examples:
20251216171743_create_tender_status_enum.sql20251217055251_create_carrier_bounces_table.sql20251216172549_add_tenders_indexes_and_audit.sql20251217060132_add_carrier_bounces_updated_at_trigger.sql
Migration File Structure
-- Header comment describing purpose -- Migration: Create carrier_bounces table for tracking carrier falloffs -- Create table with inline constraints CREATE TABLE public.carrier_bounces ( -- Primary key id UUID DEFAULT gen_random_uuid() NOT NULL, -- Foreign keys carrier_id UUID NOT NULL, load_id UUID NOT NULL, -- Core fields reason TEXT, bounce_time TIMESTAMPTZ NOT NULL DEFAULT now(), carrier_rate NUMERIC(10,2), -- Standard audit columns (see "Required Audit Columns" section above) created_at TIMESTAMPTZ DEFAULT now() NOT NULL, updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, created_by INT4, updated_by INT4, deleted_at TIMESTAMPTZ, deleted_by INT4, -- Constraints CONSTRAINT carrier_bounces_pkey PRIMARY KEY (id), CONSTRAINT fk_carrier_bounces_carrier_id FOREIGN KEY (carrier_id) REFERENCES carriers(id) ON DELETE RESTRICT, CONSTRAINT fk_carrier_bounces_load_id FOREIGN KEY (load_id) REFERENCES loads(id) ON DELETE RESTRICT ); -- Table comment COMMENT ON TABLE public.carrier_bounces IS 'Tracks when carriers back out of committed loads. Used for reliability scoring and bounce history.'; -- Column comments COMMENT ON COLUMN public.carrier_bounces.carrier_id IS 'Carrier that bounced on the load'; COMMENT ON COLUMN public.carrier_bounces.load_id IS 'Load the carrier bounced from'; COMMENT ON COLUMN public.carrier_bounces.reason IS 'Reason provided for the bounce (free text)'; COMMENT ON COLUMN public.carrier_bounces.bounce_time IS 'Timestamp when bounce occurred';
Separate Migration: Indexes
-- Migration: Add indexes to carrier_bounces table -- Foreign key indexes (REQUIRED - PostgreSQL doesn't auto-index FKs) CREATE INDEX idx_carrier_bounces_carrier_id ON public.carrier_bounces(carrier_id); CREATE INDEX idx_carrier_bounces_load_id ON public.carrier_bounces(load_id); -- Soft delete partial index CREATE INDEX idx_carrier_bounces_deleted_at ON public.carrier_bounces(deleted_at) WHERE deleted_at IS NULL; -- Timestamp index for filtering CREATE INDEX idx_carrier_bounces_bounce_time ON public.carrier_bounces(bounce_time); -- Audit column indexes CREATE INDEX idx_carrier_bounces_created_by ON public.carrier_bounces(created_by) WHERE created_by IS NOT NULL;
Separate Migration: Triggers
-- Migration: Add triggers to carrier_bounces table -- Updated_at trigger CREATE TRIGGER trg_carrier_bounces_updated_at BEFORE UPDATE ON public.carrier_bounces FOR EACH ROW EXECUTE FUNCTION public.update_timestamp(); COMMENT ON TRIGGER trg_carrier_bounces_updated_at ON public.carrier_bounces IS 'Automatically updates updated_at timestamp on row modification'; -- Audit trigger CREATE TRIGGER audit_carrier_bounces_trigger AFTER INSERT OR UPDATE OR DELETE ON public.carrier_bounces FOR EACH ROW EXECUTE FUNCTION public.audit_trigger_function(); COMMENT ON TRIGGER audit_carrier_bounces_trigger ON public.carrier_bounces IS 'Logs all changes to audit_log table for compliance tracking';
Polymorphic Relationships
Pattern: Type + ID Columns
Allows a single table to reference multiple entity types:
-- ✅ Polymorphic: calls can reference accounts, contacts, RFPs, loads CREATE TABLE public.calls ( id UUID DEFAULT gen_random_uuid() NOT NULL, -- Polymorphic relationship related_to_table TEXT NOT NULL, -- 'account', 'contact', 'rfp', 'load' related_to_id UUID NOT NULL, -- Call fields call_outcome public.call_outcome, called_at TIMESTAMPTZ DEFAULT now() NOT NULL, notes TEXT, -- Audit columns... CONSTRAINT calls_pkey PRIMARY KEY (id), CONSTRAINT chk_calls_related_to_table CHECK (related_to_table = ANY (ARRAY['account'::text, 'contact'::text, 'rfp'::text, 'load'::text])) ); -- Index for polymorphic lookup CREATE INDEX idx_calls_related_to ON public.calls(related_to_table, related_to_id); -- Query calls for specific account SELECT * FROM calls WHERE related_to_table = 'account' AND related_to_id = '123e4567-e89b-12d3-a456-426614174000';
Examples in laneweaverTMS
- calls:
+related_to_table
(accounts, contacts, RFPs, loads)related_to_id - documents:
+documentable_type
(loads, accounts, carriers, facilities, rfps)documentable_id
Generated Columns
Pattern: Calculated Values
-- ✅ Generated column for invoice readiness ALTER TABLE public.load_billing ADD COLUMN invoice_ready BOOLEAN GENERATED ALWAYS AS (pod_received AND carrier_bill_received) STORED; COMMENT ON COLUMN public.load_billing.invoice_ready IS 'Generated: true when both POD and carrier bill are received'; -- Query using generated column SELECT * FROM load_billing WHERE invoice_ready = true; -- Index on generated column CREATE INDEX idx_load_billing_invoice_ready ON public.load_billing(invoice_ready) WHERE invoice_ready = true;
When to Use Generated Columns
- Frequently queried JSONB fields
- Complex boolean logic (invoice_ready = pod_received AND carrier_bill_received)
- Calculated values used in WHERE clauses or indexes
Row-Level Security (RLS)
Enable RLS on Tables
-- Enable RLS ALTER TABLE loads ENABLE ROW LEVEL SECURITY; -- Development policy (permissive for authenticated users) CREATE POLICY "Authenticated users can select loads" ON loads FOR SELECT TO authenticated USING (true); -- Production policy (organization isolation) CREATE POLICY "Users see only their organization's loads" ON loads FOR SELECT TO authenticated USING ( account_id IN ( SELECT account_id FROM user_accounts WHERE user_id = auth.uid() ) ); -- Admin bypass CREATE POLICY "Admins see all loads" ON loads FOR ALL TO admin_users USING (true) WITH CHECK (true);
JSONB Usage
When to Use JSONB
Use JSONB for:
- Semi-structured, optional attributes (not core relations)
- Flexible configuration data
- Event payloads or raw data snapshots
- Nested data structures
Examples in laneweaverTMS:
- Flexible schedule datafacilities.operating_hours
- Invoice line item detailscustomer_invoices.line_items
,audit_log.old_data
- Change trackingaudit_log.new_data
- EDI 204 payloadtenders.edi_raw_data
JSONB Best Practices
-- ✅ Default to empty object (avoid NULL checks) ALTER TABLE facilities ADD COLUMN operating_hours JSONB NOT NULL DEFAULT '{}'::jsonb; -- ✅ CHECK constraint for structure ALTER TABLE customer_invoices ADD CONSTRAINT chk_invoices_line_items_object CHECK (jsonb_typeof(line_items) = 'object'); -- ✅ GIN index for containment queries CREATE INDEX idx_facilities_operating_hours ON facilities USING GIN (operating_hours); -- ✅ Extract frequently queried fields as generated columns ALTER TABLE facilities ADD COLUMN is_open_24_7 BOOLEAN GENERATED ALWAYS AS ( (operating_hours->>'is_24_7')::boolean ) STORED;
Database Design Checklist
Schema Design: □ UUID primary keys on all tables (except users which uses INT4) □ Required audit columns on ALL tables (created_at, updated_at, created_by, updated_by, deleted_at, deleted_by) □ Soft delete pattern with deleted_at (no hard deletes) □ Foreign keys with appropriate CASCADE/SET NULL/RESTRICT □ CHECK constraints for business validation □ UNIQUE constraints for natural keys □ ENUMs for stable value sets (status workflows, categories) □ TIMESTAMPTZ for all timestamps (NEVER TIMESTAMP) □ TEXT for strings (NEVER VARCHAR or CHAR) □ NUMERIC for money (NEVER REAL, FLOAT, or MONEY type) □ JSONB for flexible data (NEVER JSON) Indexes: □ All foreign keys manually indexed (PostgreSQL doesn't auto-index) □ Partial indexes for soft deletes (WHERE deleted_at IS NULL) □ Partial indexes for nullable FKs (WHERE column IS NOT NULL) □ Status columns indexed □ Timestamp columns indexed □ GIN indexes for JSONB containment queries □ Composite indexes for multi-column queries Functions & Triggers: □ All functions use SECURITY INVOKER + SET search_path = 'public' □ Updated_at trigger on all tables □ Audit trigger on all tables (if audit_log enabled) □ Sync triggers for denormalized columns □ Validation triggers for business rules □ COMMENT on all functions and triggers Views: □ All views use WITH (security_invoker = on) □ Views filter deleted_at IS NULL for soft deletes □ COMMENT on all views explaining purpose Migrations: □ Atomic migrations (one operation per file) □ Naming: [YYYYMMDDHHMMSS]_[descriptive_name].sql □ Header comment describing purpose □ COMMENT on all tables, columns, functions, triggers, views □ Separate files for ENUMs, tables, indexes, triggers, functions, views □ Version controlled in supabase/migrations/ RLS: □ RLS enabled on all tables □ Policies defined for SELECT, INSERT, UPDATE, DELETE □ Admin bypass policies for administrative users
Key References
Authoritative Examples (within laneweaverTMS repository):
- Schema:
(root of repository)./erd.sql - Migrations:
supabase/migrations/ - Conventions:
supabase/CLAUDE.md
External Resources:
Remember: Consistency is critical. Every table follows the same patterns for audit columns, soft deletes, indexing, triggers, and comments. This makes the codebase predictable and maintainable.