Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,5 @@ coverage
*.tsbuildinfo
data/runtime/*
!data/runtime/.gitkeep
data/backups/*
!data/backups/.gitkeep
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Empty file added data/backups/.gitkeep
Empty file.
131 changes: 131 additions & 0 deletions docs/first-deploy-backup-runbook.md
Original file line number Diff line number Diff line change
@@ -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-<timestamp>.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-<timestamp>.json
```

Before replacing the runtime store, the script creates a safety copy of the current runtime JSON file:

```text
data/backups/pre-json-restore-<timestamp>.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-<timestamp>.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-<timestamp>.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 -- <json-backup>`;
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.
9 changes: 5 additions & 4 deletions docs/first-deploy-rehearsal.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 -- <backup>` 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
Expand All @@ -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.
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
27 changes: 27 additions & 0 deletions scripts/backup-runtime-json.mjs
Original file line number Diff line number Diff line change
@@ -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}`);
59 changes: 59 additions & 0 deletions scripts/restore-runtime-json.mjs
Original file line number Diff line number Diff line change
@@ -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 -- <backup-json-path>');
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}`);
Loading