packages feed

kioku-migrations-0.1.0.0: migrations/0008-kioku-schema-hardening.sql

-- codd: in-txn

-- Migration: kioku-schema-hardening
-- Created: 2026-07-11-17-35-11 UTC
--
-- Four defects in the read-model schema, fixed together because they are all
-- constraint/index DDL on the same five scope-carrying tables:
--
--   1. The recursive supersession CTE (selectSupersessionChainStmt) joins on
--      `supersedes` and `superseded_by`, neither of which had an index, so every
--      recursion level was a sequential scan.
--   2. The session list/range/focus queries sort whole namespaces with no
--      supporting index.
--   3. The UNIQUE constraints on kioku_scenes and kioku_personas enforce nothing
--      for global-scope rows, because SQL treats NULLs as distinct: two global
--      personas for one namespace both satisfy UNIQUE (namespace, scope_kind,
--      scope_ref). PostgreSQL 15+ NULLS NOT DISTINCT closes that hole.
--   4. Nothing prevented a half-populated scope (scope_kind set, scope_ref NULL).
--      Such a row reads back as global (see Kioku.Api.Scope.scopeFromColumns) yet
--      matches no exact-scope query.
--
-- Everything below is idempotent: the repairs match nothing on a second pass, the
-- constraints are dropped before being added, and the indexes use IF NOT EXISTS.
SET search_path TO kiroku, pg_catalog;

-- Repair 1: normalise half-populated scope pairs to fully NULL, so the CHECK below
-- can be added to a non-empty database. This is not a behaviour change: such rows
-- already read back as global scopes.
UPDATE kioku_memories SET scope_kind = NULL, scope_ref = NULL
 WHERE (scope_kind IS NULL) <> (scope_ref IS NULL);
UPDATE kioku_sessions SET scope_kind = NULL, scope_ref = NULL
 WHERE (scope_kind IS NULL) <> (scope_ref IS NULL);
UPDATE kioku_scenes SET scope_kind = NULL, scope_ref = NULL
 WHERE (scope_kind IS NULL) <> (scope_ref IS NULL);
UPDATE kioku_personas SET scope_kind = NULL, scope_ref = NULL
 WHERE (scope_kind IS NULL) <> (scope_ref IS NULL);
UPDATE kioku_consolidation_decisions SET scope_kind = NULL, scope_ref = NULL
 WHERE (scope_kind IS NULL) <> (scope_ref IS NULL);

-- Repair 2: drop duplicate global-scope scenes/personas, keeping the newest row per
-- logical scope, so the NULLS NOT DISTINCT constraints below can be added. Only
-- NULL-scoped duplicates can exist -- the old constraints already blocked the rest.
DELETE FROM kioku_scenes doomed
 USING kioku_scenes keeper
 WHERE doomed.namespace = keeper.namespace
   AND doomed.scope_kind IS NOT DISTINCT FROM keeper.scope_kind
   AND doomed.scope_ref  IS NOT DISTINCT FROM keeper.scope_ref
   AND doomed.scene_key = keeper.scene_key
   AND (doomed.updated_at, doomed.scene_id) < (keeper.updated_at, keeper.scene_id);

DELETE FROM kioku_personas doomed
 USING kioku_personas keeper
 WHERE doomed.namespace = keeper.namespace
   AND doomed.scope_kind IS NOT DISTINCT FROM keeper.scope_kind
   AND doomed.scope_ref  IS NOT DISTINCT FROM keeper.scope_ref
   AND (doomed.updated_at, doomed.persona_id) < (keeper.updated_at, keeper.persona_id);

-- Scope uniqueness that also holds for global scopes. The upserts target the primary
-- keys (ON CONFLICT (scene_id) / (persona_id)), so replacing these constraints does
-- not disturb them.
ALTER TABLE kioku_scenes
  DROP CONSTRAINT IF EXISTS kioku_scenes_namespace_scope_kind_scope_ref_scene_key_key;
ALTER TABLE kioku_scenes
  DROP CONSTRAINT IF EXISTS kioku_scenes_scope_scene_key_unique;
ALTER TABLE kioku_scenes
  ADD CONSTRAINT kioku_scenes_scope_scene_key_unique
  UNIQUE NULLS NOT DISTINCT (namespace, scope_kind, scope_ref, scene_key);

ALTER TABLE kioku_personas
  DROP CONSTRAINT IF EXISTS kioku_personas_namespace_scope_kind_scope_ref_key;
ALTER TABLE kioku_personas
  DROP CONSTRAINT IF EXISTS kioku_personas_scope_unique;
ALTER TABLE kioku_personas
  ADD CONSTRAINT kioku_personas_scope_unique
  UNIQUE NULLS NOT DISTINCT (namespace, scope_kind, scope_ref);

-- A scope is either global (both columns NULL) or an entity scope (both set).
ALTER TABLE kioku_memories DROP CONSTRAINT IF EXISTS kioku_memories_scope_pair_check;
ALTER TABLE kioku_memories ADD CONSTRAINT kioku_memories_scope_pair_check
  CHECK ((scope_kind IS NULL) = (scope_ref IS NULL));

ALTER TABLE kioku_sessions DROP CONSTRAINT IF EXISTS kioku_sessions_scope_pair_check;
ALTER TABLE kioku_sessions ADD CONSTRAINT kioku_sessions_scope_pair_check
  CHECK ((scope_kind IS NULL) = (scope_ref IS NULL));

ALTER TABLE kioku_scenes DROP CONSTRAINT IF EXISTS kioku_scenes_scope_pair_check;
ALTER TABLE kioku_scenes ADD CONSTRAINT kioku_scenes_scope_pair_check
  CHECK ((scope_kind IS NULL) = (scope_ref IS NULL));

ALTER TABLE kioku_personas DROP CONSTRAINT IF EXISTS kioku_personas_scope_pair_check;
ALTER TABLE kioku_personas ADD CONSTRAINT kioku_personas_scope_pair_check
  CHECK ((scope_kind IS NULL) = (scope_ref IS NULL));

ALTER TABLE kioku_consolidation_decisions
  DROP CONSTRAINT IF EXISTS kioku_consolidation_decisions_scope_pair_check;
ALTER TABLE kioku_consolidation_decisions
  ADD CONSTRAINT kioku_consolidation_decisions_scope_pair_check
  CHECK ((scope_kind IS NULL) = (scope_ref IS NULL));

-- Indexes for the recursive supersession chain: its join arms are
-- `m.supersedes = c.memory_id OR m.superseded_by = c.memory_id`, and only the other
-- two arms hit the primary key.
CREATE INDEX IF NOT EXISTS kioku_memories_supersedes_idx
  ON kioku_memories (supersedes) WHERE supersedes IS NOT NULL;
CREATE INDEX IF NOT EXISTS kioku_memories_superseded_by_idx
  ON kioku_memories (superseded_by) WHERE superseded_by IS NOT NULL;

-- Indexes for the session list queries, which order by started_at within a namespace
-- (and, for the focus query, within a focus).
CREATE INDEX IF NOT EXISTS kioku_sessions_namespace_started_idx
  ON kioku_sessions (namespace, started_at DESC);
CREATE INDEX IF NOT EXISTS kioku_sessions_namespace_focus_idx
  ON kioku_sessions (namespace, focus, started_at DESC);

-- Pure write amplification: duplicates the index implied by
-- UNIQUE (session_id, turn_index) on the same table.
DROP INDEX IF EXISTS kioku_turns_session_idx;