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:
2026-05-11 13:47:52 +02:00
parent 0804944647
commit 1b00c8a7a2
3 changed files with 110 additions and 1 deletions

View 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");

View File

@@ -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' }),

View File

@@ -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),
],
);