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).
This commit is contained in:
mAi
2026-05-15 13:16:24 +02:00
parent 68121c6e51
commit b8d3418876
8 changed files with 529 additions and 0 deletions

2
.m/.gitignore vendored
View File

@@ -2,3 +2,5 @@ workers.json
spawn.lock spawn.lock
session.yaml session.yaml
config.reference.yaml config.reference.yaml
events.log
locks/

46
db/migrate.go Normal file
View File

@@ -0,0 +1,46 @@
package db
import (
"context"
"embed"
"fmt"
"sort"
"strings"
"github.com/jackc/pgx/v5/pgxpool"
)
//go:embed migrations/*.sql
var migrations embed.FS
// EmbeddedMigrations exposes the raw embedded FS for tests.
var EmbeddedMigrations = migrations
// ApplyMigrations runs every embedded migration file in lexicographic order.
// Idempotent because each migration is written as CREATE ... IF NOT EXISTS /
// CREATE OR REPLACE / ON CONFLICT DO NOTHING.
func ApplyMigrations(ctx context.Context, pool *pgxpool.Pool) error {
entries, err := migrations.ReadDir("migrations")
if err != nil {
return fmt.Errorf("read migrations dir: %w", err)
}
names := make([]string, 0, len(entries))
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") {
continue
}
names = append(names, e.Name())
}
sort.Strings(names)
for _, name := range names {
body, err := migrations.ReadFile("migrations/" + name)
if err != nil {
return fmt.Errorf("read %s: %w", name, err)
}
if _, err := pool.Exec(ctx, string(body)); err != nil {
return fmt.Errorf("apply %s: %w", name, err)
}
}
return nil
}

240
db/migrate_test.go Normal file
View File

@@ -0,0 +1,240 @@
package db_test
import (
"context"
"fmt"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/m/projax/db"
)
// connect returns a pool or skips the test if no DB is configured.
// Honours PROJAX_DB_URL first, then SUPABASE_DATABASE_URL.
func connect(t *testing.T) *pgxpool.Pool {
t.Helper()
url := os.Getenv("PROJAX_DB_URL")
if url == "" {
url = os.Getenv("SUPABASE_DATABASE_URL")
}
if url == "" {
t.Skip("no PROJAX_DB_URL / SUPABASE_DATABASE_URL set — skipping integration test")
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
pool, err := pgxpool.New(ctx, url)
if err != nil {
t.Fatalf("pool: %v", err)
}
if err := pool.Ping(ctx); err != nil {
t.Skipf("DB unreachable: %v", err)
}
return pool
}
func TestMigrationsAreIdempotent(t *testing.T) {
pool := connect(t)
defer pool.Close()
ctx := context.Background()
// Apply twice; second run must not fail.
if err := db.ApplyMigrations(ctx, pool); err != nil {
t.Fatalf("first apply: %v", err)
}
if err := db.ApplyMigrations(ctx, pool); err != nil {
t.Fatalf("second apply: %v", err)
}
var n int
if err := pool.QueryRow(ctx, `select count(*) from projax.items where 'area' = any(kind) and parent_id is null`).Scan(&n); err != nil {
t.Fatalf("count areas: %v", err)
}
if n < 7 {
t.Fatalf("expected at least 7 seeded areas, got %d", n)
}
}
func TestPathTriggerNestAndRename(t *testing.T) {
pool := connect(t)
defer pool.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
tx, err := pool.Begin(ctx)
if err != nil {
t.Fatalf("begin: %v", err)
}
defer tx.Rollback(ctx)
// Get the 'home' area id.
var homeID string
if err := tx.QueryRow(ctx, `select id from projax.items where slug='home' and parent_id is null`).Scan(&homeID); err != nil {
t.Fatalf("read home: %v", err)
}
// Insert child project under home.
var parentPath string
if err := tx.QueryRow(ctx,
`insert into projax.items (kind, title, slug, parent_id) values (array['project']::text[], $1, $2, $3) returning path`,
"Spring clean", "spring-clean", homeID,
).Scan(&parentPath); err != nil {
t.Fatalf("insert spring-clean: %v", err)
}
if parentPath != "home.spring-clean" {
t.Fatalf("expected path 'home.spring-clean', got %q", parentPath)
}
// Insert grandchild.
var childPath string
if err := tx.QueryRow(ctx,
`insert into projax.items (kind, title, slug, parent_id)
select array['project']::text[], 'Bathroom', 'bathroom', id from projax.items where path='home.spring-clean'
returning path`).Scan(&childPath); err != nil {
t.Fatalf("insert bathroom: %v", err)
}
if childPath != "home.spring-clean.bathroom" {
t.Fatalf("expected path 'home.spring-clean.bathroom', got %q", childPath)
}
// Rename middle: descendants must be rewritten.
if _, err := tx.Exec(ctx, `update projax.items set slug='big-clean' where path='home.spring-clean'`); err != nil {
t.Fatalf("rename: %v", err)
}
var renamedChild string
if err := tx.QueryRow(ctx, `select path from projax.items where slug='bathroom' and parent_id=(select id from projax.items where slug='big-clean')`).Scan(&renamedChild); err != nil {
t.Fatalf("read child after rename: %v", err)
}
if renamedChild != "home.big-clean.bathroom" {
t.Fatalf("expected child path 'home.big-clean.bathroom', got %q", renamedChild)
}
}
func TestPathTriggerReparent(t *testing.T) {
pool := connect(t)
defer pool.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
tx, err := pool.Begin(ctx)
if err != nil {
t.Fatalf("begin: %v", err)
}
defer tx.Rollback(ctx)
var homeID, devID string
if err := tx.QueryRow(ctx, `select id from projax.items where slug='home' and parent_id is null`).Scan(&homeID); err != nil {
t.Fatalf("home: %v", err)
}
if err := tx.QueryRow(ctx, `select id from projax.items where slug='dev' and parent_id is null`).Scan(&devID); err != nil {
t.Fatalf("dev: %v", err)
}
// Create project under home, then move it to dev.
var pid string
if err := tx.QueryRow(ctx,
`insert into projax.items (kind, title, slug, parent_id) values (array['project']::text[], 'X', 'mover', $1) returning id`, homeID,
).Scan(&pid); err != nil {
t.Fatalf("insert mover: %v", err)
}
// Child of mover.
if _, err := tx.Exec(ctx,
`insert into projax.items (kind, title, slug, parent_id) values (array['project']::text[], 'X.child', 'child', $1)`, pid,
); err != nil {
t.Fatalf("insert child: %v", err)
}
if _, err := tx.Exec(ctx, `update projax.items set parent_id=$1 where id=$2`, devID, pid); err != nil {
t.Fatalf("reparent: %v", err)
}
var p1, p2 string
if err := tx.QueryRow(ctx, `select path from projax.items where id=$1`, pid).Scan(&p1); err != nil {
t.Fatalf("read mover path: %v", err)
}
if p1 != "dev.mover" {
t.Fatalf("mover path = %q, want dev.mover", p1)
}
if err := tx.QueryRow(ctx, `select path from projax.items where parent_id=$1`, pid).Scan(&p2); err != nil {
t.Fatalf("read child path: %v", err)
}
if p2 != "dev.mover.child" {
t.Fatalf("child path = %q, want dev.mover.child", p2)
}
}
func TestStructuralRules(t *testing.T) {
pool := connect(t)
defer pool.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cases := []struct {
name string
sql string
args []any
}{
{
name: "area with parent rejected",
sql: `insert into projax.items (kind, title, slug, parent_id) values (array['area']::text[], 'bad', $1, (select id from projax.items where slug='home' and parent_id is null))`,
args: []any{fmt.Sprintf("bad-area-%d", time.Now().UnixNano())},
},
{
name: "project at root rejected",
sql: `insert into projax.items (kind, title, slug, parent_id) values (array['project']::text[], 'orphan', $1, null)`,
args: []any{fmt.Sprintf("orphan-%d", time.Now().UnixNano())},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
tx, err := pool.Begin(ctx)
if err != nil {
t.Fatalf("begin: %v", err)
}
defer tx.Rollback(ctx)
if _, err := tx.Exec(ctx, tc.sql, tc.args...); err == nil {
t.Fatalf("expected error, got nil")
}
})
}
}
func TestCycleRejected(t *testing.T) {
pool := connect(t)
defer pool.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
tx, err := pool.Begin(ctx)
if err != nil {
t.Fatalf("begin: %v", err)
}
defer tx.Rollback(ctx)
var homeID string
if err := tx.QueryRow(ctx, `select id from projax.items where slug='home' and parent_id is null`).Scan(&homeID); err != nil {
t.Fatalf("home: %v", err)
}
var aID, bID string
if err := tx.QueryRow(ctx,
`insert into projax.items (kind, title, slug, parent_id) values (array['project']::text[], 'A', 'cyc-a', $1) returning id`, homeID,
).Scan(&aID); err != nil {
t.Fatalf("a: %v", err)
}
if err := tx.QueryRow(ctx,
`insert into projax.items (kind, title, slug, parent_id) values (array['project']::text[], 'B', 'cyc-b', $1) returning id`, aID,
).Scan(&bID); err != nil {
t.Fatalf("b: %v", err)
}
// Now try to make A a child of B -> cycle.
if _, err := tx.Exec(ctx, `update projax.items set parent_id=$1 where id=$2`, bID, aID); err == nil {
t.Fatalf("expected cycle rejection, got nil error")
}
// Also: self-parent.
if _, err := tx.Exec(ctx, `update projax.items set parent_id=$1 where id=$1`, aID); err == nil {
t.Fatalf("expected self-parent rejection, got nil error")
}
}

View File

@@ -0,0 +1,81 @@
-- 0001_init.sql
-- projax schema: items + item_links
-- See docs/design.md §3
create schema if not exists projax;
create table if not exists projax.items (
id uuid primary key default gen_random_uuid(),
kind text[] not null default '{}',
title text not null,
slug text not null,
path text not null default '',
parent_id uuid references projax.items(id) on delete restrict,
content_md text not null default '',
aliases text[] not null default '{}',
metadata jsonb not null default '{}'::jsonb,
status text not null default 'active',
pinned boolean not null default false,
archived boolean not null default false,
start_time timestamptz,
end_time timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
deleted_at timestamptz,
constraint items_slug_no_dots check (slug !~ '\.'),
constraint items_status_valid check (status in ('active', 'done', 'archived')),
unique (parent_id, slug)
);
create index if not exists items_path_idx on projax.items (path);
create index if not exists items_kind_idx on projax.items using gin (kind);
create index if not exists items_parent_idx on projax.items (parent_id);
create index if not exists items_status_idx on projax.items (status) where deleted_at is null;
create index if not exists items_aliases_idx on projax.items using gin (aliases);
-- Partial uniqueness for root-level slugs (parent_id is null) — PG treats null != null in unique
create unique index if not exists items_root_slug_uniq
on projax.items (slug) where parent_id is null;
create table if not exists projax.item_links (
id uuid primary key default gen_random_uuid(),
item_id uuid not null references projax.items(id) on delete cascade,
ref_type text not null,
ref_id text not null,
rel text not null default 'contains',
note text,
metadata jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
unique (item_id, ref_type, ref_id, rel)
);
create index if not exists item_links_item_idx on projax.item_links (item_id);
create index if not exists item_links_ref_idx on projax.item_links (ref_type, ref_id);
-- updated_at auto-touch
create or replace function projax.set_updated_at() returns trigger
language plpgsql as $$
begin
new.updated_at := now();
return new;
end;
$$;
drop trigger if exists items_set_updated_at on projax.items;
create trigger items_set_updated_at
before update on projax.items
for each row execute function projax.set_updated_at();
-- Grants: the projax service connects as the `postgres` role on msupabase.
-- Tailscale-only, single-user, so we grant freely on this schema.
do $$ begin
if exists (select 1 from pg_roles where rolname = 'postgres') then
execute 'grant usage on schema projax to postgres';
execute 'grant all on all tables in schema projax to postgres';
execute 'grant all on all sequences in schema projax to postgres';
execute 'grant all on all functions in schema projax to postgres';
execute 'alter default privileges in schema projax grant all on tables to postgres';
execute 'alter default privileges in schema projax grant all on sequences to postgres';
execute 'alter default privileges in schema projax grant all on functions to postgres';
end if;
end $$;

View File

@@ -0,0 +1,108 @@
-- 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();

View File

@@ -0,0 +1,13 @@
-- 0003_seed_areas.sql
-- Day-one areas. Idempotent: ON CONFLICT (slug) where parent is null DO NOTHING.
insert into projax.items (kind, title, slug)
values
(array['area']::text[], 'dev', 'dev'),
(array['area']::text[], 'sports', 'sports'),
(array['area']::text[], 'home', 'home'),
(array['area']::text[], 'work', 'work'),
(array['area']::text[], 'health', 'health'),
(array['area']::text[], 'finances', 'finances'),
(array['area']::text[], 'social', 'social')
on conflict (slug) where parent_id is null do nothing;

13
go.mod Normal file
View File

@@ -0,0 +1,13 @@
module github.com/m/projax
go 1.25.5
require github.com/jackc/pgx/v5 v5.9.2
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/text v0.29.0 // indirect
)

26
go.sum Normal file
View File

@@ -0,0 +1,26 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=