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:
2
.m/.gitignore
vendored
2
.m/.gitignore
vendored
@@ -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
46
db/migrate.go
Normal 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
240
db/migrate_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
81
db/migrations/0001_init.sql
Normal file
81
db/migrations/0001_init.sql
Normal 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 $$;
|
||||||
108
db/migrations/0002_path_trigger.sql
Normal file
108
db/migrations/0002_path_trigger.sql
Normal 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();
|
||||||
13
db/migrations/0003_seed_areas.sql
Normal file
13
db/migrations/0003_seed_areas.sql
Normal 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
13
go.mod
Normal 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
26
go.sum
Normal 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=
|
||||||
Reference in New Issue
Block a user