-- 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);