remove backup journal, stop-recovery, and verify modal (deleted upstream)

This commit is contained in:
jarek committed 2026-08-09 10:35:15 +02:00
1 parent da8c83e20e
commit 091adfca35
4 files changed
-327

No files matched your search

-92
View File
@@ -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:<id>`. */
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<any>;
set(key: string, value: any): Promise<void>;
delete(key: string): Promise<void>;
listByPrefix(prefix: string): Promise<Array<{ key: string; value: any }>>;
}
export function rowKey(id: string): string {
return OP_KEY_PREFIX + id;
}
export async function recordOperationIn(
store: JournalStore,
rec: Omit<OperationRecord, 'id'> & { id?: string }
): Promise<string> {
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<void> {
try {
await store.delete(rowKey(id));
} catch { /* best-effort */ }
}
export async function listOperationsIn(
store: JournalStore,
kind?: OpKind,
instanceId?: string
): Promise<OperationRecord[]> {
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;
}
-42
View File
@@ -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:<id>`:
* 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<OperationRecord, 'id'> & { id?: string }): Promise<string> {
return recordOperationIn(settingsStore, rec);
}
export function clearOperation(id: string): Promise<void> {
return clearOperationIn(settingsStore, id);
}
export function listOperations(kind?: OpKind, instanceId?: string): Promise<OperationRecord[]> {
return listOperationsIn(settingsStore, kind, instanceId);
}
@@ -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;
}