Clean up the schema: drop unused tables, rename scopes, publishable awards

Migrations 020–023, with the code that reads each:

- 020 drops people_lists, people_list_members, v_chapters and
  v_person_affiliations. Nothing queried any of them.
- 021 renames event_sections to event_scopes and events.section_id
  to scope_id, finishing what 015 described. The API sends `scopes`
  and `scope_id`, and useEvents, EventListCards and EventCalendar
  take `scope`. It also inserts national/regional/partner, which only
  the retired seed ever created: a database built from migrations
  alone had no scope for the Retreats bands.
- 022 drops events.sort_order and people.sort_order. Events now sort
  by date (upcoming soonest first, past latest first, undated last)
  on /events, org pages and the countdown. People were only ever
  sorted by sort_name on the site. Every other sort_order stays.
- 023 rebuilds teams with created_at and updated_at plus a touch
  trigger, so the teams editor gets the same optimistic concurrency
  as the other entities.

Awards can be drafted: is_published (added in 011) is on both
descriptor halves with a Publishing group, and the award list, award
page, org awards and event awards leave drafts out.

The migration runner now turns foreign keys off around the per-file
transactions and runs foreign_key_check before each commit. PRAGMA
foreign_keys is a no-op inside a transaction, so 009's warning was
right and a rebuild of a referenced table (023) couldn't be written
otherwise. CLAUDE.md is updated to match.

Also removes the stray src/App.tsx.save.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Zaldimmar 2026-09-26 17:09:58 -05:00
parent 8790f861fe
commit 25e592bfea
19 changed files with 339 additions and 423 deletions

View file

@ -299,28 +299,27 @@ const events = {
columns: [
"id",
"title",
"section_id",
"scope_id",
"event_type",
"date_label",
"starts_on",
"status",
"is_published",
"sort_order",
"updated_at",
],
// No host filter: hosts are rows in another table now, and the
// engine's filters are columns on this one. The events a host
// owns are on that host's own page.
filters: ["section_id", "event_type", "status", "is_published"],
filters: ["scope_id", "event_type", "status", "is_published"],
search: ["title", "id", "theme"],
order: "sort_order, starts_on DESC, title",
order: "starts_on IS NULL, starts_on DESC, title",
},
columns: [
text("section_id", { required: true }),
text("scope_id", { required: true }),
// What kind of gathering, as against section_id's which band of
// the page. Declared required even though the column has a
// What kind of gathering, as against scope_id's whose gathering
// it is. Declared required even though the column has a
// DEFAULT: every select renders a blank first option, so without
// it a new event files itself as a retreat while nobody is
// looking. An existing row always loads with its value set, so
@ -344,7 +343,6 @@ const events = {
text("color"),
text("gradient"),
bool("is_published"),
int("sort_order"),
bool("in_timeline"),
// A repeating schedule. Columns rather than a side table: the
@ -410,12 +408,11 @@ const people = {
"tagline",
"locality",
"is_published",
"sort_order",
"updated_at",
],
filters: ["is_published"],
search: ["display_name", "sort_name", "id"],
order: "sort_order, sort_name, display_name",
order: "sort_name, display_name",
},
columns: [
@ -433,7 +430,6 @@ const people = {
text("country"),
text("location_label"),
bool("is_published"),
int("sort_order"),
],
extensions: [
@ -505,18 +501,15 @@ const people = {
// · deleting a team with members fails the same way, rather than
// quietly detaching them. Emptying the members list first is
// now something the form can do.
//
// No concurrency column: teams have no updated_at. Adding one
// means rebuilding a STRICT table for a row that one person edits
// at a time, which is not a trade worth making yet.
const teams = {
key: "teams",
table: "teams",
idColumn: "id",
idKind: "slug",
concurrency: "updated_at",
list: {
columns: ["id", "org_id", "name", "tagline", "is_published", "sort_order"],
columns: ["id", "org_id", "name", "tagline", "is_published", "sort_order", "updated_at"],
filters: ["org_id", "is_published"],
search: ["name", "id", "tagline"],
order: "org_id, sort_order, name",
@ -575,8 +568,8 @@ const awards = {
idKind: "slug",
list: {
columns: ["id", "org_id", "name", "description", "sort_order"],
filters: ["org_id"],
columns: ["id", "org_id", "name", "description", "is_published", "sort_order"],
filters: ["org_id", "is_published"],
search: ["name", "id", "description"],
order: "org_id, sort_order, name",
},
@ -586,6 +579,7 @@ const awards = {
text("name", { required: true }),
text("description"),
text("logo"),
bool("is_published"),
int("sort_order"),
],
};
@ -787,7 +781,7 @@ export const OPTION_QUERIES = {
"SELECT id, name AS label, kind FROM organizations ORDER BY kind, name",
regions:
"SELECT id, name AS label FROM organizations WHERE kind = 'region' ORDER BY name",
event_sections: "SELECT id, name AS label FROM event_sections ORDER BY sort_order",
event_scopes: "SELECT id, name AS label FROM event_scopes ORDER BY sort_order",
events: "SELECT id, title AS label FROM events ORDER BY starts_on DESC, title",
people: "SELECT id, display_name AS label FROM people ORDER BY sort_name, display_name",

View file

@ -82,6 +82,16 @@ export function tx(db, fn) {
Migrations only ever go forward. To undo something, write a
new migration.
Foreign keys are off while migrations run, the recipe from
the SQLite docs for changing a table's shape. PRAGMA
foreign_keys is a no-op inside a transaction, so it has to be
set here, around the per-file transactions, rather than in
the file. With it on, rebuilding a table something references
(drop the old one, rename the new one into place) either
cascades into the children or fails the commit. Instead each
file ends with a foreign_key_check, and any orphan it leaves
rolls that file back.
───────────────────────────────────────────────────────────── */
export function migrate(db, { log = console.log } = {}) {
@ -92,25 +102,41 @@ export function migrate(db, { log = console.log } = {}) {
.sort();
let applied = 0;
const enforced = db.prepare("PRAGMA foreign_keys").get().foreign_keys;
db.exec("PRAGMA foreign_keys = OFF");
for (const file of files) {
const version = Number.parseInt(file.slice(0, 3), 10);
try {
for (const file of files) {
const version = Number.parseInt(file.slice(0, 3), 10);
if (!Number.isInteger(version) || version < 1) {
throw new Error(`Migration "${file}" must start with a number, e.g. 001_`);
if (!Number.isInteger(version) || version < 1) {
throw new Error(`Migration "${file}" must start with a number, e.g. 001_`);
}
if (version <= current) continue;
const sql = readFileSync(join(MIGRATIONS_DIR, file), "utf8");
tx(db, () => {
db.exec(sql);
const orphans = db.prepare("PRAGMA foreign_key_check").all();
if (orphans.length > 0) {
const where = orphans
.slice(0, 5)
.map((o) => `${o.table} row ${o.rowid} → ${o.parent}`)
.join(", ");
throw new Error(`${file} leaves ${orphans.length} foreign key violation(s): ${where}`);
}
// Not parameterisable, but version is a validated integer.
db.exec(`PRAGMA user_version = ${version}`);
});
log(`migrated → ${file}`);
applied += 1;
}
if (version <= current) continue;
const sql = readFileSync(join(MIGRATIONS_DIR, file), "utf8");
tx(db, () => {
db.exec(sql);
// Not parameterisable, but version is a validated integer.
db.exec(`PRAGMA user_version = ${version}`);
});
log(`migrated → ${file}`);
applied += 1;
} finally {
if (enforced) db.exec("PRAGMA foreign_keys = ON");
}
const final = db.prepare("PRAGMA user_version").get().user_version;

View file

@ -0,0 +1,23 @@
-- ═══════════════════════════════════════════════════════════════
-- 020 DROP WHAT NOTHING READS
--
-- people_lists and people_list_members were planned for
-- hand-picked rosters and never got a route, a descriptor or a
-- component. v_chapters and v_person_affiliations have no query
-- against them: the routes read organizations and affiliations
-- directly.
--
-- Views first, then the child table before its parent. No trigger
-- references either table, and nothing has a foreign key into
-- them, so no rebuild and no PRAGMA foreign_keys dance.
--
-- PRAGMA user_version; -- reads 19 before this file
-- ═══════════════════════════════════════════════════════════════
DROP VIEW IF EXISTS v_chapters;
DROP VIEW IF EXISTS v_person_affiliations;
DROP TABLE IF EXISTS people_list_members;
DROP TABLE IF EXISTS people_lists;
PRAGMA user_version = 20; -- ← set to this migration's number

View file

@ -0,0 +1,32 @@
-- ═══════════════════════════════════════════════════════════════
-- 021 event_sections → event_scopes
--
-- 015 established that the table is a scope list — whose gathering
-- an event is — and kept the old name to avoid a rename across the
-- codebase. This is that rename: the table, and events.section_id
-- to scope_id. The API field and the useEvents filter follow in
-- the same change.
--
-- Both renames run with legacy_alter_table off (the default), so
-- SQLite carries them into the REFERENCES clause on events, the
-- index on section_id, and v_events, which selects e.*.
--
-- It also inserts the three scopes 015 only relabelled. The
-- retired seed script was what created national, regional and
-- partner, so a database built from migrations alone had no row
-- for the Retreats page's three bands to point at. INSERT OR
-- IGNORE with 015's names and order: a no-op where they exist.
--
-- PRAGMA user_version; -- reads 20 before this file
-- ═══════════════════════════════════════════════════════════════
ALTER TABLE event_sections RENAME TO event_scopes;
ALTER TABLE events RENAME COLUMN section_id TO scope_id;
INSERT OR IGNORE INTO event_scopes (id, name, sort_order) VALUES
('national', 'National', 10),
('regional', 'Regional', 20),
('partner', 'Partner', 50);
PRAGMA user_version = 21; -- ← set to this migration's number

View file

@ -0,0 +1,36 @@
-- ═══════════════════════════════════════════════════════════════
-- 022 DROP events.sort_order AND people.sort_order
--
-- events.sort_order dates from the seed, when event dates were
-- free text ("March/April 2026") and a typed number was the only
-- way to order a list. Events have real dates now, and a new one
-- landed at 0 — ahead of everything — until someone renumbered.
-- The routes order by date instead.
--
-- people.sort_order was never read by the public site: people
-- sort by sort_name everywhere. It only reordered the admin list.
--
-- Every other sort_order stays. On child collections it is the
-- row's position, written by the admin engine from drag order;
-- on organizations, teams, awards, timeline_entries and
-- event_scopes it is an ordering the site actually uses.
--
-- SQLite refuses DROP COLUMN while an index names the column, so
-- the three indexes that do go first and come back without it.
-- No view names either column (v_events selects e.*).
--
-- PRAGMA user_version; -- reads 21 before this file
-- ═══════════════════════════════════════════════════════════════
DROP INDEX IF EXISTS events_section_idx;
DROP INDEX IF EXISTS events_type_idx;
DROP INDEX IF EXISTS people_sort_idx;
ALTER TABLE events DROP COLUMN sort_order;
ALTER TABLE people DROP COLUMN sort_order;
CREATE INDEX events_scope_idx ON events (scope_id, is_published, starts_on);
CREATE INDEX events_type_idx ON events (event_type, is_published, starts_on);
CREATE INDEX people_sort_idx ON people (is_published, sort_name);
PRAGMA user_version = 22; -- ← set to this migration's number

View file

@ -0,0 +1,87 @@
-- ═══════════════════════════════════════════════════════════════
-- 023 teams GETS created_at AND updated_at
--
-- Every other top-level entity has updated_at, which the admin
-- engine compares on save so two people editing the same row
-- can't silently overwrite each other. teams was left out because
-- ADD COLUMN can't take DEFAULT (datetime('now')) on a STRICT
-- table, so this is the rebuild — the recipe from the SQLite docs.
--
-- The runner turns foreign keys off around every migration and
-- runs foreign_key_check before committing (see migrate() in
-- db.js). So DROP TABLE doesn't touch affiliations, whose
-- (team_id, org_id) reference keeps pointing at the name `teams`,
-- which the rename puts back underneath it.
--
-- legacy_alter_table is on for the rename: v_org_leadership,
-- v_timeline and the timeline_entries ref triggers name `teams`.
-- Between the DROP and the RENAME that table doesn't exist, and
-- the modern rename re-parses every view and trigger and fails on
-- them. Legacy mode leaves them alone; they resolve to the new
-- table by name. Turned back off before the end, since the
-- connection outlives this file.
--
-- DROP TABLE takes the two AFTER DELETE triggers with it (and does
-- not fire them), so they're recreated below exactly as 002 and
-- 007 wrote them, along with teams_touch. Triggers go last: the
-- runner may drop statements that follow a BEGIN...END body.
--
-- Verify after:
--
-- PRAGMA user_version; -- 23
-- PRAGMA foreign_key_check; -- no rows
-- SELECT name FROM sqlite_master WHERE tbl_name = 'teams';
-- ═══════════════════════════════════════════════════════════════
PRAGMA legacy_alter_table = ON;
CREATE TABLE teams_new (
id TEXT PRIMARY KEY, -- slug: 'board', 'nw-leadership'
org_id TEXT NOT NULL REFERENCES organizations (id) ON DELETE CASCADE,
name TEXT NOT NULL,
tagline TEXT,
color TEXT,
logo TEXT,
is_published INTEGER NOT NULL DEFAULT 1 CHECK (is_published IN (0, 1)),
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (id, org_id)
) STRICT;
-- Columns listed explicitly rather than SELECT *, so this breaks
-- loudly if the old shape isn't what this file assumes. Existing
-- rows get the migration time for both timestamps.
INSERT INTO teams_new
(id, org_id, name, tagline, color, logo, is_published, sort_order)
SELECT
id, org_id, name, tagline, color, logo, is_published, sort_order
FROM teams;
DROP TABLE teams;
ALTER TABLE teams_new RENAME TO teams;
CREATE INDEX teams_org_idx ON teams (org_id, sort_order);
PRAGMA legacy_alter_table = OFF;
CREATE TRIGGER teams_cleanup
AFTER DELETE ON teams
BEGIN
DELETE FROM content_blocks WHERE owner_kind = 'team' AND owner_id = old.id;
DELETE FROM links WHERE owner_kind = 'team' AND owner_id = old.id;
END;
CREATE TRIGGER timeline_teams_cleanup
AFTER DELETE ON teams
BEGIN
DELETE FROM timeline_entries WHERE ref_kind = 'team' AND ref_id = old.id;
END;
CREATE TRIGGER teams_touch
AFTER UPDATE ON teams
FOR EACH ROW WHEN new.updated_at = old.updated_at
BEGIN
UPDATE teams SET updated_at = datetime('now') WHERE id = new.id;
END;

View file

@ -1,7 +1,7 @@
/* ═══════════════════════════════════════════════════════════════
CONTENT ROUTES — read-only, mounted under /api
GET /events list + the section ids
GET /events list + the event scopes
GET /events/:id one event, full body, people
GET /organizations list, ?kind=region|chapter|…
GET /organizations/:id one organization's page
@ -20,8 +20,8 @@
from the dates when it isn't set. Components read one field and
don't reimplement the rules.
`event_type` is orthogonal to `section_id`: the section is which
band of the Retreats page an event belongs to, the type is what
`event_type` is orthogonal to `scope_id`: the scope is whose
gathering it is (national, regional, partner…), the type is what
kind of gathering it is. A region can run a class and a partner
can run a retreat, so neither implies the other and both ship on
every event.
@ -120,7 +120,7 @@ function shapeEvent(row, links, cardBlocks, hosts = []) {
return {
id: row.id,
section_id: row.section_id,
scope_id: row.scope_id,
event_type: row.event_type,
title: row.title,
@ -399,9 +399,7 @@ function attachTeams(db, orgs) {
for (const org of orgs) org.teams = byOrg.get(org.id) ?? [];
}
/* The awards this organization gives. awards has no is_published
column, so every row is public the moment it exists — see the
note in the route below. */
/* The awards this organization gives, drafts left out. */
function attachAwards(db, orgs) {
const ids = orgs.map((o) => o.id);
if (ids.length === 0) return;
@ -414,7 +412,7 @@ function attachAwards(db, orgs) {
JOIN people p ON p.id = pa.person_id AND p.is_published = 1
WHERE pa.award_id = a.id AND pa.is_public = 1) AS recipient_count
FROM awards a
WHERE a.org_id IN (${marks(ids.length)})
WHERE a.org_id IN (${marks(ids.length)}) AND a.is_published = 1
ORDER BY a.sort_order, a.name`,
)
.all(...ids);
@ -437,22 +435,33 @@ function attachAwards(db, orgs) {
}
/* ── Events ────────────────────────────────────────────────────
Flat, with the section ids alongside. Retreats.tsx owns the
section titles and colours and filters this list by section_id.
Flat, with the event scopes alongside. Retreats.tsx owns the
band titles and colours and filters this list by scope_id.
By date, the way the cards split them: upcoming soonest first,
then past most recent first, undated at the end of each. There
is no hand-typed order; a tie falls to the title.
───────────────────────────────────────────────────────────── */
const EVENT_ORDER = `
effective_status = 'past',
starts_on IS NULL,
CASE WHEN effective_status = 'past' THEN NULL ELSE starts_on END,
starts_on DESC,
title`;
content.get("/events", (c) => {
const db = c.get("db");
const sections = db
.prepare(`SELECT id, name, sort_order FROM event_sections ORDER BY sort_order`)
const scopes = db
.prepare(`SELECT id, name, sort_order FROM event_scopes ORDER BY sort_order`)
.all();
const rows = db
.prepare(
`SELECT * FROM v_events
WHERE is_published = 1
ORDER BY section_id, sort_order`,
ORDER BY ${EVENT_ORDER}`,
)
.all();
@ -470,7 +479,7 @@ content.get("/events", (c) => {
),
);
return json(c, { sections, events });
return json(c, { scopes, events });
});
@ -507,7 +516,7 @@ content.get("/events/:id", (c) => {
a.name AS award_name, a.logo AS award_logo,
pa.person_id, p.display_name, p.photo
FROM person_awards pa
JOIN awards a ON a.id = pa.award_id
JOIN awards a ON a.id = pa.award_id AND a.is_published = 1
JOIN people p ON p.id = pa.person_id AND p.is_published = 1
WHERE pa.event_id = ? AND pa.is_public = 1
ORDER BY a.sort_order, a.name, COALESCE(p.sort_name, p.display_name)`,
@ -622,7 +631,7 @@ content.get("/organizations/:id", (c) => {
FROM v_events e
JOIN event_hosts eh ON eh.event_id = e.id AND eh.org_id = ?
WHERE e.is_published = 1
ORDER BY e.sort_order`,
ORDER BY ${EVENT_ORDER}`,
)
.all(id);
@ -694,12 +703,8 @@ content.get("/teams/:id", (c) => {
GET /awards?org=ngu awards a given organization gives
GET /awards/:id one award and who has received it
`awards` has no is_published column: an award is public the
moment somebody creates it, and there is no way to draft one.
That was fine while awards only appeared as a line on a
person's record; it is thinner ground now that each has a URL.
A plain ADD COLUMN with DEFAULT 1 fixes it without a rebuild —
worth doing before this ships.
An unpublished award is a draft: left out of the list, and a
404 at its own URL, the same as any other unpublished row.
───────────────────────────────────────────────────────────── */
content.get("/awards", (c) => {
@ -718,7 +723,7 @@ content.get("/awards", (c) => {
WHERE pa.award_id = a.id AND pa.is_public = 1) AS recipient_count
FROM awards a
LEFT JOIN organizations o ON o.id = a.org_id AND o.is_published = 1
${org ? "WHERE a.org_id = ?" : ""}
WHERE a.is_published = 1 ${org ? "AND a.org_id = ?" : ""}
ORDER BY a.sort_order, a.name`,
)
.all(...(org ? [org] : []));
@ -741,7 +746,7 @@ content.get("/awards/:id", (c) => {
`SELECT a.*, o.name AS org_name, o.kind AS org_kind
FROM awards a
LEFT JOIN organizations o ON o.id = a.org_id AND o.is_published = 1
WHERE a.id = ?`,
WHERE a.id = ? AND a.is_published = 1`,
)
.get(id);

View file

@ -151,7 +151,7 @@ home.get("/front-page", (c) => {
.prepare(
`SELECT * FROM v_events
WHERE ${notOver}
ORDER BY starts_on, sort_order
ORDER BY starts_on, title
LIMIT 1`,
)
.get();

View file

@ -225,7 +225,7 @@ people.get("/people/:id", (c) => {
WHERE person_id = ?
) x
JOIN v_events e ON e.id = x.event_id AND e.is_published = 1
ORDER BY e.starts_on IS NULL, e.starts_on DESC, e.sort_order, x.sort_order`,
ORDER BY e.starts_on IS NULL, e.starts_on DESC, e.title, x.sort_order`,
)
.all(id, id);