phase 2 (part 2): sqlc queries, audit/event helpers, event NOTIFY trigger

- sqlc.yaml + internal/db/queries/*.sql: typed queries for entities,
  relationships, ontology, operations (signals, events, audit,
  idempotency, entity_status)
- internal/db/sqlcgen/: generated Go from sqlc (pgx/v5)
- internal/observability/record.go: Audit() and Event() helpers that
  write in the caller's transaction (SG10). actorLabel is interim text
  identity in detail JSON until OIDC resolution lands; actor_id column
  exists but is not yet populated
- migrations/008: post-commit pg_notify trigger on events table for
  SSE fan-out (SG8/SG10)
This commit is contained in:
2026-07-07 08:49:59 +02:00
parent f2fe812cda
commit c9975d60a5
13 changed files with 1549 additions and 0 deletions

View File

@@ -0,0 +1,52 @@
-- Entity read + mutation queries (API paths). Aliased `e` throughout to
-- avoid ambiguity with joined tables.
-- name: GetEntityByID :one
SELECT e.* FROM entities e WHERE e.id = $1;
-- name: GetEntityBySlug :one
SELECT e.* FROM entities e WHERE e.slug = $1;
-- name: ListEntities :many
WITH RECURSIVE tt AS (
SELECT name FROM entity_types WHERE sqlc.narg('type')::text IS NULL OR name = sqlc.narg('type')
UNION
SELECT et.name FROM entity_types et JOIN tt ON et.parent_type = tt.name
WHERE sqlc.narg('type')::text IS NOT NULL
)
SELECT e.* FROM entities e
JOIN entity_types et ON et.name = e.type
WHERE e.type IN (SELECT name FROM tt)
AND (sqlc.narg('state')::text IS NULL OR e.state = sqlc.narg('state'))
AND (sqlc.narg('domain')::text IS NULL OR et.domain = sqlc.narg('domain'))
AND (sqlc.narg('layer')::text IS NULL OR et.layer = sqlc.narg('layer'))
AND (sqlc.narg('q')::text IS NULL
OR e.slug ILIKE '%'||sqlc.narg('q')||'%'
OR e.name ILIKE '%'||sqlc.narg('q')||'%')
AND (sqlc.narg('cursor')::text IS NULL OR e.slug > sqlc.narg('cursor'))
ORDER BY e.slug
LIMIT sqlc.arg('lim');
-- name: ListEntitiesCapped :many
SELECT e.* FROM entities e ORDER BY e.slug LIMIT $1;
-- name: InsertEntity :one
INSERT INTO entities (id, slug, type, name, state, attributes)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *;
-- name: UpdateEntity :one
UPDATE entities SET
name = COALESCE(sqlc.narg('name'), name),
state = COALESCE(sqlc.narg('state'), state),
attributes = COALESCE(sqlc.narg('attributes'), attributes),
maintenance_until = CASE WHEN sqlc.arg('set_maintenance')::bool
THEN sqlc.narg('maintenance_until') ELSE maintenance_until END,
version = version + 1,
updated_at = now()
WHERE id = sqlc.arg('id') AND version = sqlc.arg('version')
RETURNING *;
-- blast_radius(): the recursive-CTE traversal function's TABLE return type
-- is opaque to sqlc's analyzer — that one query stays hand-written pgx in
-- internal/httpapi (see impl.go).

View File

@@ -0,0 +1,13 @@
-- name: ListEntityTypes :many
SELECT * FROM entity_types ORDER BY name;
-- name: ListRelationshipTypes :many
SELECT * FROM relationship_types ORDER BY name;
-- name: ListLifecycleDefs :many
SELECT * FROM lifecycle_defs ORDER BY id;
-- name: GetLifecycleForType :one
SELECT ld.* FROM lifecycle_defs ld
JOIN entity_types et ON et.lifecycle_id = ld.id
WHERE et.name = $1;

View File

@@ -0,0 +1,56 @@
-- name: ListSignals :many
SELECT sig.entity_id, se.slug, sig.kind, sig.severity, sig.state,
te.slug AS target_slug, sig.check_id, sig.evidence, sig.likely_cause,
sig.occurrence_count, sig.flap_count, sig.hold_down_until,
sig.mute_until, sig.first_seen_at, sig.last_seen_at
FROM signals sig
JOIN entities se ON se.id = sig.entity_id
LEFT JOIN entities te ON te.id = sig.target_entity_id
WHERE (sqlc.narg('state')::text IS NULL OR sig.state = sqlc.narg('state'))
AND (sqlc.narg('severity')::text IS NULL OR sig.severity = sqlc.narg('severity'))
AND (sqlc.narg('target')::text IS NULL OR te.slug = sqlc.narg('target'))
AND (sqlc.narg('kind')::text IS NULL OR sig.kind = sqlc.narg('kind'))
AND (sqlc.narg('cursor')::text IS NULL OR se.slug > sqlc.narg('cursor'))
ORDER BY se.slug
LIMIT sqlc.arg('lim');
-- name: ListEntityStatus :many
SELECT e.slug, e.type, st.health, st.last_check_at
FROM entity_status st JOIN entities e ON e.id = st.entity_id
ORDER BY e.slug;
-- name: GetIdempotentResponse :one
SELECT response_code, response_body, request_hash FROM idempotency_keys
WHERE actor = $1 AND key = $2;
-- name: PutIdempotentResponse :exec
INSERT INTO idempotency_keys (actor, key, request_hash, response_code, response_body)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (actor, key) DO NOTHING;
-- name: InsertAuditEntry :exec
INSERT INTO audit_log (actor_type, actor_id, action, entity_id, method, path,
status_code, detail, source_ip, correlation_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10);
-- name: InsertEvent :one
INSERT INTO events (type, entity_id, severity, source, data, correlation_id)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, ts;
-- name: ListEvents :many
SELECT id, ts, type, entity_id, severity, source, data, correlation_id
FROM events
WHERE (sqlc.narg('type')::text IS NULL OR type = sqlc.narg('type'))
AND (sqlc.narg('entity_id')::uuid IS NULL OR entity_id = sqlc.narg('entity_id'))
AND (sqlc.narg('severity')::text IS NULL OR severity = sqlc.narg('severity'))
AND (sqlc.narg('correlation_id')::text IS NULL OR correlation_id = sqlc.narg('correlation_id'))
AND (sqlc.narg('from_ts')::timestamptz IS NULL OR ts >= sqlc.narg('from_ts'))
AND (sqlc.narg('to_ts')::timestamptz IS NULL OR ts <= sqlc.narg('to_ts'))
AND (sqlc.narg('before_id')::bigint IS NULL OR id < sqlc.narg('before_id'))
ORDER BY id DESC
LIMIT sqlc.arg('lim');
-- name: ListEventsAfter :many
SELECT id, ts, type, entity_id, severity, source, data, correlation_id
FROM events WHERE id > $1 ORDER BY id ASC LIMIT $2;

View File

@@ -0,0 +1,33 @@
-- name: ListEntityRelations :many
SELECT se.slug AS source_slug, te.slug AS target_slug, r.type, r.attributes,
r.valid_from, r.valid_to
FROM relationships r
JOIN entities se ON se.id = r.source_id
JOIN entities te ON te.id = r.target_id
WHERE r.valid_to IS NULL
AND ((sqlc.arg('direction')::text IN ('out','both') AND r.source_id = sqlc.arg('id'))
OR (sqlc.arg('direction')::text IN ('in','both') AND r.target_id = sqlc.arg('id')))
AND (sqlc.narg('rel_type')::text IS NULL OR r.type = sqlc.narg('rel_type'))
ORDER BY r.type, se.slug, te.slug;
-- name: ListGraphEdges :many
SELECT se.slug AS source_slug, te.slug AS target_slug, r.type, r.attributes,
r.valid_from, r.valid_to
FROM relationships r
JOIN entities se ON se.id = r.source_id
JOIN entities te ON te.id = r.target_id
WHERE r.valid_to IS NULL
AND r.source_id = ANY(sqlc.arg('ids')::uuid[])
AND r.target_id = ANY(sqlc.arg('ids')::uuid[])
AND (sqlc.narg('rel_types')::text[] IS NULL OR r.type = ANY(sqlc.narg('rel_types')::text[]))
ORDER BY r.type, se.slug, te.slug;
-- name: UpsertCurrentRelationship :exec
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from, valid_to)
VALUES ($1, $2, $3, $4, now(), NULL)
ON CONFLICT (source_id, target_id, type) WHERE valid_to IS NULL
DO UPDATE SET attributes = EXCLUDED.attributes;
-- name: EndCurrentRelationship :execrows
UPDATE relationships SET valid_to = now()
WHERE source_id = $1 AND target_id = $2 AND type = $3 AND valid_to IS NULL;