m/paliad#77 Slice A. Folds the unbuilt t-paliad-214 Slice 3 (org async export) into a new "Backup Mode" surface gated by adminGate. m's calls (all 4 material picks per design §2): - Storage: local disk PALIAD_EXPORT_DIR (LocalDiskStore only) - Format: .zip bundle (xlsx + JSON + CSV + README) — no-lock-in preserved - paliadin_turns + paliadin_aichat_conversation: EXCLUDE structurally - Scheduler (Slice B): nightly 03:00 UTC, env-tunable Wiring: - mig 123 adds paliad.backups catalog table (kind/status/storage_uri/ size/row_counts/warnings/error/deleted_at + admin-only RLS). - ExportService.WriteOrg + orgSheetQueries enumerate 37 entity sheets + 12 ref sheets; REPEATABLE READ READ ONLY tx wraps the dump for snapshot consistency (design §3.3). - writeBundle + runSheetQuery refactored to take a sqlx.QueryerContext so both *sqlx.DB (personal/project paths, unchanged) and *sqlx.Tx (org snapshot path) work. - BackupRunner orchestrates: catalog INSERT → audit INSERT (event_type='backup_created') → WriteOrg → ArtifactStore.Put → patch catalog + audit on success/failure. - ArtifactStore interface + LocalDiskStore impl (defense-in-depth key validation + URI-outside-dir guard). - Sentinel actor for scheduled runs: actor_email='system@paliad', actor_id=NULL — no phantom user in paliad.users. - Admin handlers POST /api/admin/backups/run + GET list/get/download behind adminGate(users, …); /admin/backups page + sidebar entry + bilingual i18n keys. - BackupRunner only wired when PALIAD_EXPORT_DIR is set; routes return 503 otherwise (same shape as requireDB). Tests: 8 pure-function tests cover registry shape (no dups, paliadin absent both as sheet name and SQL substring, ref__* sheets unscoped, every sheet has ORDER BY) and LocalDiskStore (round-trip, bad-key rejection, URI-traversal rejection, mkdir on construction). go build ./... + go test ./internal/... clean. bun run build clean. Slice B (BackupScheduler + retention cleanup) and Slice C (UI polish) are separate follow-ups per head's instruction.
87 lines
4.4 KiB
SQL
87 lines
4.4 KiB
SQL
-- t-paliad-246 / m/paliad#77 — Backup Mode catalog table.
|
|
--
|
|
-- Design: docs/design-backup-mode-2026-05-25.md §4. One row per backup
|
|
-- run (on-demand or scheduled). The catalog is operational metadata for
|
|
-- the /admin/backups UI (size, row counts, storage URI, status). The
|
|
-- audit chain stays on paliad.system_audit_log — this table is the
|
|
-- richer-shape duplicate that the UI lists from without parsing JSON.
|
|
--
|
|
-- INSERT/UPDATE happen only through the Go service path (BackupRunner)
|
|
-- under the migration-runner role, so we don't add a write RLS policy
|
|
-- for end users. SELECT is admin-only, mirroring system_audit_log.
|
|
--
|
|
-- Idempotent: CREATE TABLE / INDEX / POLICY all guarded.
|
|
|
|
SELECT set_config(
|
|
'paliad.audit_reason',
|
|
'mig 123: add paliad.backups catalog for Backup Mode (t-paliad-246 / m/paliad#77 Slice A)',
|
|
true);
|
|
|
|
CREATE TABLE IF NOT EXISTS paliad.backups (
|
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
kind text NOT NULL CHECK (kind IN ('scheduled', 'on_demand')),
|
|
status text NOT NULL CHECK (status IN ('running', 'done', 'failed')),
|
|
-- requested_by is NULL for kind='scheduled' (no human caller).
|
|
requested_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
|
|
-- requested_by_email is captured at write time so the row survives
|
|
-- a subsequent user deletion. For scheduled runs we write a sentinel
|
|
-- like 'system@paliad' (no real user attached).
|
|
requested_by_email text NOT NULL,
|
|
-- audit_id back-references the system_audit_log row written before
|
|
-- the artifact is generated. Nullable so a catalog row can still be
|
|
-- INSERTed if the audit write itself fails (defense-in-depth).
|
|
audit_id uuid REFERENCES paliad.system_audit_log(id) ON DELETE SET NULL,
|
|
-- storage_uri is populated when status flips to 'done'. Resolves
|
|
-- through the Go-side ArtifactStore interface ('file://...' for
|
|
-- LocalDiskStore today; future stores get their own URI scheme).
|
|
storage_uri text,
|
|
size_bytes bigint,
|
|
row_counts jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
sheet_count int,
|
|
warnings jsonb NOT NULL DEFAULT '[]'::jsonb,
|
|
-- error is NULL unless status='failed'. Free-form, captured from
|
|
-- the Go-side error.Error().
|
|
error text,
|
|
started_at timestamptz NOT NULL DEFAULT now(),
|
|
finished_at timestamptz,
|
|
-- deleted_at marks artifacts the lifecycle cleanup removed from
|
|
-- storage (Slice B). The catalog row itself stays forever — it's
|
|
-- part of the audit chain. NULL means "still on disk".
|
|
deleted_at timestamptz
|
|
);
|
|
|
|
-- Read patterns:
|
|
-- - "show me recent backups" — started_at DESC
|
|
-- - "find last successful scheduled backup today" — kind + status + started_at
|
|
CREATE INDEX IF NOT EXISTS backups_started_at_desc_idx
|
|
ON paliad.backups (started_at DESC);
|
|
|
|
CREATE INDEX IF NOT EXISTS backups_kind_status_idx
|
|
ON paliad.backups (kind, status);
|
|
|
|
ALTER TABLE paliad.backups ENABLE ROW LEVEL SECURITY;
|
|
|
|
-- Admin-only read. INSERT/UPDATE/DELETE happen via the Go service path
|
|
-- under the migration-runner role (no end-user write surface).
|
|
DROP POLICY IF EXISTS backups_select_admin ON paliad.backups;
|
|
CREATE POLICY backups_select_admin ON paliad.backups
|
|
FOR SELECT USING (
|
|
EXISTS (
|
|
SELECT 1 FROM paliad.users u
|
|
WHERE u.id = auth.uid()
|
|
AND u.global_role = 'global_admin'
|
|
)
|
|
);
|
|
|
|
COMMENT ON TABLE paliad.backups IS
|
|
'Catalog of org-scope backup runs (t-paliad-246 / m/paliad#77). One row per scheduled or on-demand backup. status transitions: running → done | failed. storage_uri is resolved by the Go-side ArtifactStore interface. audit_id links to system_audit_log; the catalog row is the richer-shape duplicate, the audit row is the trust signal.';
|
|
|
|
COMMENT ON COLUMN paliad.backups.requested_by_email IS
|
|
'Captured at write time so the row survives user deletion. Sentinel ''system@paliad'' for scheduled runs.';
|
|
|
|
COMMENT ON COLUMN paliad.backups.storage_uri IS
|
|
'Resolved by the Go-side ArtifactStore implementation. file://... for LocalDiskStore; future stores use their own URI scheme.';
|
|
|
|
COMMENT ON COLUMN paliad.backups.deleted_at IS
|
|
'Set when the artifact is removed from storage by lifecycle cleanup. Catalog row stays forever (audit chain). NULL means artifact is still on disk.';
|