- 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).
109 lines
3.3 KiB
PL/PgSQL
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();
|