diff --git a/.m/.gitignore b/.m/.gitignore index 1864979..133192e 100644 --- a/.m/.gitignore +++ b/.m/.gitignore @@ -2,3 +2,5 @@ workers.json spawn.lock session.yaml config.reference.yaml +events.log +locks/ diff --git a/db/migrate.go b/db/migrate.go new file mode 100644 index 0000000..2a8179e --- /dev/null +++ b/db/migrate.go @@ -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 +} diff --git a/db/migrate_test.go b/db/migrate_test.go new file mode 100644 index 0000000..3bb171c --- /dev/null +++ b/db/migrate_test.go @@ -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") + } +} diff --git a/db/migrations/0001_init.sql b/db/migrations/0001_init.sql new file mode 100644 index 0000000..b1685e3 --- /dev/null +++ b/db/migrations/0001_init.sql @@ -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 $$; diff --git a/db/migrations/0002_path_trigger.sql b/db/migrations/0002_path_trigger.sql new file mode 100644 index 0000000..a074b92 --- /dev/null +++ b/db/migrations/0002_path_trigger.sql @@ -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(); diff --git a/db/migrations/0003_seed_areas.sql b/db/migrations/0003_seed_areas.sql new file mode 100644 index 0000000..bf2c4fa --- /dev/null +++ b/db/migrations/0003_seed_areas.sql @@ -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; diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..431b446 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f5b2410 --- /dev/null +++ b/go.sum @@ -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=