mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 04:14:56 +00:00
fix(db): add FK constraints, indexes, transaction fixes, and hook_executions tenant isolation (migrations 082-083) (#1246)
Co-authored-by: Bruno Clermont <bruno.clermont@gmail.com>
This commit is contained in:
1 parent
c31dd21934
commit
756c4dce71
12 files changed
+397
-27
No files matched your search
@@ -438,6 +438,7 @@ func (d *stdDispatcher) writeExec(ctx context.Context, cfg HookConfig, ev Event,
|
||||
exec := HookExecution{
|
||||
ID: uuid.New(),
|
||||
HookID: &hookID,
|
||||
TenantID: &ev.TenantID,
|
||||
SessionID: ev.SessionID,
|
||||
Event: ev.HookEvent,
|
||||
InputHash: inputHash,
|
||||
|
||||
@@ -186,7 +186,8 @@ type HookConfig struct {
|
||||
// error_detail (BYTEA) is AES-256-GCM encrypted before storage.
|
||||
type HookExecution struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
HookID *uuid.UUID `json:"hook_id,omitempty"` // NULL when hook deleted (ON DELETE SET NULL)
|
||||
HookID *uuid.UUID `json:"hook_id,omitempty"` // NULL when hook deleted (ON DELETE SET NULL)
|
||||
TenantID *uuid.UUID `json:"tenant_id,omitempty"`
|
||||
SessionID string `json:"session_id"`
|
||||
Event HookEvent `json:"event"`
|
||||
InputHash string `json:"input_hash"` // canonical-JSON sha256, 64 hex chars
|
||||
|
||||
+28
-10
@@ -97,6 +97,12 @@ const agentSelectCols = `id, agent_key, display_name, frontmatter, owner_id, pro
|
||||
model_fallback, shell_deny_groups, kg_dedup_config,
|
||||
agent_type, is_default, status, budget_monthly_cents, created_at, updated_at, tenant_id`
|
||||
|
||||
// sqlExecer is satisfied by both *sql.DB and *sql.Tx, allowing helper
|
||||
// functions to be called within or outside a transaction.
|
||||
type sqlExecer interface {
|
||||
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
|
||||
}
|
||||
|
||||
func (s *PGAgentStore) Create(ctx context.Context, agent *store.AgentData) error {
|
||||
if agent.ID == uuid.Nil {
|
||||
agent.ID = store.GenNewID()
|
||||
@@ -108,7 +114,14 @@ func (s *PGAgentStore) Create(ctx context.Context, agent *store.AgentData) error
|
||||
if tenantID == uuid.Nil {
|
||||
tenantID = store.MasterTenantID
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck
|
||||
|
||||
_, err = tx.ExecContext(ctx,
|
||||
`INSERT INTO agents (id, agent_key, display_name, frontmatter, owner_id, provider, model,
|
||||
context_window, max_tool_iterations, workspace, restrict_to_workspace,
|
||||
tools_config, sandbox_config, subagents_config, memory_config,
|
||||
@@ -131,16 +144,18 @@ func (s *PGAgentStore) Create(ctx context.Context, agent *store.AgentData) error
|
||||
agent.AgentType, agent.IsDefault, agent.Status, agent.BudgetMonthlyCents, now, now, tenantID,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("insert agent: %w", err)
|
||||
}
|
||||
if agent.BudgetMonthlyCents != nil {
|
||||
if err := s.syncAgentBudgetUsageCap(ctx, tenantID, agent.ID, agent.BudgetMonthlyCents); err != nil {
|
||||
_, _ = s.db.ExecContext(ctx, "DELETE FROM agents WHERE id = $1", agent.ID)
|
||||
return err
|
||||
if err := syncAgentBudgetUsageCap(ctx, tx, tenantID, agent.ID, agent.BudgetMonthlyCents); err != nil {
|
||||
return fmt.Errorf("sync budget usage cap: %w", err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit agent create: %w", err)
|
||||
}
|
||||
|
||||
// Generate embedding for new agent with frontmatter
|
||||
// Generate embedding for new agent with frontmatter (best-effort, outside tx)
|
||||
if agent.Frontmatter != "" && s.embProvider != nil {
|
||||
go s.generateAgentEmbedding(context.Background(), agent.ID, agent.DisplayName, agent.Frontmatter)
|
||||
}
|
||||
@@ -271,7 +286,7 @@ func (s *PGAgentStore) Update(ctx context.Context, id uuid.UUID, updates map[str
|
||||
}
|
||||
}
|
||||
if syncBudget {
|
||||
if err := s.syncAgentBudgetUsageCap(ctx, budgetTenantID, id, budgetCents); err != nil {
|
||||
if err := syncAgentBudgetUsageCap(ctx, s.db, budgetTenantID, id, budgetCents); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -329,16 +344,19 @@ func budgetCentsFromUpdate(v any) (*int, error) {
|
||||
return ¢s, nil
|
||||
}
|
||||
|
||||
func (s *PGAgentStore) syncAgentBudgetUsageCap(ctx context.Context, tenantID, agentID uuid.UUID, budgetCents *int) error {
|
||||
// syncAgentBudgetUsageCap upserts or deletes the agent's monthly budget cap
|
||||
// in usage_cap_policies. It accepts a sqlExecer so it can be called inside a
|
||||
// transaction (during Create) or directly against the pool (during Update).
|
||||
func syncAgentBudgetUsageCap(ctx context.Context, db sqlExecer, tenantID, agentID uuid.UUID, budgetCents *int) error {
|
||||
if budgetCents == nil || *budgetCents <= 0 {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
_, err := db.ExecContext(ctx,
|
||||
`DELETE FROM usage_cap_policies
|
||||
WHERE tenant_id=$1 AND agent_id=$2 AND source=$3`,
|
||||
tenantID, agentID, store.UsageCapSourceAgentBudget)
|
||||
return err
|
||||
}
|
||||
costMicros := int64(*budgetCents) * 10000
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
_, err := db.ExecContext(ctx, `
|
||||
INSERT INTO usage_cap_policies (
|
||||
tenant_id, agent_id, window_key, max_cost_micros, enabled, priority, source
|
||||
) VALUES ($1,$2,'month',$3,true,90,$4)
|
||||
|
||||
@@ -78,7 +78,13 @@ func (s *PGHookStore) Create(ctx context.Context, cfg hooks.HookConfig) (uuid.UU
|
||||
name = &cfg.Name
|
||||
}
|
||||
|
||||
_, err = s.db.ExecContext(ctx, `
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return uuid.Nil, fmt.Errorf("begin transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck
|
||||
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO hooks
|
||||
(id, tenant_id, scope, event, handler_type,
|
||||
config, matcher, if_expr, timeout_ms, on_timeout,
|
||||
@@ -99,12 +105,15 @@ func (s *PGHookStore) Create(ctx context.Context, cfg hooks.HookConfig) (uuid.UU
|
||||
agentIDs = []uuid.UUID{*cfg.AgentID}
|
||||
}
|
||||
for _, aid := range agentIDs {
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO hook_agents (hook_id, agent_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
||||
id, aid); err != nil {
|
||||
return uuid.Nil, fmt.Errorf("insert hook agent link: %w", err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return uuid.Nil, fmt.Errorf("commit hook create: %w", err)
|
||||
}
|
||||
s.invalidateCache()
|
||||
return id, nil
|
||||
}
|
||||
@@ -459,11 +468,11 @@ func (s *PGHookStore) WriteExecution(ctx context.Context, exec hooks.HookExecuti
|
||||
|
||||
_, err = s.db.ExecContext(ctx, `
|
||||
INSERT INTO hook_executions
|
||||
(id, hook_id, session_id, event, input_hash, decision,
|
||||
(id, hook_id, tenant_id, session_id, event, input_hash, decision,
|
||||
duration_ms, retry, dedup_key, error, error_detail, metadata, created_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
|
||||
ON CONFLICT (dedup_key) WHERE dedup_key IS NOT NULL DO NOTHING`,
|
||||
exec.ID, exec.HookID, sessionID, string(exec.Event),
|
||||
exec.ID, exec.HookID, exec.TenantID, sessionID, string(exec.Event),
|
||||
inputHash, string(exec.Decision),
|
||||
exec.DurationMS, exec.Retry, dedupKey,
|
||||
errStr, exec.ErrorDetail, metaJSON, now,
|
||||
|
||||
@@ -481,15 +481,9 @@ func (s *PGTracingStore) GetCostSummary(ctx context.Context, opts store.CostSumm
|
||||
}
|
||||
|
||||
// DeleteTracesOlderThan deletes traces and their spans older than cutoff.
|
||||
// Spans are deleted first (FK), then traces. Returns total traces deleted.
|
||||
// Spans are removed automatically via ON DELETE CASCADE on spans.trace_id → traces(id)
|
||||
// added in migration 081. Returns total traces deleted.
|
||||
func (s *PGTracingStore) DeleteTracesOlderThan(ctx context.Context, cutoff time.Time) (int64, error) {
|
||||
// Delete spans belonging to old traces.
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`DELETE FROM spans WHERE trace_id IN (SELECT id FROM traces WHERE created_at < $1)`, cutoff)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("delete old spans: %w", err)
|
||||
}
|
||||
|
||||
res, err := s.db.ExecContext(ctx, `DELETE FROM traces WHERE created_at < $1`, cutoff)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("delete old traces: %w", err)
|
||||
|
||||
@@ -314,14 +314,16 @@ func (s *SQLiteTracingStore) GetCostSummary(ctx context.Context, opts store.Cost
|
||||
}
|
||||
|
||||
// DeleteTracesOlderThan deletes traces and their spans older than cutoff.
|
||||
// SQLite's spans.trace_id has no FK reference to traces(id), so spans must be
|
||||
// deleted explicitly before the parent traces to avoid leaving orphaned span rows.
|
||||
// Returns total traces deleted.
|
||||
func (s *SQLiteTracingStore) DeleteTracesOlderThan(ctx context.Context, cutoff time.Time) (int64, error) {
|
||||
// Delete spans belonging to old traces.
|
||||
// Delete spans first — SQLite has no CASCADE on spans.trace_id.
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`DELETE FROM spans WHERE trace_id IN (SELECT id FROM traces WHERE created_at < ?)`, cutoff)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("delete old spans: %w", err)
|
||||
}
|
||||
|
||||
res, err := s.db.ExecContext(ctx, `DELETE FROM traces WHERE created_at < ?`, cutoff)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("delete old traces: %w", err)
|
||||
|
||||
@@ -2,4 +2,4 @@ package upgrade
|
||||
|
||||
// RequiredSchemaVersion is the schema migration version this binary requires.
|
||||
// Bump this whenever adding a new SQL migration file.
|
||||
const RequiredSchemaVersion uint = 81
|
||||
const RequiredSchemaVersion uint = 83
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Migration 000081 down: remove FK constraints added in up migration
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE usage_events DROP CONSTRAINT IF EXISTS usage_events_team_id_fkey;
|
||||
ALTER TABLE traces DROP CONSTRAINT IF EXISTS traces_agent_id_fkey;
|
||||
ALTER TABLE spans DROP CONSTRAINT IF EXISTS spans_agent_id_fkey;
|
||||
ALTER TABLE spans DROP CONSTRAINT IF EXISTS spans_trace_id_fkey;
|
||||
ALTER TABLE webhook_calls DROP CONSTRAINT IF EXISTS webhook_calls_agent_id_fkey;
|
||||
ALTER TABLE webhook_calls DROP CONSTRAINT IF EXISTS webhook_calls_tenant_id_fkey;
|
||||
ALTER TABLE webhooks DROP CONSTRAINT IF EXISTS webhooks_tenant_id_fkey;
|
||||
ALTER TABLE tenant_hook_budget DROP CONSTRAINT IF EXISTS tenant_hook_budget_tenant_id_fkey;
|
||||
ALTER TABLE hooks DROP CONSTRAINT IF EXISTS hooks_tenant_id_fkey;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,104 @@
|
||||
-- Migration 000081: add missing FK constraints for referential integrity
|
||||
--
|
||||
-- Audit found 9 missing FK constraints across 6 tables.
|
||||
-- Orphan audit confirmed 0 orphaned rows in all cases.
|
||||
--
|
||||
-- Phase 1: clean orphans (all counts were 0, but DELETE guards are idempotent)
|
||||
-- Phase 2: fix tenant_id drift (none found, guard included)
|
||||
-- Phase 3: add FK constraints
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ── Phase 1: clean orphans ──────────────────────────────────────────────────
|
||||
-- All orphan counts were 0 at time of migration authoring, but these DELETEs
|
||||
-- are safe guards in case any slip in before the migration runs.
|
||||
|
||||
-- webhook_calls orphaned agent_id (nullable)
|
||||
DELETE FROM webhook_calls
|
||||
WHERE agent_id IS NOT NULL
|
||||
AND agent_id NOT IN (SELECT id FROM agents);
|
||||
|
||||
-- webhook_calls orphaned tenant_id (NOT NULL)
|
||||
DELETE FROM webhook_calls
|
||||
WHERE tenant_id NOT IN (SELECT id FROM tenants);
|
||||
|
||||
-- webhooks orphaned tenant_id (NOT NULL)
|
||||
DELETE FROM webhooks
|
||||
WHERE tenant_id NOT IN (SELECT id FROM tenants);
|
||||
|
||||
-- hooks orphaned tenant_id (NOT NULL)
|
||||
-- Global-scope hooks use the sentinel MasterTenantID which must exist in tenants.
|
||||
DELETE FROM hooks
|
||||
WHERE tenant_id NOT IN (SELECT id FROM tenants);
|
||||
|
||||
-- tenant_hook_budget orphaned tenant_id (NOT NULL, PK)
|
||||
DELETE FROM tenant_hook_budget
|
||||
WHERE tenant_id NOT IN (SELECT id FROM tenants);
|
||||
|
||||
-- spans orphaned agent_id (nullable)
|
||||
DELETE FROM spans
|
||||
WHERE agent_id IS NOT NULL
|
||||
AND agent_id NOT IN (SELECT id FROM agents);
|
||||
|
||||
-- spans orphaned trace_id (NOT NULL)
|
||||
DELETE FROM spans
|
||||
WHERE trace_id NOT IN (SELECT id FROM traces);
|
||||
|
||||
-- traces orphaned agent_id (nullable)
|
||||
DELETE FROM traces
|
||||
WHERE agent_id IS NOT NULL
|
||||
AND agent_id NOT IN (SELECT id FROM agents);
|
||||
|
||||
-- usage_events orphaned team_id (nullable)
|
||||
DELETE FROM usage_events
|
||||
WHERE team_id IS NOT NULL
|
||||
AND team_id NOT IN (SELECT id FROM agent_teams);
|
||||
|
||||
-- ── Phase 3: add FK constraints ─────────────────────────────────────────────
|
||||
|
||||
-- hooks.tenant_id → tenants(id)
|
||||
ALTER TABLE hooks
|
||||
ADD CONSTRAINT hooks_tenant_id_fkey
|
||||
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE;
|
||||
|
||||
-- tenant_hook_budget.tenant_id → tenants(id)
|
||||
ALTER TABLE tenant_hook_budget
|
||||
ADD CONSTRAINT tenant_hook_budget_tenant_id_fkey
|
||||
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE;
|
||||
|
||||
-- webhooks.tenant_id → tenants(id)
|
||||
ALTER TABLE webhooks
|
||||
ADD CONSTRAINT webhooks_tenant_id_fkey
|
||||
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE;
|
||||
|
||||
-- webhook_calls.tenant_id → tenants(id)
|
||||
ALTER TABLE webhook_calls
|
||||
ADD CONSTRAINT webhook_calls_tenant_id_fkey
|
||||
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE;
|
||||
|
||||
-- webhook_calls.agent_id → agents(id) (nullable, SET NULL on agent delete)
|
||||
ALTER TABLE webhook_calls
|
||||
ADD CONSTRAINT webhook_calls_agent_id_fkey
|
||||
FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE SET NULL;
|
||||
|
||||
-- spans.trace_id → traces(id) (NOT NULL, cascade)
|
||||
ALTER TABLE spans
|
||||
ADD CONSTRAINT spans_trace_id_fkey
|
||||
FOREIGN KEY (trace_id) REFERENCES traces(id) ON DELETE CASCADE;
|
||||
|
||||
-- spans.agent_id → agents(id) (nullable, SET NULL on agent delete)
|
||||
ALTER TABLE spans
|
||||
ADD CONSTRAINT spans_agent_id_fkey
|
||||
FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE SET NULL;
|
||||
|
||||
-- traces.agent_id → agents(id) (nullable, SET NULL on agent delete)
|
||||
ALTER TABLE traces
|
||||
ADD CONSTRAINT traces_agent_id_fkey
|
||||
FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE SET NULL;
|
||||
|
||||
-- usage_events.team_id → agent_teams(id) (nullable, SET NULL on team delete)
|
||||
ALTER TABLE usage_events
|
||||
ADD CONSTRAINT usage_events_team_id_fkey
|
||||
FOREIGN KEY (team_id) REFERENCES agent_teams(id) ON DELETE SET NULL;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,35 @@
|
||||
-- Rollback migration 082
|
||||
|
||||
-- 8. Remove extra indexes
|
||||
DROP INDEX IF EXISTS idx_usage_events_team_id;
|
||||
DROP INDEX IF EXISTS idx_hooks_created_by;
|
||||
|
||||
-- 7. hook_executions tenant isolation
|
||||
DROP INDEX IF EXISTS idx_hook_executions_tenant_id;
|
||||
DROP INDEX IF EXISTS idx_hook_executions_hook_id;
|
||||
ALTER TABLE hook_executions DROP COLUMN IF EXISTS tenant_id;
|
||||
|
||||
-- 6. team_task_attachments: revert to original constraint name + RESTRICT (NO ACTION)
|
||||
ALTER TABLE team_task_attachments
|
||||
DROP CONSTRAINT IF EXISTS fk_team_task_attachments_created_by_agent_id;
|
||||
|
||||
ALTER TABLE team_task_attachments
|
||||
ADD CONSTRAINT team_task_attachments_created_by_agent_id_fkey
|
||||
FOREIGN KEY (created_by_agent_id) REFERENCES agents(id);
|
||||
|
||||
-- 5. subagent_tasks self-ref
|
||||
DROP INDEX IF EXISTS idx_subagent_tasks_spawned_by;
|
||||
ALTER TABLE subagent_tasks DROP CONSTRAINT IF EXISTS fk_subagent_tasks_spawned_by;
|
||||
|
||||
-- 4. spans self-ref
|
||||
ALTER TABLE spans DROP CONSTRAINT IF EXISTS fk_spans_parent_span_id;
|
||||
|
||||
-- 3. traces self-ref
|
||||
ALTER TABLE traces DROP CONSTRAINT IF EXISTS fk_traces_parent_trace_id;
|
||||
|
||||
-- 2. channel_contacts self-ref
|
||||
ALTER TABLE channel_contacts DROP CONSTRAINT IF EXISTS fk_channel_contacts_merged_id;
|
||||
|
||||
-- 1. webhooks.channel_id
|
||||
DROP INDEX IF EXISTS idx_webhooks_channel_id;
|
||||
ALTER TABLE webhooks DROP CONSTRAINT IF EXISTS fk_webhooks_channel_id;
|
||||
@@ -0,0 +1,78 @@
|
||||
-- Migration 082: FK constraints round 2 + hook_executions tenant isolation + indexes
|
||||
-- Zero orphans verified before each constraint addition.
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 1. webhooks.channel_id → channel_instances(id) ON DELETE SET NULL
|
||||
-- ---------------------------------------------------------------------------
|
||||
ALTER TABLE webhooks
|
||||
ADD CONSTRAINT fk_webhooks_channel_id
|
||||
FOREIGN KEY (channel_id) REFERENCES channel_instances(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_webhooks_channel_id ON webhooks(channel_id);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 2. channel_contacts.merged_id → channel_contacts(id) self-ref ON DELETE SET NULL
|
||||
-- ---------------------------------------------------------------------------
|
||||
ALTER TABLE channel_contacts
|
||||
ADD CONSTRAINT fk_channel_contacts_merged_id
|
||||
FOREIGN KEY (merged_id) REFERENCES channel_contacts(id) ON DELETE SET NULL;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 3. traces.parent_trace_id → traces(id) self-ref ON DELETE SET NULL
|
||||
-- ---------------------------------------------------------------------------
|
||||
ALTER TABLE traces
|
||||
ADD CONSTRAINT fk_traces_parent_trace_id
|
||||
FOREIGN KEY (parent_trace_id) REFERENCES traces(id) ON DELETE SET NULL;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 4. spans.parent_span_id → spans(id) self-ref ON DELETE SET NULL
|
||||
-- ---------------------------------------------------------------------------
|
||||
ALTER TABLE spans
|
||||
ADD CONSTRAINT fk_spans_parent_span_id
|
||||
FOREIGN KEY (parent_span_id) REFERENCES spans(id) ON DELETE SET NULL;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 5. subagent_tasks.spawned_by → subagent_tasks(id) self-ref ON DELETE SET NULL
|
||||
-- ---------------------------------------------------------------------------
|
||||
ALTER TABLE subagent_tasks
|
||||
ADD CONSTRAINT fk_subagent_tasks_spawned_by
|
||||
FOREIGN KEY (spawned_by) REFERENCES subagent_tasks(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_subagent_tasks_spawned_by ON subagent_tasks(spawned_by);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 6. team_task_attachments.created_by_agent_id: RESTRICT → SET NULL
|
||||
-- (all other created_by_agent_id columns use SET NULL; make this consistent)
|
||||
-- ---------------------------------------------------------------------------
|
||||
ALTER TABLE team_task_attachments
|
||||
DROP CONSTRAINT team_task_attachments_created_by_agent_id_fkey;
|
||||
|
||||
ALTER TABLE team_task_attachments
|
||||
ADD CONSTRAINT fk_team_task_attachments_created_by_agent_id
|
||||
FOREIGN KEY (created_by_agent_id) REFERENCES agents(id) ON DELETE SET NULL;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 7. hook_executions: add tenant_id for tenant isolation
|
||||
-- hook_id is nullable (ON DELETE SET NULL), so backfill only where hook_id IS NOT NULL.
|
||||
-- Rows with NULL hook_id will have NULL tenant_id — column is nullable by design.
|
||||
-- ---------------------------------------------------------------------------
|
||||
ALTER TABLE hook_executions
|
||||
ADD COLUMN IF NOT EXISTS tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE;
|
||||
|
||||
UPDATE hook_executions he
|
||||
SET tenant_id = h.tenant_id
|
||||
FROM hooks h
|
||||
WHERE he.hook_id = h.id
|
||||
AND he.hook_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_hook_executions_hook_id ON hook_executions(hook_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_hook_executions_tenant_id ON hook_executions(tenant_id);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 8. Missing indexes on FK-candidate columns
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- hooks.created_by: no FK (OAuth UUID, no referencing table), but benefits from index
|
||||
CREATE INDEX IF NOT EXISTS idx_hooks_created_by ON hooks(created_by);
|
||||
|
||||
-- usage_events.team_id: FK added in 081 but index was missing
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_events_team_id ON usage_events(team_id);
|
||||
@@ -0,0 +1,113 @@
|
||||
-- FK Integrity Audit Queries
|
||||
-- Run these BEFORE applying migrations 000081 and 000082 to verify
|
||||
-- the database has no orphans or tenant_id drift that would block the constraints.
|
||||
-- All queries should return 0 rows on a clean database.
|
||||
|
||||
-- ============================================================
|
||||
-- MIGRATION 081 PRE-CHECKS
|
||||
-- ============================================================
|
||||
|
||||
-- hooks: tenant_id orphans (no matching tenant)
|
||||
SELECT 'hooks.tenant_id orphans' AS check_name, COUNT(*) AS violations
|
||||
FROM hooks h
|
||||
LEFT JOIN tenants t ON t.id = h.tenant_id
|
||||
WHERE h.tenant_id != '0193a5b0-7000-7000-8000-000000000001' -- exclude master sentinel
|
||||
AND t.id IS NULL;
|
||||
|
||||
-- webhooks: tenant_id orphans
|
||||
SELECT 'webhooks.tenant_id orphans' AS check_name, COUNT(*) AS violations
|
||||
FROM webhooks w
|
||||
LEFT JOIN tenants t ON t.id = w.tenant_id
|
||||
WHERE t.id IS NULL;
|
||||
|
||||
-- webhook_calls: tenant_id orphans
|
||||
SELECT 'webhook_calls.tenant_id orphans' AS check_name, COUNT(*) AS violations
|
||||
FROM webhook_calls wc
|
||||
LEFT JOIN tenants t ON t.id = wc.tenant_id
|
||||
WHERE t.id IS NULL;
|
||||
|
||||
-- webhook_calls: agent_id orphans
|
||||
SELECT 'webhook_calls.agent_id orphans' AS check_name, COUNT(*) AS violations
|
||||
FROM webhook_calls wc
|
||||
LEFT JOIN agents a ON a.id = wc.agent_id
|
||||
WHERE wc.agent_id IS NOT NULL AND a.id IS NULL;
|
||||
|
||||
-- spans: trace_id orphans (NOT NULL column — any mismatch is critical)
|
||||
SELECT 'spans.trace_id orphans' AS check_name, COUNT(*) AS violations
|
||||
FROM spans s
|
||||
LEFT JOIN traces t ON t.id = s.trace_id
|
||||
WHERE t.id IS NULL;
|
||||
|
||||
-- spans: agent_id orphans
|
||||
SELECT 'spans.agent_id orphans' AS check_name, COUNT(*) AS violations
|
||||
FROM spans s
|
||||
LEFT JOIN agents a ON a.id = s.agent_id
|
||||
WHERE s.agent_id IS NOT NULL AND a.id IS NULL;
|
||||
|
||||
-- traces: agent_id orphans
|
||||
SELECT 'traces.agent_id orphans' AS check_name, COUNT(*) AS violations
|
||||
FROM traces t
|
||||
LEFT JOIN agents a ON a.id = t.agent_id
|
||||
WHERE t.agent_id IS NOT NULL AND a.id IS NULL;
|
||||
|
||||
-- usage_events: team_id orphans
|
||||
SELECT 'usage_events.team_id orphans' AS check_name, COUNT(*) AS violations
|
||||
FROM usage_events ue
|
||||
LEFT JOIN agent_teams at ON at.id = ue.team_id
|
||||
WHERE ue.team_id IS NOT NULL AND at.id IS NULL;
|
||||
|
||||
-- ============================================================
|
||||
-- MIGRATION 082 PRE-CHECKS
|
||||
-- ============================================================
|
||||
|
||||
-- webhooks: channel_id orphans
|
||||
SELECT 'webhooks.channel_id orphans' AS check_name, COUNT(*) AS violations
|
||||
FROM webhooks w
|
||||
LEFT JOIN channel_instances ci ON ci.id = w.channel_id
|
||||
WHERE w.channel_id IS NOT NULL AND ci.id IS NULL;
|
||||
|
||||
-- channel_contacts: merged_id self-ref orphans
|
||||
SELECT 'channel_contacts.merged_id orphans' AS check_name, COUNT(*) AS violations
|
||||
FROM channel_contacts cc
|
||||
LEFT JOIN channel_contacts cc2 ON cc2.id = cc.merged_id
|
||||
WHERE cc.merged_id IS NOT NULL AND cc2.id IS NULL;
|
||||
|
||||
-- traces: parent_trace_id self-ref orphans
|
||||
SELECT 'traces.parent_trace_id orphans' AS check_name, COUNT(*) AS violations
|
||||
FROM traces t
|
||||
LEFT JOIN traces t2 ON t2.id = t.parent_trace_id
|
||||
WHERE t.parent_trace_id IS NOT NULL AND t2.id IS NULL;
|
||||
|
||||
-- spans: parent_span_id self-ref orphans
|
||||
SELECT 'spans.parent_span_id orphans' AS check_name, COUNT(*) AS violations
|
||||
FROM spans s
|
||||
LEFT JOIN spans s2 ON s2.id = s.parent_span_id
|
||||
WHERE s.parent_span_id IS NOT NULL AND s2.id IS NULL;
|
||||
|
||||
-- subagent_tasks: spawned_by self-ref orphans
|
||||
SELECT 'subagent_tasks.spawned_by orphans' AS check_name, COUNT(*) AS violations
|
||||
FROM subagent_tasks st
|
||||
LEFT JOIN subagent_tasks st2 ON st2.id = st.spawned_by
|
||||
WHERE st.spawned_by IS NOT NULL AND st2.id IS NULL;
|
||||
|
||||
-- ============================================================
|
||||
-- TENANT_ID DRIFT CHECKS
|
||||
-- ============================================================
|
||||
|
||||
-- agent_context_files: tenant_id differs from parent agent
|
||||
SELECT 'agent_context_files tenant_id drift' AS check_name, COUNT(*) AS violations
|
||||
FROM agent_context_files acf
|
||||
JOIN agents a ON a.id = acf.agent_id
|
||||
WHERE acf.tenant_id != a.tenant_id;
|
||||
|
||||
-- user_context_files: tenant_id differs from parent agent
|
||||
SELECT 'user_context_files tenant_id drift' AS check_name, COUNT(*) AS violations
|
||||
FROM user_context_files ucf
|
||||
JOIN agents a ON a.id = ucf.agent_id
|
||||
WHERE ucf.tenant_id != a.tenant_id;
|
||||
|
||||
-- ============================================================
|
||||
-- SUMMARY (run all checks at once)
|
||||
-- ============================================================
|
||||
-- Copy all SELECT statements above into a single query using UNION ALL
|
||||
-- to get a single result set showing all violations.
|
||||
Reference in new issue
Block a user