diff --git a/src/lib/server/backups/journal-core.ts b/src/lib/server/backups/journal-core.ts deleted file mode 100644 index b97d28f..0000000 --- a/src/lib/server/backups/journal-core.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * Pure core of the backup operation journal — NO database import, so it is unit - * testable under bun test (journal.ts pulls better-sqlite3 transitively and - * cannot be). All persistence is behind the injected `JournalStore` interface; - * journal.ts supplies the settings-backed implementation. - * - * The property this core encodes and the tests pin: record/clear/list operate on - * PER-KEY rows (one settings row per operation id), so two concurrent records - * never lost-update each other the way a single shared JSON array would. - */ -import { randomUUID } from 'crypto'; - -/** One durable row per key. `backup:op:`. */ -export const OP_KEY_PREFIX = 'backup:op:'; - -/** The kinds of in-flight operation the journal records. */ -export type OpKind = 'swap' | 'stop'; - -export interface OperationRecord { - id: string; - kind: OpKind; - instanceId: string; - envId?: number | null; - startedAt: string; - // kind: 'stop' - stopType?: 'stack' | 'container'; - targetName?: string; - containerId?: string; - configId?: number; - /** For a 'stop' of type 'container': the ids/names that were running and got - * stopped, so a fresh process can start exactly those back up. */ - containers?: Array<{ id: string; name: string }>; - // kind: 'swap' - snapshotId?: string; - includes?: string[]; - volumeBinds?: string[]; -} - -/** Minimal per-key settings store the core depends on (injected). */ -export interface JournalStore { - get(key: string): Promise; - set(key: string, value: any): Promise; - delete(key: string): Promise; - listByPrefix(prefix: string): Promise>; -} - -export function rowKey(id: string): string { - return OP_KEY_PREFIX + id; -} - -export async function recordOperationIn( - store: JournalStore, - rec: Omit & { id?: string } -): Promise { - const id = rec.id ?? randomUUID(); - const full: OperationRecord = { ...rec, id }; - try { - // Single-key upsert. No read-modify-write of a shared row → no lost update. - await store.set(rowKey(id), full); - } catch (err) { - console.warn(`[Backup] Could not persist ${rec.kind} operation ${id}: ${err instanceof Error ? err.message : String(err)}`); - } - return id; -} - -export async function clearOperationIn(store: JournalStore, id: string): Promise { - try { - await store.delete(rowKey(id)); - } catch { /* best-effort */ } -} - -export async function listOperationsIn( - store: JournalStore, - kind?: OpKind, - instanceId?: string -): Promise { - let rows: Array<{ key: string; value: any }>; - try { - rows = await store.listByPrefix(OP_KEY_PREFIX); - } catch { - return []; - } - const out: OperationRecord[] = []; - for (const r of rows) { - const rec = r.value as OperationRecord; - if (!rec || typeof rec !== 'object' || !rec.kind) continue; - if (kind && rec.kind !== kind) continue; - if (instanceId && rec.instanceId !== instanceId) continue; - out.push(rec); - } - return out; -} diff --git a/src/lib/server/backups/journal.ts b/src/lib/server/backups/journal.ts deleted file mode 100644 index 50496f8..0000000 --- a/src/lib/server/backups/journal.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Durable operation journal for the backup/restore domain — settings-backed - * adapter over the pure core in journal-core.ts. - * - * Each in-flight operation gets its OWN settings row keyed `backup:op:`: - * record = single-key upsert; clear = single-key delete; list = prefix scan. Two - * distinct operation ids write distinct rows and so can never lost-update each - * other — the failure mode a single shared JSON array mutated by a lock-free - * read-filter-write suffers under concurrency. The row carries the owning - * instanceId so a list can be scoped to this installation. The record shape lives - * in journal-core.ts (no db import) so it is unit-testable. - * - * This module is a LEAF: it depends only on the settings store, so it never - * imports the pipelines that use it (no cycle). - */ -import { getSetting, setSetting, deleteSetting, listSettingsByPrefix } from '../db'; -import { - recordOperationIn, clearOperationIn, listOperationsIn, - type JournalStore, type OperationRecord, type OpKind, -} from './journal-core'; - -export type { OperationRecord, OpKind } from './journal-core'; - -/** The production store: dockhand's settings table. */ -const settingsStore: JournalStore = { - get: (key) => getSetting(key), - set: (key, value) => setSetting(key, value), - delete: (key) => deleteSetting(key), - listByPrefix: (prefix) => listSettingsByPrefix(prefix), -}; - -export function recordOperation(rec: Omit & { id?: string }): Promise { - return recordOperationIn(settingsStore, rec); -} - -export function clearOperation(id: string): Promise { - return clearOperationIn(settingsStore, id); -} - -export function listOperations(kind?: OpKind, instanceId?: string): Promise { - return listOperationsIn(settingsStore, kind, instanceId); -} diff --git a/src/lib/server/backups/stop-recovery-core.ts b/src/lib/server/backups/stop-recovery-core.ts deleted file mode 100644 index e5846cc..0000000 --- a/src/lib/server/backups/stop-recovery-core.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * Pure decision core for recovering containers/stacks that were stopped for a - * consistent backup (or an in-place restore) but never restarted — because the - * process died between the stop and the in-process restart. NO database or - * docker import, so it is unit-testable under bun test. - * - * `reconcileOnStartup` records a durable 'stop' intent BEFORE it stops a target - * and clears it when the in-process restart succeeds. Anything left in the - * journal on the next startup is a target that is still down; this core turns - * each such intent into the concrete restart action the composition layer must - * run. It carries NO effects — it only decides — so the ordering and mapping can - * be proven without a live docker daemon. - */ -import type { OperationRecord } from './journal-core'; - -/** A stable per-target key for the durable 'stop' intent, so re-recording the - * same target updates in place rather than piling up rows. Mirrors how the - * 'swap' intent is keyed. */ -export function stopIntentKey(envId: number | null | undefined, type: 'container' | 'stack', targetName: string): string { - return `stop:${envId ?? 'local'}:${type}:${targetName}`; -} - -/** One concrete restart to perform for an interrupted 'stop' intent. */ -export type RestartAction = - | { intentId: string; type: 'stack'; targetName: string; envId: number | null | undefined } - | { intentId: string; type: 'container'; envId: number | null | undefined; containers: Array<{ id: string; name: string }> }; - -/** - * Whether an error from inspecting a stop-intent's target means the target is - * GONE (deleted) rather than temporarily unreachable. Only a Docker 404 is - * "gone" — the container/stack no longer exists, so a restart can never succeed - * and the intent must be dropped. Any other failure (env offline, daemon down, - * 5xx) is transient: the target may still exist, so the intent is kept for a - * later startup to retry. Pure so the classification is unit-testable. - */ -export function isTargetGoneError(err: unknown): boolean { - return (err as { statusCode?: number } | null)?.statusCode === 404; -} - -/** - * Map recorded 'stop' intents to restart actions, skipping any malformed row. - * A stack intent restarts the stack by name; a container intent restarts each - * recorded container id. A container intent with no recorded containers is - * skipped (nothing to restart); the caller drops such rows via the - * target-existence check in reconcileOnStartup. - */ -export function planStopRecovery(intents: OperationRecord[]): RestartAction[] { - const actions: RestartAction[] = []; - for (const rec of intents) { - if (rec.kind !== 'stop') continue; - if (rec.stopType === 'stack') { - if (!rec.targetName) continue; - actions.push({ intentId: rec.id, type: 'stack', targetName: rec.targetName, envId: rec.envId }); - continue; - } - // container (default) - const containers = (rec.containers ?? []).filter((c) => c && c.id); - if (containers.length === 0) continue; - actions.push({ intentId: rec.id, type: 'container', envId: rec.envId, containers }); - } - return actions; -} diff --git a/src/routes/settings/backups/VerifyModal.svelte b/src/routes/settings/backups/VerifyModal.svelte deleted file mode 100644 index 8097494..0000000 --- a/src/routes/settings/backups/VerifyModal.svelte +++ /dev/null @@ -1,131 +0,0 @@ - - - - - - - Verify backup integrity - - {destinationName} - - - {#if status === 'idle'} -
-

- Reads a random subset of data packs from the repository and verifies they are intact and readable. Larger subsets take longer but provide more thorough verification. -

-
- Data to verify: - dataSubset = v}> - {dataSubset} - - 5% (quick) - 10% - 25% - 50% - 100% (full) - - -
-
-
- - -
- {:else} -
- {#each logs as line} -
{line}
- {/each} - {#if status === 'running'} -
- Running... -
- {/if} - {#if status === 'success'} -
- Verification passed -
- {/if} - {#if status === 'error'} -
- {error} -
- {/if} -
-
- -
- {/if} -
-