Files
pansy/internal/store/migrations/0001_init.sql
T
steveandClaude Opus 4.8 91da9ff945
Gadfly review (reusable) / review (pull_request) Successful in 10m8s
Adversarial Review (Gadfly) / review (pull_request) Successful in 10m9s
Phase 0: backend + frontend scaffold, single-binary build
Implements the Pansy v1 scaffold (epic #20, phase 0).

#1 Backend: Go module (pure-Go, builds with CGO_ENABLED=0), env config,
   gin server with slog + recovery + /api/v1/healthz, modernc SQLite store
   with WAL/busy_timeout/foreign_keys pragmas, embedded numbered-migration
   runner, full initial schema (users, sessions, gardens, garden_shares,
   garden_objects, plants, plantings), domain structs + sentinel errors,
   graceful shutdown.

#2 Frontend: Vite + React 19 + TS (strict) + Tailwind 4 + TanStack
   Router/Query, typed /api/v1 fetch wrapper (ApiError carries status +
   body so later issues can read 409 conflict rows), dev proxy /api -> :8080,
   responsive app shell with stub pages for all five routes.

#3 Single binary: //go:embed of the web build with a committed placeholder,
   SPA fallback (deep links, immutable asset caching, JSON 404 for unmatched
   /api and missing assets), Makefile (web/build/dev/test), README quickstart
   + env var table.

Verified: go build/vet/test clean (CGO off); binary migrates idempotently and
serves healthz; web tsc + build clean; integrated binary serves the SPA, deep
links, and correct cache headers; app mounts and navigates all five routes at
desktop and 375px widths.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
2026-07-18 14:52:05 -04:00

140 lines
7.8 KiB
SQL

-- 0001_init.sql — pansy initial schema.
--
-- Conventions (see DESIGN.md § Domain model):
-- * All measurements are centimeters (REAL); imperial is a display concern.
-- * `version INTEGER NOT NULL DEFAULT 1` on every user-mutable row; PATCH/DELETE
-- carry it and the service layer increments on write for 409 conflict detection.
-- * Enumerations are enforced with CHECK constraints.
-- * Timestamps are ISO-8601 TEXT (UTC); dates are 'YYYY-MM-DD' TEXT.
-- * Foreign keys cascade on owner/parent deletion; enforcement is on (PRAGMA foreign_keys).
-- users -----------------------------------------------------------------------
-- A user may authenticate locally (password_hash), via OIDC (oidc_issuer+subject),
-- or both (linked by email on first OIDC login). First registered user is admin.
CREATE TABLE users (
id INTEGER PRIMARY KEY,
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
display_name TEXT NOT NULL,
password_hash TEXT, -- argon2id; NULL for OIDC-only users
oidc_issuer TEXT, -- NULL for local-only users
oidc_subject TEXT,
is_admin INTEGER NOT NULL DEFAULT 0 CHECK (is_admin IN (0, 1)),
version INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
-- NULLs compare distinct in SQLite, so this only constrains real (issuer, subject) pairs.
CREATE UNIQUE INDEX idx_users_oidc ON users (oidc_issuer, oidc_subject);
-- sessions --------------------------------------------------------------------
-- Opaque bearer token stored only as a sha256 hash; the raw token lives in the
-- HttpOnly cookie. 30-day sliding expiry (expires_at bumped on use).
CREATE TABLE sessions (
token_hash TEXT PRIMARY KEY, -- sha256 of the 32-byte random token
user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
CREATE INDEX idx_sessions_user ON sessions (user_id);
-- gardens ---------------------------------------------------------------------
CREATE TABLE gardens (
id INTEGER PRIMARY KEY,
owner_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
name TEXT NOT NULL,
width_cm REAL NOT NULL,
height_cm REAL NOT NULL,
unit_pref TEXT NOT NULL DEFAULT 'metric' CHECK (unit_pref IN ('metric', 'imperial')),
notes TEXT NOT NULL DEFAULT '',
version INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
CREATE INDEX idx_gardens_owner ON gardens (owner_id);
-- garden_shares ---------------------------------------------------------------
-- The owner is implicit via gardens.owner_id and never has a share row.
CREATE TABLE garden_shares (
id INTEGER PRIMARY KEY,
garden_id INTEGER NOT NULL REFERENCES gardens (id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK (role IN ('viewer', 'editor')),
created_by INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
version INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
UNIQUE (garden_id, user_id)
);
CREATE INDEX idx_garden_shares_garden ON garden_shares (garden_id);
CREATE INDEX idx_garden_shares_user ON garden_shares (user_id);
-- garden_objects --------------------------------------------------------------
-- One polymorphic table for every placeable object. Positioned by CENTER point +
-- rotation about center, in garden space (origin top-left, x→right, y→down).
-- shape='polygon' and the points JSON column are reserved for post-v1; the v1
-- editor emits only 'rect' and 'circle' (circle: width_cm = diameter).
CREATE TABLE garden_objects (
id INTEGER PRIMARY KEY,
garden_id INTEGER NOT NULL REFERENCES gardens (id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK (kind IN ('bed', 'grow_bag', 'container', 'in_ground', 'tree', 'path', 'structure')),
name TEXT NOT NULL DEFAULT '',
shape TEXT NOT NULL DEFAULT 'rect' CHECK (shape IN ('rect', 'circle', 'polygon')),
points TEXT, -- reserved: JSON [[x,y],...] for shape='polygon'
x_cm REAL NOT NULL, -- center in garden space
y_cm REAL NOT NULL,
width_cm REAL NOT NULL,
height_cm REAL NOT NULL,
rotation_deg REAL NOT NULL DEFAULT 0, -- clockwise about center
z_index INTEGER NOT NULL DEFAULT 0,
plantable INTEGER NOT NULL DEFAULT 0 CHECK (plantable IN (0, 1)),
color TEXT, -- optional hex override
props TEXT, -- kind-specific extras (JSON)
notes TEXT NOT NULL DEFAULT '',
version INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
CREATE INDEX idx_garden_objects_garden ON garden_objects (garden_id);
-- plants ----------------------------------------------------------------------
-- Catalog. owner_id NULL = built-in (seeded, read-only; clone to customize).
-- Users see built-ins plus their own.
CREATE TABLE plants (
id INTEGER PRIMARY KEY,
owner_id INTEGER REFERENCES users (id) ON DELETE CASCADE, -- NULL = built-in
name TEXT NOT NULL,
category TEXT NOT NULL CHECK (category IN ('vegetable', 'herb', 'flower', 'fruit', 'tree_shrub', 'cover')),
spacing_cm REAL NOT NULL, -- mature spacing
color TEXT NOT NULL, -- hex
icon TEXT NOT NULL DEFAULT '', -- emoji (zero image assets in v1)
days_to_maturity INTEGER,
notes TEXT NOT NULL DEFAULT '',
version INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
CREATE INDEX idx_plants_owner ON plants (owner_id);
-- plantings ("plops") ---------------------------------------------------------
-- A circular patch of one plant, positioned in the PARENT OBJECT's local frame
-- (origin at object center, unrotated axes) so moving/rotating the bed moves its
-- plants for free. count NULL = derived max(1, round(pi*r^2 / spacing_cm^2)).
-- "Clear bed" sets removed_at; v1 lists rows where removed_at IS NULL.
CREATE TABLE plantings (
id INTEGER PRIMARY KEY,
object_id INTEGER NOT NULL REFERENCES garden_objects (id) ON DELETE CASCADE,
plant_id INTEGER NOT NULL REFERENCES plants (id) ON DELETE RESTRICT,
x_cm REAL NOT NULL, -- center in object-local frame
y_cm REAL NOT NULL,
radius_cm REAL NOT NULL,
count INTEGER, -- NULL = derived from area / spacing^2
label TEXT,
planted_at TEXT, -- 'YYYY-MM-DD'
removed_at TEXT, -- 'YYYY-MM-DD'; NULL = currently planted
version INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
CREATE INDEX idx_plantings_object ON plantings (object_id);
CREATE INDEX idx_plantings_plant ON plantings (plant_id);