feat(db): tighten chk_system_folder_shape, add recommender FK + composite indexes
- Fix A5: chk_system_folder_shape NULL escape - Fix Audit 17 G-I4: berthRecommendations.interestId FK with cascade - Add (port_id, client_id) / (port_id, company_id) / (port_id, yacht_id) composite indexes on files + documents for aggregated-projection performance Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
94
src/lib/db/migrations/0052_audit_critical_fixes.sql
Normal file
94
src/lib/db/migrations/0052_audit_critical_fixes.sql
Normal file
@@ -0,0 +1,94 @@
|
||||
-- Prod-readiness audit 2026-05-11 follow-ups (A5 + Audit-17 G-I4 + perf).
|
||||
-- Fully idempotent — safe to re-run.
|
||||
--
|
||||
-- IMPORTANT: This migration creates indexes CONCURRENTLY, which Postgres
|
||||
-- forbids inside a transaction block. When applying via `psql`, do NOT
|
||||
-- wrap the file in `BEGIN/COMMIT`. The DO blocks (for constraint adds)
|
||||
-- each open their own implicit transaction, which is fine.
|
||||
--
|
||||
-- ─── A5: tighten chk_system_folder_shape (NULL escape) ────────────────────
|
||||
-- The constraint installed by 0051 evaluates to NULL when entity_type IS
|
||||
-- NULL AND system_managed = true. Postgres treats NULL as "not false" so
|
||||
-- the constraint passes. Tighten the predicate so system_managed=true
|
||||
-- always implies a non-null entity_type.
|
||||
-- Drop the old constraint (if present from 0051) then re-create with the
|
||||
-- tightened predicate. Wrapped in DO blocks for idempotency.
|
||||
|
||||
DO $$ BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'chk_system_folder_shape'
|
||||
) THEN
|
||||
ALTER TABLE "document_folders"
|
||||
DROP CONSTRAINT "chk_system_folder_shape";
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'chk_system_folder_shape'
|
||||
) THEN
|
||||
ALTER TABLE "document_folders"
|
||||
ADD CONSTRAINT "chk_system_folder_shape" CHECK (
|
||||
NOT system_managed
|
||||
OR (
|
||||
entity_type IS NOT NULL
|
||||
AND (
|
||||
entity_type = 'root'
|
||||
OR (
|
||||
entity_type = ANY(ARRAY['client','company','yacht'])
|
||||
AND entity_id IS NOT NULL
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ─── Audit-17 G-I4: berth_recommendations.interest_id missing FK ──────────
|
||||
-- The column has carried an `// references interests.id` comment since
|
||||
-- introduction but no actual constraint. If an interest is hard-deleted,
|
||||
-- stale berth_recommendations rows persist and skew the recommender tier
|
||||
-- aggregates. Cascade delete keeps the dependent rows in sync with the
|
||||
-- parent interest.
|
||||
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'berth_recommendations_interest_id_fkey'
|
||||
) THEN
|
||||
ALTER TABLE "berth_recommendations"
|
||||
ADD CONSTRAINT "berth_recommendations_interest_id_fkey"
|
||||
FOREIGN KEY ("interest_id") REFERENCES "interests" ("id")
|
||||
ON DELETE CASCADE NOT VALID;
|
||||
ALTER TABLE "berth_recommendations"
|
||||
VALIDATE CONSTRAINT "berth_recommendations_interest_id_fkey";
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ─── Performance: composite indexes for the aggregated projection ─────────
|
||||
-- listFilesAggregatedByEntity / listInflightWorkflowsAggregatedByEntity
|
||||
-- always filter by port_id (defense-in-depth at every join) and then by
|
||||
-- one of client_id / company_id / yacht_id. The existing single-column
|
||||
-- indexes (idx_files_client etc.) don't include port_id so Postgres
|
||||
-- either re-checks per row or falls back to a sequential scan once the
|
||||
-- per-port row counts grow. Add the composites BEFORE the prod backfill
|
||||
-- so the rebuild rides the new indexes.
|
||||
--
|
||||
-- CONCURRENTLY avoids the ShareLock that blocks writes during a normal
|
||||
-- CREATE INDEX. It can fail mid-build — IF NOT EXISTS skips on re-run,
|
||||
-- but a failed/invalid index from a prior attempt needs to be dropped
|
||||
-- before this migration succeeds (check `pg_indexes` + `pg_index.indisvalid`).
|
||||
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS "idx_files_port_client"
|
||||
ON "files" ("port_id", "client_id");
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS "idx_files_port_company"
|
||||
ON "files" ("port_id", "company_id");
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS "idx_files_port_yacht"
|
||||
ON "files" ("port_id", "yacht_id");
|
||||
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS "idx_docs_port_client"
|
||||
ON "documents" ("port_id", "client_id");
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS "idx_docs_port_company"
|
||||
ON "documents" ("port_id", "company_id");
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS "idx_docs_port_yacht"
|
||||
ON "documents" ("port_id", "yacht_id");
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { ports } from './ports';
|
||||
import { clients } from './clients';
|
||||
import { yachts } from './yachts';
|
||||
import { interests } from './interests';
|
||||
|
||||
export const berths = pgTable(
|
||||
'berths',
|
||||
@@ -131,7 +132,9 @@ export const berthRecommendations = pgTable(
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
interestId: text('interest_id').notNull(), // references interests.id
|
||||
interestId: text('interest_id')
|
||||
.notNull()
|
||||
.references(() => interests.id, { onDelete: 'cascade' }),
|
||||
berthId: text('berth_id')
|
||||
.notNull()
|
||||
.references(() => berths.id, { onDelete: 'cascade' }),
|
||||
|
||||
@@ -50,6 +50,12 @@ export const files = pgTable(
|
||||
index('idx_files_company').on(table.companyId),
|
||||
index('idx_files_folder').on(table.folderId),
|
||||
index('idx_files_port_folder').on(table.portId, table.folderId),
|
||||
// Composite indexes for the aggregated-projection queries
|
||||
// (`listFilesAggregatedByEntity`) — every join carries a defense-in-
|
||||
// depth `port_id` filter so the leading column matters at scale.
|
||||
index('idx_files_port_client').on(table.portId, table.clientId),
|
||||
index('idx_files_port_company').on(table.portId, table.companyId),
|
||||
index('idx_files_port_yacht').on(table.portId, table.yachtId),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -109,6 +115,12 @@ export const documents = pgTable(
|
||||
index('idx_docs_file_id').on(table.fileId),
|
||||
index('idx_docs_signed_file_id').on(table.signedFileId),
|
||||
index('idx_docs_folder').on(table.folderId),
|
||||
// Composite indexes for the aggregated-projection queries
|
||||
// (`listInflightWorkflowsAggregatedByEntity`) — every join carries a
|
||||
// defense-in-depth `port_id` filter so the leading column matters at scale.
|
||||
index('idx_docs_port_client').on(table.portId, table.clientId),
|
||||
index('idx_docs_port_company').on(table.portId, table.companyId),
|
||||
index('idx_docs_port_yacht').on(table.portId, table.yachtId),
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user