Files
projax/db/migrations/0002_path_trigger.sql
mAi b8d3418876 feat(db): projax schema, path trigger, seed areas
- 0001_init.sql: projax.items + projax.item_links tables with indices,
  partial-unique root slug, updated_at trigger, schema grants to the
  application role.
- 0002_path_trigger.sql: BEFORE-write trigger maintains items.path via
  recursive parent walk; rejects cycles and structural-rule violations
  (areas at root, projects not at root). AFTER trigger rewrites
  descendant paths on slug rename or re-parent.
- 0003_seed_areas.sql: dev, sports, home, work, health, finances, social.
- db/migrate.go: embed.FS-backed sequential runner.
- db/migrate_test.go: integration suite covering idempotency, nest,
  rename propagation, re-parent propagation, cycle rejection, and
  structural rules. Skips when no DB env var is set.

Also ignores .m/events.log and .m/locks (per-worker scratch).
2026-05-15 13:16:24 +02:00

109 lines
3.3 KiB
PL/PgSQL

-- 0002_path_trigger.sql
-- Maintain projax.items.path as dot-joined slug walk from root.
-- Enforce: areas at root only, projects not at root, no cycles.
-- See docs/design.md §3.1
create or replace function projax.compute_item_path(p_parent_id uuid, p_slug text, p_self_id uuid)
returns text
language plpgsql
stable
as $$
declare
parts text[] := array[p_slug];
cur_id uuid := p_parent_id;
cur_slug text;
cur_parent uuid;
hops int := 0;
begin
while cur_id is not null loop
hops := hops + 1;
if hops > 64 then
raise exception 'projax.items: path depth exceeds 64 (cycle or pathological tree?)';
end if;
if p_self_id is not null and cur_id = p_self_id then
raise exception 'projax.items: cycle detected (item % is ancestor of itself)', p_self_id
using errcode = 'check_violation';
end if;
select slug, parent_id into cur_slug, cur_parent
from projax.items
where id = cur_id;
if cur_slug is null then
raise exception 'projax.items: parent % not found', cur_id;
end if;
parts := array_prepend(cur_slug, parts);
cur_id := cur_parent;
end loop;
return array_to_string(parts, '.');
end;
$$;
create or replace function projax.items_before_write()
returns trigger
language plpgsql
as $$
begin
-- Structural rules
if 'area' = any(new.kind) and new.parent_id is not null then
raise exception 'projax.items: area must have parent_id = NULL (got %)', new.parent_id
using errcode = 'check_violation';
end if;
if 'project' = any(new.kind) and new.parent_id is null then
raise exception 'projax.items: project must have a non-null parent_id'
using errcode = 'check_violation';
end if;
-- Cycle check on UPDATE: a node cannot be its own ancestor
if tg_op = 'UPDATE' and new.parent_id is not null and new.parent_id = new.id then
raise exception 'projax.items: parent_id cannot equal id'
using errcode = 'check_violation';
end if;
-- Compute path
new.path := projax.compute_item_path(new.parent_id, new.slug,
case when tg_op = 'UPDATE' then new.id else null end);
return new;
end;
$$;
drop trigger if exists items_before_write on projax.items;
create trigger items_before_write
before insert or update of slug, parent_id, kind on projax.items
for each row execute function projax.items_before_write();
-- After update of slug or parent_id: recompute paths of all descendants.
create or replace function projax.items_after_reparent()
returns trigger
language plpgsql
as $$
begin
if (tg_op = 'UPDATE') and (old.slug is distinct from new.slug or old.parent_id is distinct from new.parent_id) then
-- Use recursive CTE to refresh descendant paths
with recursive descendants as (
select id, parent_id, slug
from projax.items
where parent_id = new.id
union all
select i.id, i.parent_id, i.slug
from projax.items i
join descendants d on i.parent_id = d.id
)
update projax.items i
set path = projax.compute_item_path(i.parent_id, i.slug, i.id)
from descendants d
where i.id = d.id;
end if;
return null;
end;
$$;
drop trigger if exists items_after_reparent on projax.items;
create trigger items_after_reparent
after update of slug, parent_id on projax.items
for each row execute function projax.items_after_reparent();