-- pgmnemo upgrade: 0.1.3 → 0.1.4 -- Bundles three feature PRs: -- #3 State machine — lifecycle state + allowed-transition table + transition_lesson() -- #4 Provenance FKs — source_run_id / source_task_id columns + partial indexes -- #5 TTL — expires_at column + evict_expired_lessons() helper -- Also fixes: version() now reads extversion from pg_catalog dynamically (closes #1) -- SPDX-License-Identifier: Apache-2.0 -- ───────────────────────────────────────────────────────────────── -- Fix #1: version() dynamic pg_catalog lookup -- ───────────────────────────────────────────────────────────────── CREATE OR REPLACE FUNCTION pgmnemo.version() RETURNS TEXT LANGUAGE SQL STABLE PARALLEL SAFE AS $$ SELECT extversion FROM pg_extension WHERE extname = 'pgmnemo'; $$; COMMENT ON FUNCTION pgmnemo.version() IS 'Returns the currently-installed pgmnemo version by querying pg_catalog.pg_extension — always accurate after ALTER EXTENSION UPDATE.'; -- ───────────────────────────────────────────────────────────────── -- Feature #3: State machine for agent_lesson -- ───────────────────────────────────────────────────────────────── ALTER TABLE pgmnemo.agent_lesson ADD COLUMN IF NOT EXISTS state TEXT NOT NULL DEFAULT 'draft' CHECK (state IN ('draft','candidate','validated','canonical','deprecated','superseded','archived','rejected','conflicted')); ALTER TABLE pgmnemo.agent_lesson ADD COLUMN IF NOT EXISTS state_changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); CREATE TABLE IF NOT EXISTS pgmnemo.agent_lesson_state_transition ( from_state TEXT NOT NULL, to_state TEXT NOT NULL, PRIMARY KEY (from_state, to_state) ); INSERT INTO pgmnemo.agent_lesson_state_transition (from_state, to_state) VALUES ('draft', 'candidate'), ('draft', 'rejected'), ('candidate', 'validated'), ('candidate', 'rejected'), ('candidate', 'conflicted'), ('validated', 'canonical'), ('validated', 'rejected'), ('canonical', 'deprecated'), ('canonical', 'superseded'), ('canonical', 'archived'), ('canonical', 'conflicted'), ('deprecated', 'archived'), ('deprecated', 'canonical'), ('superseded', 'archived'), ('conflicted', 'canonical'), ('conflicted', 'rejected'), ('conflicted', 'archived') ON CONFLICT DO NOTHING; CREATE OR REPLACE FUNCTION pgmnemo.transition_lesson(lesson_id BIGINT, new_state TEXT) RETURNS pgmnemo.agent_lesson LANGUAGE plpgsql AS $$ DECLARE _lesson pgmnemo.agent_lesson; BEGIN SELECT * INTO _lesson FROM pgmnemo.agent_lesson WHERE id = lesson_id FOR UPDATE; IF NOT FOUND THEN RAISE EXCEPTION 'lesson % not found', lesson_id; END IF; IF NOT EXISTS ( SELECT 1 FROM pgmnemo.agent_lesson_state_transition WHERE from_state = _lesson.state AND to_state = new_state ) THEN RAISE EXCEPTION 'invalid state transition: % → %', _lesson.state, new_state; END IF; UPDATE pgmnemo.agent_lesson SET state = new_state, state_changed_at = NOW() WHERE id = lesson_id RETURNING * INTO _lesson; RETURN _lesson; END; $$; COMMENT ON TABLE pgmnemo.agent_lesson_state_transition IS 'Allowed state transitions for pgmnemo.agent_lesson.state lifecycle.'; COMMENT ON FUNCTION pgmnemo.transition_lesson(BIGINT, TEXT) IS 'Advance a lesson to new_state; raises if the transition is not permitted.'; -- ───────────────────────────────────────────────────────────────── -- Feature #4: Provenance FKs — source_run_id / source_task_id -- ───────────────────────────────────────────────────────────────── ALTER TABLE pgmnemo.agent_lesson ADD COLUMN IF NOT EXISTS source_run_id BIGINT NULL; ALTER TABLE pgmnemo.agent_lesson ADD COLUMN IF NOT EXISTS source_task_id BIGINT NULL; CREATE INDEX IF NOT EXISTS ix_pgmnemo_lesson_source_run ON pgmnemo.agent_lesson (source_run_id) WHERE source_run_id IS NOT NULL; CREATE INDEX IF NOT EXISTS ix_pgmnemo_lesson_source_task ON pgmnemo.agent_lesson (source_task_id) WHERE source_task_id IS NOT NULL; COMMENT ON COLUMN pgmnemo.agent_lesson.source_run_id IS 'External-system FK; not REFERENCES-constrained (allows extension to be portable across host schemas).'; COMMENT ON COLUMN pgmnemo.agent_lesson.source_task_id IS 'External-system FK; not REFERENCES-constrained (allows extension to be portable across host schemas).'; -- ───────────────────────────────────────────────────────────────── -- Feature #5: TTL — expires_at + evict_expired_lessons() -- ───────────────────────────────────────────────────────────────── ALTER TABLE pgmnemo.agent_lesson ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ NULL; COMMENT ON COLUMN pgmnemo.agent_lesson.expires_at IS 'Optional hard expiry. NULL = never expires. Rows with expires_at < NOW() are considered stale and are removed by pgmnemo.evict_expired_lessons().'; CREATE INDEX IF NOT EXISTS ix_pgmnemo_agent_lesson_expires ON pgmnemo.agent_lesson (expires_at) WHERE expires_at IS NOT NULL; CREATE OR REPLACE FUNCTION pgmnemo.evict_expired_lessons() RETURNS INT LANGUAGE plpgsql AS $$ DECLARE evicted INT; BEGIN WITH deleted AS ( DELETE FROM pgmnemo.agent_lesson WHERE expires_at IS NOT NULL AND expires_at < NOW() RETURNING 1 ) SELECT COUNT(*) INTO evicted FROM deleted; RETURN COALESCE(evicted, 0); END; $$; COMMENT ON FUNCTION pgmnemo.evict_expired_lessons() IS 'Deletes all lessons whose expires_at is non-NULL and in the past. Returns the number of rows removed. Safe to call frequently; the partial index keeps the scan cheap.';