From ee454163fdfd13c68354d9bb5ec14ffb4bff750e Mon Sep 17 00:00:00 2001 From: Diego Madero Islas Date: Wed, 10 Jun 2026 19:21:34 -0600 Subject: [PATCH] Add first deploy backup runbook and JSON snapshot scripts --- .gitignore | 2 + README.md | 1 + data/backups/.gitkeep | 0 docs/first-deploy-backup-runbook.md | 131 ++++++++++++++++++++++++++++ docs/first-deploy-rehearsal.md | 9 +- package.json | 4 +- scripts/backup-runtime-json.mjs | 27 ++++++ scripts/restore-runtime-json.mjs | 59 +++++++++++++ 8 files changed, 228 insertions(+), 5 deletions(-) create mode 100644 data/backups/.gitkeep create mode 100644 docs/first-deploy-backup-runbook.md create mode 100644 scripts/backup-runtime-json.mjs create mode 100644 scripts/restore-runtime-json.mjs diff --git a/.gitignore b/.gitignore index 7ce369e..d5cdb90 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,5 @@ coverage *.tsbuildinfo data/runtime/* !data/runtime/.gitkeep +data/backups/* +!data/backups/.gitkeep diff --git a/README.md b/README.md index 9532222..6d49f93 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,7 @@ SKOSS should favor: - practical exports in human-manageable formats - reliable backups - simple restore paths +- a documented first-deploy backup rehearsal path in [`docs/first-deploy-backup-runbook.md`](docs/first-deploy-backup-runbook.md) - architecture that can be understood and maintained by FOSS contributors - sensible privacy and security defaults for customer and operational data diff --git a/data/backups/.gitkeep b/data/backups/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/first-deploy-backup-runbook.md b/docs/first-deploy-backup-runbook.md new file mode 100644 index 0000000..8ba2b9b --- /dev/null +++ b/docs/first-deploy-backup-runbook.md @@ -0,0 +1,131 @@ +# First Deploy Backup Runbook + +This runbook is for safely retrying a first local SKOSS deploy rehearsal. It is intentionally narrow: it gives maintainers a way to snapshot rehearsal state, restore locally, and understand what is still manual. It is not a production-grade disaster recovery plan. + +## When To Take A Snapshot + +Take a snapshot before each destructive or hard-to-repeat step: + +- before running reset or reseed tools; +- before restoring another backup; +- after bootstrap and admin login are confirmed; +- after customers and basic products are confirmed; +- after the first real order is created; +- after production and handoff are verified. + +Label backup files with the rehearsal step in external notes if the filename alone is not enough. + +## Identify Persistence Mode + +SKOSS currently selects persistence mode with `SKOSS_PERSISTENCE_MODE`: + +- unset or `json`: JSON runtime mode; +- `hybrid`: migrated domains in PostgreSQL when `DATABASE_URL` is set, with remaining domains still in JSON; +- `postgres`: currently uses the same hybrid gateway shape and still needs the JSON-backed domains until migration is complete. + +PostgreSQL is only active when `SKOSS_PERSISTENCE_MODE` is `hybrid` or `postgres` and `DATABASE_URL` is set. If PostgreSQL is unavailable, the gateway logs a fallback and uses JSON runtime data. + +## JSON Runtime Backup + +JSON runtime state is stored at: + +```text +data/runtime/demo-store.json +``` + +Create a timestamped local rehearsal backup with: + +```bash +npm run skoss:backup:json +``` + +The backup is written to: + +```text +data/backups/skoss-json-runtime-.json +``` + +`data/backups/*` is ignored by git so local rehearsal records are not committed by accident. + +## JSON Runtime Restore + +Restore is destructive because it replaces `data/runtime/demo-store.json`. The CLI restore command refuses to run unless the explicit guard flag is set: + +```bash +SKOSS_ALLOW_JSON_RESTORE=1 npm run skoss:restore:json -- data/backups/skoss-json-runtime-.json +``` + +Before replacing the runtime store, the script creates a safety copy of the current runtime JSON file: + +```text +data/backups/pre-json-restore-.json +``` + +For app-based restore, `/entry` can upload a JSON backup. Use the CLI path when rehearsing from a terminal and the `/entry` path when validating the user-facing restore surface. + +## Hybrid And PostgreSQL Backup Considerations + +In hybrid mode, a complete rehearsal snapshot has two parts: + +1. the JSON runtime store, for JSON-backed domains such as orders, products, destinations, recurring templates, suppliers, raw materials, recipes, WIP, shift logs, and activities; +2. a PostgreSQL dump, for migrated domains such as workspace, preferences, instance state, session state, users, user roles, and customers. + +Start the local PostgreSQL service from `docker-compose.postgres.yml` when using the included compose setup. A maintainer can create a database dump with either a local `pg_dump` or Docker Compose command, depending on where PostgreSQL is running. + +Local `pg_dump` shape: + +```bash +pg_dump "$DATABASE_URL" --format=custom --file=data/backups/skoss-postgres-.dump +``` + +Docker Compose shape for the included service: + +```bash +docker compose -f docker-compose.postgres.yml exec -T skoss-postgres pg_dump -U skoss -d skoss --format=custom > data/backups/skoss-postgres-.dump +``` + +Do not commit `.dump` files or JSON backups. + +## Hybrid Restore And Retry + +A safe hybrid retry should restore both halves from the same rehearsal point: + +1. stop the app; +2. restore the PostgreSQL dump with `pg_restore` using the matching database connection; +3. restore the JSON runtime backup with `SKOSS_ALLOW_JSON_RESTORE=1 npm run skoss:restore:json -- `; +4. restart the app with the same `SKOSS_PERSISTENCE_MODE` and `DATABASE_URL`; +5. open `/entry` and confirm instance/admin/onboarding/demo state before continuing. + +This repository does not provide a PostgreSQL restore script yet. That is intentional: a database restore is destructive, environment-specific, and should not be hidden behind a premature npm alias. + +## Safe Vs Unsafe + +Safe for first local rehearsal: + +- timestamped JSON runtime backups; +- guarded JSON runtime restore with a pre-restore safety copy; +- manual PostgreSQL dump files kept outside git; +- restore testing on a local rehearsal instance. + +Unsafe or not ready: + +- treating JSON-only backup as complete when hybrid/PostgreSQL mode is active; +- running restore commands against a production database; +- committing backup files with real customer/order data; +- assuming this replaces real disaster recovery; +- restoring PostgreSQL without confirming the target database and credentials. + +## Not Production Grade Yet + +This runbook does not provide: + +- scheduled backups; +- encrypted backup storage; +- cloud or offsite backup; +- retention policy; +- point-in-time recovery; +- production restore automation; +- user-facing export management; +- validation that a backup contains every future migrated domain. + +For the first deploy rehearsal, the goal is narrower: make it possible to retry local setup and operational-flow tests without losing the current rehearsal state by accident. diff --git a/docs/first-deploy-rehearsal.md b/docs/first-deploy-rehearsal.md index 446391b..c28a6a1 100644 --- a/docs/first-deploy-rehearsal.md +++ b/docs/first-deploy-rehearsal.md @@ -99,9 +99,10 @@ If the runtime mode is unclear in `/entry`, stop the rehearsal and inspect envir ## Reset And Retry Notes - Use reset tools only in `demo` or other non-production local modes. -- Before destructive reset or restore tests, take a manual copy of JSON runtime data and any local database volume used by the rehearsal. -- Restore currently accepts JSON backup uploads from `/entry`. -- A user-facing export/backup creation flow is not ready yet. Treat export as a documented manual operator/maintainer task for the first rehearsal. +- Before destructive reset or restore tests, follow [First Deploy Backup Runbook](./first-deploy-backup-runbook.md). +- Use `npm run skoss:backup:json` for JSON runtime snapshots. +- Restore currently accepts JSON backup uploads from `/entry`; maintainers can also use the guarded `npm run skoss:restore:json -- ` path. +- Hybrid/PostgreSQL rehearsals need both the JSON runtime backup and a matching PostgreSQL dump. - If data mode changes during rehearsal, restart from a clean instance and repeat the path from `/entry`. ## Explicitly Not Ready Yet @@ -128,4 +129,4 @@ The first local deploy rehearsal should not attempt to validate: ## Recommended Next PR -Add a narrow backup/export rehearsal path or maintainer runbook that matches the active persistence mode. Keep it focused on "can we safely retry the first deploy rehearsal" rather than broad data lifecycle tooling. +Run the first deploy rehearsal using the backup runbook, then fix only the failures that block retrying setup, order capture, production progress, or handoff on the selected persistence mode. diff --git a/package.json b/package.json index 00a508a..17c89ce 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,9 @@ "reset:demo-runtime": "node scripts/reset-runtime-demo-store.mjs", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", - "db:push": "drizzle-kit push" + "db:push": "drizzle-kit push", + "skoss:backup:json": "node scripts/backup-runtime-json.mjs", + "skoss:restore:json": "node scripts/restore-runtime-json.mjs" }, "dependencies": { "next": "15.3.0", diff --git a/scripts/backup-runtime-json.mjs b/scripts/backup-runtime-json.mjs new file mode 100644 index 0000000..b653c86 --- /dev/null +++ b/scripts/backup-runtime-json.mjs @@ -0,0 +1,27 @@ +#!/usr/bin/env node +import { copyFile, mkdir, stat } from 'node:fs/promises'; +import path from 'node:path'; + +function timestamp() { + return new Date().toISOString().replace(/[:.]/g, '-'); +} + +const root = process.cwd(); +const runtimePath = path.join(root, 'data', 'runtime', 'demo-store.json'); +const backupDir = path.join(root, 'data', 'backups'); +const backupPath = path.join(backupDir, `skoss-json-runtime-${timestamp()}.json`); + +try { + await stat(runtimePath); +} catch { + console.error(`Runtime JSON store not found: ${runtimePath}`); + console.error('Start the app once or run bootstrap/reset before taking a JSON rehearsal backup.'); + process.exit(1); +} + +await mkdir(backupDir, { recursive: true }); +await copyFile(runtimePath, backupPath); + +console.log(`JSON runtime backup created: +- source: ${runtimePath} +- backup: ${backupPath}`); diff --git a/scripts/restore-runtime-json.mjs b/scripts/restore-runtime-json.mjs new file mode 100644 index 0000000..49b92b1 --- /dev/null +++ b/scripts/restore-runtime-json.mjs @@ -0,0 +1,59 @@ +#!/usr/bin/env node +import { copyFile, mkdir, readFile, stat } from 'node:fs/promises'; +import path from 'node:path'; + +function timestamp() { + return new Date().toISOString().replace(/[:.]/g, '-'); +} + +const backupArg = process.argv[2]; +const restoreAllowed = process.env.SKOSS_ALLOW_JSON_RESTORE === '1'; + +if (!backupArg) { + console.error('Usage: SKOSS_ALLOW_JSON_RESTORE=1 npm run skoss:restore:json -- '); + process.exit(1); +} + +if (!restoreAllowed) { + console.error('Refusing to restore without SKOSS_ALLOW_JSON_RESTORE=1.'); + console.error('This command replaces data/runtime/demo-store.json and is intended only for local rehearsal retry.'); + process.exit(1); +} + +const root = process.cwd(); +const runtimePath = path.join(root, 'data', 'runtime', 'demo-store.json'); +const runtimeDir = path.dirname(runtimePath); +const backupDir = path.join(root, 'data', 'backups'); +const sourcePath = path.resolve(root, backupArg); +const safetyBackupPath = path.join(backupDir, `pre-json-restore-${timestamp()}.json`); + +let parsed; +try { + parsed = JSON.parse(await readFile(sourcePath, 'utf8')); +} catch (error) { + console.error(`Backup file is not readable JSON: ${sourcePath}`); + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +} + +if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + console.error(`Backup JSON must contain an object-shaped SKOSS store: ${sourcePath}`); + process.exit(1); +} + +await mkdir(runtimeDir, { recursive: true }); +await mkdir(backupDir, { recursive: true }); + +try { + await stat(runtimePath); + await copyFile(runtimePath, safetyBackupPath); + console.log(`Pre-restore safety backup created: +- ${safetyBackupPath}`); +} catch { + console.log('No existing runtime JSON store found; skipping pre-restore safety backup.'); +} + +await copyFile(sourcePath, runtimePath); +console.log(`JSON runtime store restored: +- source: ${sourcePath} +- runtime: ${runtimePath}`);