feat: Phase 2 — ports package, secrets port move, postgres adapter move
Problem: the hexagon's Phase 2 (plans/2026-08-15-hexagonal-architecture.md) must give the use-cases-to-be their contract surface: driven-port interfaces, test fakes, the secrets interface moved into core, and the postgres package inside the adapters tree — before the first vertical slice (Phase 3) can wire a composition root. Change: - internal/core/ports: full driven-port catalog per plan §3.3 — repositories as transaction-scoped aggregates whose inputs carry derived checks, audit, and events (§3.6), plus CommandExecutor, TargetResolver, Checker, Secrets, EventPublisher, Provisioner. Port-local payload types (Event, AuditEntry, CheckDef, KnowledgeEntry, ExecResult) keep signatures off infrastructure; TypeTree aliases internal/ontology (pure over domain) until checkdefaults is absorbed. ReadModels intentionally not declared yet — it materializes with the Phase 3 slice and grows as report handlers rewire. - secrets.Backend is now an alias of ports.Secrets; implementations (Infisical, SOPS, Manager) unchanged. mcp's local secretBackend subset is deleted; tool constructors take ports.Secrets. - internal/db → internal/adapters/postgres (mechanical import rewrite; package identifier stays db until the Phase 3 repository split). sqlc.yaml, Makefile, golangci exclusions, and docs follow the move; make generate-check verified. - internal/adapters/ssh: Executor implements ports.CommandExecutor over the actuator dial pool + RunStreaming (10-min default timeout carried over from the httpapi path). - internal/adapters/remote: Resolver implements ports.TargetResolver delegating to internal/remote (still pool-based; drops onto ports.EntityRepository when repositories land in Phase 3 — documented transitional import). - internal/core/ports/portstest: importable fakes — in-memory EntityRepo (with check-then-act SetState, side-effect recording), RecordingExecutor, FakeChecker, SpyPublisher; port-satisfaction guards; tests. Risk: ports are declared ahead of implementations — signatures firm up per phase as slices land (documented in the package doc); the remote→postgres transitional import is explicit and dissolves in Phase 3. Verification: go vet, make test (race, 19 packages), generate-check, golangci on core+adapters — 0 issues; full-repo baseline down 365→344.
This commit is contained in:
79
internal/adapters/postgres/queries/entities.sql
Normal file
79
internal/adapters/postgres/queries/entities.sql
Normal file
@@ -0,0 +1,79 @@
|
||||
-- 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: 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 *;
|
||||
|
||||
-- name: MergeEntityAttributes :execrows
|
||||
-- Shallow-merge a JSON patch into an entity's attributes (the
|
||||
-- update_entity_attributes MCP/HTTP surface). Replaces the raw
|
||||
-- `attributes = attributes || $2::jsonb` used in entity_tools.go.
|
||||
UPDATE entities SET
|
||||
attributes = attributes || sqlc.arg('patch')::jsonb,
|
||||
updated_at = now()
|
||||
WHERE slug = sqlc.arg('slug');
|
||||
|
||||
-- name: SetEntityState :execrows
|
||||
-- Set an entity's lifecycle state by id (the set_entity_state surface, run
|
||||
-- after db.ValidateTransition). Replaces the raw
|
||||
-- `UPDATE entities SET state = $2 ... WHERE id = $1`.
|
||||
UPDATE entities SET
|
||||
state = sqlc.arg('state'),
|
||||
updated_at = now()
|
||||
WHERE id = sqlc.arg('id');
|
||||
|
||||
-- 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 entities.go GetBlastRadius).
|
||||
--
|
||||
-- Deliberate raw-SQL exceptions (plan E2): the httpapi entity *read* handlers
|
||||
-- (ListEntities/GetEntity/GetGraph/queryEntities) project a fixed
|
||||
-- `entityCols` column set (entities.* + a LEFT JOIN to entity_status for
|
||||
-- health/last_check_at) and scan it positionally into the oapi-generated
|
||||
-- gen.Entity shape. sqlc generates its own row struct per query and cannot
|
||||
-- emit gen.Entity, so migrating those reads would add a per-call field-by-
|
||||
-- field mapping with no compile-time gain and real column-order risk. They
|
||||
-- stay hand-written pgx, like blast_radius and the seed/export bulk paths
|
||||
-- noted in sqlc.yaml. The mutation/relationship surface (MergeEntityAttributes,
|
||||
-- SetEntityState, InsertRelationshipIfAbsent, EndCurrentRelationship) IS
|
||||
-- migrated and is what the entity CRUD tools now call.
|
||||
13
internal/adapters/postgres/queries/ontology.sql
Normal file
13
internal/adapters/postgres/queries/ontology.sql
Normal 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;
|
||||
269
internal/adapters/postgres/queries/operations.sql
Normal file
269
internal/adapters/postgres/queries/operations.sql
Normal file
@@ -0,0 +1,269 @@
|
||||
-- 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: 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, session_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11);
|
||||
|
||||
-- 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;
|
||||
|
||||
-- =====================================================================
|
||||
-- Phase 3 queries
|
||||
-- =====================================================================
|
||||
|
||||
-- name: ListEnabledCheckDefs :many
|
||||
-- Enabled AND due. interval_s used to be selected but never filtered on, so
|
||||
-- every check ran on every 30s pass and the declared intervals meant nothing.
|
||||
-- NULL last_run_at = never run = due now.
|
||||
SELECT cd.entity_id, cd.target_id, cd.target_type, cd.kind, cd.config,
|
||||
cd.interval_s, cd.timeout_s, cd.zone, cd.enabled, cd.updated_at,
|
||||
e.slug AS entity_slug
|
||||
FROM check_defs cd
|
||||
JOIN entities e ON e.id = cd.entity_id
|
||||
LEFT JOIN entities tgt ON tgt.id = cd.target_id
|
||||
WHERE cd.enabled = true
|
||||
AND (tgt.id IS NULL OR tgt.state IS NULL OR tgt.state NOT IN ('deprecated', 'destroyed'))
|
||||
AND (cd.last_run_at IS NULL
|
||||
OR cd.last_run_at <= now() - make_interval(secs => cd.interval_s));
|
||||
|
||||
-- name: MarkCheckRun :exec
|
||||
UPDATE check_defs SET last_run_at = now(), last_health = $2 WHERE entity_id = $1;
|
||||
|
||||
-- name: WorstHealthForTarget :one
|
||||
-- An entity is as healthy as its unhealthiest check. Checks that have not run
|
||||
-- yet (last_health IS NULL) are ignored rather than counted as unknown, so a
|
||||
-- newly added check does not drag a known-good entity down before it has
|
||||
-- produced a verdict.
|
||||
SELECT COALESCE(
|
||||
(SELECT last_health FROM check_defs
|
||||
WHERE enabled AND target_id = $1 AND last_health IS NOT NULL
|
||||
ORDER BY CASE last_health
|
||||
WHEN 'down' THEN 0 WHEN 'degraded' THEN 1 WHEN 'stale' THEN 2
|
||||
WHEN 'unknown' THEN 3 ELSE 4 END
|
||||
LIMIT 1),
|
||||
'unknown')::text AS health;
|
||||
|
||||
-- name: GetCheckDef :one
|
||||
SELECT * FROM check_defs WHERE entity_id = $1;
|
||||
|
||||
-- name: InsertCheckDef :exec
|
||||
INSERT INTO check_defs (entity_id, target_id, target_type, kind, config, interval_s, timeout_s, zone, enabled)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9);
|
||||
|
||||
-- name: UpdateCheckDef :exec
|
||||
UPDATE check_defs SET kind = $2, config = $3, interval_s = $4, timeout_s = $5,
|
||||
target_id = $6, target_type = $7, zone = $8, enabled = $9, updated_at = now()
|
||||
WHERE entity_id = $1;
|
||||
|
||||
-- name: UpsertSignal :one
|
||||
INSERT INTO signals (entity_id, kind, severity, target_entity_id, check_id, evidence, likely_cause, state)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, 'raised')
|
||||
ON CONFLICT (target_entity_id, kind) WHERE state NOT IN ('resolved','failed')
|
||||
DO UPDATE SET occurrence_count = signals.occurrence_count + 1,
|
||||
last_seen_at = now(),
|
||||
evidence = EXCLUDED.evidence,
|
||||
updated_at = now()
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetOpenSignalsForAutoAct :many
|
||||
-- Signals with auto-act classifications that haven't been executed yet
|
||||
SELECT s.*, c.entity_id AS classification_id, c.action, c.risk_class, c.route,
|
||||
c.blast_radius, c.correlation_id, c.reasoning
|
||||
FROM classifications c
|
||||
JOIN signals s ON s.entity_id = c.signal_entity_id
|
||||
LEFT JOIN executions e ON e.classification_id = c.entity_id
|
||||
WHERE c.route = 'auto-act'
|
||||
AND e.entity_id IS NULL
|
||||
AND (s.hold_down_until IS NULL OR s.hold_down_until < now())
|
||||
AND (s.mute_until IS NULL OR s.mute_until < now())
|
||||
ORDER BY s.last_seen_at ASC
|
||||
LIMIT $1;
|
||||
|
||||
-- name: ListClassifications :many
|
||||
SELECT c.entity_id, c.signal_entity_id, c.target_entity_id, c.action,
|
||||
c.recommended_action, c.risk_class, c.route, c.blast_radius,
|
||||
c.pattern_confidence, c.skill_id, c.autonomy_check, c.reasoning,
|
||||
c.correlation_id, c.created_at,
|
||||
e.slug AS target_slug
|
||||
FROM classifications c
|
||||
JOIN entities e ON e.id = c.target_entity_id
|
||||
WHERE (sqlc.narg('route')::text IS NULL OR c.route = sqlc.narg('route'))
|
||||
AND (sqlc.narg('cursor')::text IS NULL OR e.slug > sqlc.narg('cursor'))
|
||||
ORDER BY e.slug
|
||||
LIMIT sqlc.arg('lim');
|
||||
|
||||
-- name: InsertExecution :exec
|
||||
INSERT INTO executions (entity_id, classification_id, signal_entity_id,
|
||||
target_entity_id, action, risk_class, approval_id, agent_id,
|
||||
skill_id, skill_version, status, correlation_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'proposed', $11);
|
||||
|
||||
-- name: UpdateExecutionStatus :exec
|
||||
UPDATE executions SET status = $2, result = $3, duration_ms = $4,
|
||||
verified = $5, started_at = COALESCE(started_at, now()),
|
||||
completed_at = CASE WHEN $2 IN ('completed','failed','cancelled') THEN now() ELSE completed_at END
|
||||
WHERE entity_id = $1;
|
||||
|
||||
-- name: GetExecution :one
|
||||
SELECT * FROM executions WHERE entity_id = $1;
|
||||
|
||||
-- name: ListExecutions :many
|
||||
SELECT e.entity_id, e.classification_id, e.signal_entity_id, e.target_entity_id,
|
||||
e.action, e.risk_class, e.approval_id, e.agent_id,
|
||||
e.skill_id, e.skill_version, e.status, e.result, e.duration_ms,
|
||||
e.verified, e.correlation_id, e.started_at, e.completed_at, e.created_at,
|
||||
te.slug AS target_slug
|
||||
FROM executions e
|
||||
JOIN entities te ON te.id = e.target_entity_id
|
||||
WHERE (sqlc.narg('status')::text IS NULL OR e.status = sqlc.narg('status'))
|
||||
AND (sqlc.narg('cursor')::text IS NULL OR te.slug > sqlc.narg('cursor'))
|
||||
ORDER BY te.slug
|
||||
LIMIT sqlc.arg('lim');
|
||||
|
||||
-- name: GetFeedbackAfterWatermark :many
|
||||
SELECT f.entity_id, f.execution_id, f.outcome, f.observation, f.lesson,
|
||||
f.unexpected_side_effects, f.tags, f.created_at,
|
||||
e.action, e.risk_class, e.target_entity_id,
|
||||
et.name AS applies_type
|
||||
FROM feedback f
|
||||
JOIN executions e ON e.entity_id = f.execution_id
|
||||
JOIN entities ent ON ent.id = e.target_entity_id
|
||||
JOIN entity_types et ON et.name = ent.type
|
||||
WHERE f.created_at > $1
|
||||
ORDER BY f.created_at ASC;
|
||||
|
||||
-- name: UpsertPattern :exec
|
||||
INSERT INTO patterns (entity_id, applies_type, action, pattern, confidence,
|
||||
evidence_count, success_count, failure_count, status, version)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'hypothesized', 1)
|
||||
ON CONFLICT (applies_type, action)
|
||||
DO UPDATE SET evidence_count = patterns.evidence_count + EXCLUDED.evidence_count,
|
||||
success_count = patterns.success_count + EXCLUDED.success_count,
|
||||
failure_count = patterns.failure_count + EXCLUDED.failure_count,
|
||||
updated_at = now();
|
||||
|
||||
-- name: GetPattern :one
|
||||
SELECT * FROM patterns WHERE applies_type = $1 AND action = $2;
|
||||
|
||||
-- name: ListPatterns :many
|
||||
SELECT p.* FROM patterns p
|
||||
WHERE (sqlc.narg('status')::text IS NULL OR p.status = sqlc.narg('status'))
|
||||
ORDER BY p.applies_type, p.action;
|
||||
|
||||
-- name: UpdatePatternStatus :exec
|
||||
UPDATE patterns SET status = $2, version = version + 1,
|
||||
last_validated_at = CASE WHEN $2 = 'validated' THEN now() ELSE last_validated_at END
|
||||
WHERE entity_id = $1;
|
||||
|
||||
-- name: UpdatePatternQuarantine :exec
|
||||
UPDATE patterns SET quarantined = $2 WHERE entity_id = $1;
|
||||
|
||||
-- name: ListSkills :many
|
||||
SELECT * FROM skills
|
||||
WHERE (sqlc.narg('status')::text IS NULL OR status = sqlc.narg('status'))
|
||||
ORDER BY name, version DESC;
|
||||
|
||||
-- name: UpdateSkillStatus :exec
|
||||
UPDATE skills SET status = $2, last_used_at = now() WHERE entity_id = $1 AND version = $2;
|
||||
|
||||
-- name: InsertApproval :exec
|
||||
INSERT INTO approvals (entity_id, subject_entity_id, action, risk_class, kind,
|
||||
payload, status, token_hash, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'pending', $7, $8);
|
||||
|
||||
-- name: ListApprovals :many
|
||||
SELECT a.*, e.slug AS subject_slug
|
||||
FROM approvals a
|
||||
JOIN entities e ON e.id = a.subject_entity_id
|
||||
WHERE (sqlc.narg('status')::text IS NULL OR a.status = sqlc.narg('status'))
|
||||
AND (sqlc.narg('cursor')::text IS NULL OR e.slug > sqlc.narg('cursor'))
|
||||
ORDER BY e.slug
|
||||
LIMIT sqlc.arg('lim');
|
||||
|
||||
-- name: GetApprovalByID :one
|
||||
SELECT * FROM approvals WHERE entity_id = $1;
|
||||
|
||||
-- name: UpdateApprovalStatus :exec
|
||||
UPDATE approvals SET status = $2, decided_at = now(), decided_by = $3
|
||||
WHERE entity_id = $1 AND status = 'pending';
|
||||
|
||||
-- name: GetAutonomySetting :one
|
||||
SELECT value FROM autonomy_settings WHERE key = $1;
|
||||
|
||||
-- name: ListRiskClasses :many
|
||||
SELECT * FROM risk_classes ORDER BY name;
|
||||
|
||||
-- name: ListApprovalRules :many
|
||||
SELECT * FROM approval_rules ORDER BY entity_type, action;
|
||||
|
||||
-- name: InsertMetricSample :exec
|
||||
INSERT INTO metric_samples (entity_id, metric, value, tags, ts)
|
||||
VALUES ($1, $2, $3, $4, now());
|
||||
|
||||
-- name: QueryMetrics :many
|
||||
SELECT time_bucket(sqlc.arg('bucket_interval')::interval, ts) AS bucket,
|
||||
entity_id, metric,
|
||||
ROUND(avg(value)::numeric, 2) AS avg_val,
|
||||
ROUND(min(value)::numeric, 2) AS min_val,
|
||||
ROUND(max(value)::numeric, 2) AS max_val
|
||||
FROM metric_samples
|
||||
WHERE entity_id = $1
|
||||
AND metric = $2
|
||||
AND ts > $3
|
||||
GROUP BY bucket, entity_id, metric
|
||||
ORDER BY bucket DESC;
|
||||
|
||||
-- name: UpsertEntityStatus :exec
|
||||
INSERT INTO entity_status (entity_id, health, last_check_at, details)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (entity_id)
|
||||
DO UPDATE SET health = EXCLUDED.health,
|
||||
last_check_at = EXCLUDED.last_check_at,
|
||||
details = EXCLUDED.details,
|
||||
updated_at = now();
|
||||
|
||||
-- name: GetEntityStatus :one
|
||||
SELECT * FROM entity_status WHERE entity_id = $1;
|
||||
42
internal/adapters/postgres/queries/relationships.sql
Normal file
42
internal/adapters/postgres/queries/relationships.sql
Normal file
@@ -0,0 +1,42 @@
|
||||
-- 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: EndCurrentRelationship :execrows
|
||||
UPDATE relationships SET valid_to = now()
|
||||
WHERE source_id = $1 AND target_id = $2 AND type = $3 AND valid_to IS NULL;
|
||||
|
||||
-- name: InsertRelationshipIfAbsent :execrows
|
||||
-- Idempotent relationship insert (the create_relationship surface): no-op if
|
||||
-- an active edge of the same source/target/type already exists. Replaces the
|
||||
-- raw INSERT...WHERE NOT EXISTS used in entity_tools.go.
|
||||
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
|
||||
SELECT sqlc.arg('source_id'), sqlc.arg('target_id'), sqlc.arg('type'),
|
||||
sqlc.arg('attributes')::jsonb, now()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM relationships
|
||||
WHERE source_id = sqlc.arg('source_id')
|
||||
AND target_id = sqlc.arg('target_id')
|
||||
AND type = sqlc.arg('type')
|
||||
AND valid_to IS NULL
|
||||
);
|
||||
Reference in New Issue
Block a user