@sdods/db 0.13.2 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/driver.d.ts CHANGED
@@ -16,4 +16,6 @@ export declare const DEFAULT_SQLITE_PATH = ".sdods/sdods.db";
16
16
  export declare function findWorkspaceRoot(start?: string): string | null;
17
17
  /** Resolve the platform database driver from the environment (`DB_DRIVER`, `SQLITE_PATH`, `DATABASE_URL`). */
18
18
  export declare function resolveDriverConfig(env?: NodeJS.ProcessEnv): DriverConfig;
19
+ /** A connection URL with the password masked, safe to print or return from the API. */
20
+ export declare function redactUrl(url?: string): string;
19
21
  //# sourceMappingURL=driver.d.ts.map
package/dist/driver.js CHANGED
@@ -47,4 +47,10 @@ export function resolveDriverConfig(env = process.env) {
47
47
  sqlitePath: root ? join(root, DEFAULT_SQLITE_PATH) : DEFAULT_SQLITE_PATH,
48
48
  };
49
49
  }
50
+ /** A connection URL with the password masked, safe to print or return from the API. */
51
+ export function redactUrl(url) {
52
+ if (!url)
53
+ return '';
54
+ return url.replace(/\/\/([^:/]+):[^@]+@/, '//$1:***@');
55
+ }
50
56
  //# sourceMappingURL=driver.js.map
package/dist/index.d.ts CHANGED
@@ -10,6 +10,7 @@ export * from './switch/index.js';
10
10
  export * from './insights.js';
11
11
  export * from './test-data.js';
12
12
  export * from './scopes.js';
13
+ export * from './storage.js';
13
14
  import { type SdodsDb } from './create-db.js';
14
15
  import { type DriverConfig } from './driver.js';
15
16
  /** Open + migrate in one call (what `sdods serve` and `sdods run --ingest` do on boot). */
package/dist/index.js CHANGED
@@ -10,6 +10,7 @@ export * from './switch/index.js';
10
10
  export * from './insights.js';
11
11
  export * from './test-data.js';
12
12
  export * from './scopes.js';
13
+ export * from './storage.js';
13
14
  import { createDb } from './create-db.js';
14
15
  import { resolveDriverConfig } from './driver.js';
15
16
  import { migrateToLatest } from './migrate.js';
@@ -0,0 +1,10 @@
1
+ import type { Kysely } from 'kysely';
2
+ import type { Col } from '../col.js';
3
+ /**
4
+ * Platform-wide settings an admin changes from the UI or `sdods db retention`, keyed by name
5
+ * (`retention`, …). One row per key; the value is JSON so a setting can grow fields without a
6
+ * migration.
7
+ */
8
+ export declare function up(db: Kysely<any>, c: Col): Promise<void>;
9
+ export declare function down(db: Kysely<any>, _c: Col): Promise<void>;
10
+ //# sourceMappingURL=0012_platform_settings.d.ts.map
@@ -0,0 +1,16 @@
1
+ import { dropTables, index, stamps, table } from './helpers.js';
2
+ /**
3
+ * Platform-wide settings an admin changes from the UI or `sdods db retention`, keyed by name
4
+ * (`retention`, …). One row per key; the value is JSON so a setting can grow fields without a
5
+ * migration.
6
+ */
7
+ export async function up(db, c) {
8
+ await stamps(table(db, 'platform_settings', c)
9
+ .addColumn('key', 'text', (cb) => cb.notNull())
10
+ .addColumn('value_json', c.json, (cb) => cb.notNull()), c).execute();
11
+ await index(db, 'uq_platform_settings_key', 'platform_settings', ['key'], true);
12
+ }
13
+ export async function down(db, _c) {
14
+ await dropTables(db, ['platform_settings']);
15
+ }
16
+ //# sourceMappingURL=0012_platform_settings.js.map
@@ -9,6 +9,7 @@ import * as m0008 from './0008_runner_naming.js';
9
9
  import * as m0009 from './0009_user_profile.js';
10
10
  import * as m0010 from './0010_run_params.js';
11
11
  import * as m0011 from './0011_user_preferences.js';
12
+ import * as m0012 from './0012_platform_settings.js';
12
13
  /**
13
14
  * Static registry (bundler-friendly, no runtime file discovery). Order matters: Kysely sorts by key.
14
15
  */
@@ -24,5 +25,6 @@ export const MIGRATIONS = {
24
25
  '0009_user_profile': m0009,
25
26
  '0010_run_params': m0010,
26
27
  '0011_user_preferences': m0011,
28
+ '0012_platform_settings': m0012,
27
29
  };
28
30
  //# sourceMappingURL=index.js.map
@@ -6,4 +6,6 @@ export * from './auth.js';
6
6
  export * from './platform.js';
7
7
  export * from './hierarchy.js';
8
8
  export * from './preferences.js';
9
+ export * from './settings.js';
10
+ export * from './retention.js';
9
11
  //# sourceMappingURL=index.d.ts.map
@@ -6,4 +6,6 @@ export * from './auth.js';
6
6
  export * from './platform.js';
7
7
  export * from './hierarchy.js';
8
8
  export * from './preferences.js';
9
+ export * from './settings.js';
10
+ export * from './retention.js';
9
11
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,49 @@
1
+ import type { SdodsDb } from '../create-db.js';
2
+ /** A run in one of these states is still being written to; deleting it would corrupt it. */
3
+ export declare const LIVE_RUN_STATUSES: readonly ["queued", "running"];
4
+ export interface DeleteRunsOptions {
5
+ /** Where run artifact directories live (`.sdods/runs`), for runs with no `artifacts_dir`. */
6
+ artifactsRoot?: string;
7
+ actor?: {
8
+ userId?: string | null;
9
+ type?: 'user' | 'cli' | 'system' | 'token';
10
+ ip?: string | null;
11
+ };
12
+ /** Why: `manual`, `prune`, `retention`, `schedule-retention`. Recorded in the audit log. */
13
+ reason?: string;
14
+ dryRun?: boolean;
15
+ }
16
+ export interface DeletedRun {
17
+ id: string;
18
+ projectSlug: string;
19
+ createdAt: string;
20
+ bytes: number;
21
+ }
22
+ export interface DeleteRunsResult {
23
+ deleted: DeletedRun[];
24
+ skipped: Array<{
25
+ id: string;
26
+ reason: 'live' | 'not-found';
27
+ }>;
28
+ freedBytes: number;
29
+ }
30
+ /**
31
+ * Delete runs, their child rows and their artifact directories.
32
+ *
33
+ * - Live runs (queued / running) are skipped, never deleted.
34
+ * - Rows are removed in one transaction; references from tables without a foreign key
35
+ * (`schedule_runs`, `user_leases`, `last_run_id` columns) are cleared so nothing dangles.
36
+ * - Artifact directories are removed only after the commit, and only when the directory is the
37
+ * run's own (under `artifactsRoot`, or named after the run id).
38
+ * - Each deleted run gets a `run.delete` audit entry.
39
+ */
40
+ export declare function deleteRuns(adb: SdodsDb, ids: string[], opts?: DeleteRunsOptions): Promise<DeleteRunsResult>;
41
+ /**
42
+ * The artifact directory that belongs to this run, or null. A stored `artifacts_dir` is trusted
43
+ * only when it sits under `artifactsRoot` or its last segment is the run id, so a bad row can
44
+ * never point the delete at an unrelated folder.
45
+ */
46
+ export declare function ownArtifactsDir(runId: string, stored: string | null, artifactsRoot?: string): string | null;
47
+ /** Total size of the files under `path` (symlinks are not followed). */
48
+ export declare function dirSize(path: string): number;
49
+ //# sourceMappingURL=retention.d.ts.map
@@ -0,0 +1,134 @@
1
+ import { existsSync, lstatSync, readdirSync, rmSync } from 'node:fs';
2
+ import { join, relative, resolve, sep, isAbsolute } from 'node:path';
3
+ import { audit } from './platform.js';
4
+ import { deleteRun } from './runs.js';
5
+ /** A run in one of these states is still being written to; deleting it would corrupt it. */
6
+ export const LIVE_RUN_STATUSES = ['queued', 'running'];
7
+ /**
8
+ * Delete runs, their child rows and their artifact directories.
9
+ *
10
+ * - Live runs (queued / running) are skipped, never deleted.
11
+ * - Rows are removed in one transaction; references from tables without a foreign key
12
+ * (`schedule_runs`, `user_leases`, `last_run_id` columns) are cleared so nothing dangles.
13
+ * - Artifact directories are removed only after the commit, and only when the directory is the
14
+ * run's own (under `artifactsRoot`, or named after the run id).
15
+ * - Each deleted run gets a `run.delete` audit entry.
16
+ */
17
+ export async function deleteRuns(adb, ids, opts = {}) {
18
+ const unique = [...new Set(ids)].filter(Boolean);
19
+ if (unique.length === 0)
20
+ return { deleted: [], skipped: [], freedBytes: 0 };
21
+ const rows = await adb.db
22
+ .selectFrom('runs')
23
+ .leftJoin('projects', 'projects.id', 'runs.project_id')
24
+ .select([
25
+ 'runs.id as id',
26
+ 'runs.status as status',
27
+ 'runs.created_at as created_at',
28
+ 'runs.artifacts_dir as artifacts_dir',
29
+ 'projects.slug as slug',
30
+ ])
31
+ .where('runs.id', 'in', unique)
32
+ .execute();
33
+ const byId = new Map(rows.map((r) => [r.id, r]));
34
+ const skipped = [];
35
+ const victims = [];
36
+ for (const id of unique) {
37
+ const r = byId.get(id);
38
+ if (!r) {
39
+ skipped.push({ id, reason: 'not-found' });
40
+ continue;
41
+ }
42
+ if (LIVE_RUN_STATUSES.includes(r.status)) {
43
+ skipped.push({ id, reason: 'live' });
44
+ continue;
45
+ }
46
+ const dir = ownArtifactsDir(id, r.artifacts_dir, opts.artifactsRoot);
47
+ victims.push({
48
+ id,
49
+ projectSlug: r.slug ?? '',
50
+ createdAt: String(r.created_at),
51
+ bytes: dir ? dirSize(dir) : 0,
52
+ dir,
53
+ });
54
+ }
55
+ const freedBytes = victims.reduce((n, v) => n + v.bytes, 0);
56
+ const result = {
57
+ deleted: victims.map(({ dir: _dir, ...v }) => v),
58
+ skipped,
59
+ freedBytes,
60
+ };
61
+ if (opts.dryRun || victims.length === 0)
62
+ return result;
63
+ const victimIds = victims.map((v) => v.id);
64
+ await adb.db.transaction().execute(async (trx) => {
65
+ await trx
66
+ .updateTable('schedule_runs')
67
+ .set({ run_id: null })
68
+ .where('run_id', 'in', victimIds)
69
+ .execute();
70
+ await trx
71
+ .updateTable('user_leases')
72
+ .set({ run_id: null })
73
+ .where('run_id', 'in', victimIds)
74
+ .execute();
75
+ for (const t of ['schedules', 'processes', 'flaky_stats', 'issue_links']) {
76
+ await trx
77
+ .updateTable(t)
78
+ .set({ last_run_id: null })
79
+ .where('last_run_id', 'in', victimIds)
80
+ .execute();
81
+ }
82
+ for (const v of victims) {
83
+ await deleteRun(trx, v.id);
84
+ await audit(trx, {
85
+ actorUserId: opts.actor?.userId ?? null,
86
+ actorType: opts.actor?.type ?? 'system',
87
+ action: 'run.delete',
88
+ targetType: 'run',
89
+ targetId: v.id,
90
+ details: { project: v.projectSlug, reason: opts.reason ?? 'manual', bytes: v.bytes },
91
+ ip: opts.actor?.ip ?? null,
92
+ });
93
+ }
94
+ });
95
+ for (const v of victims) {
96
+ if (v.dir && existsSync(v.dir))
97
+ rmSync(v.dir, { recursive: true, force: true });
98
+ }
99
+ return result;
100
+ }
101
+ /**
102
+ * The artifact directory that belongs to this run, or null. A stored `artifacts_dir` is trusted
103
+ * only when it sits under `artifactsRoot` or its last segment is the run id, so a bad row can
104
+ * never point the delete at an unrelated folder.
105
+ */
106
+ export function ownArtifactsDir(runId, stored, artifactsRoot) {
107
+ const root = artifactsRoot ? resolve(artifactsRoot) : null;
108
+ const candidates = [stored, root ? join(root, runId) : null].filter(Boolean);
109
+ for (const c of candidates) {
110
+ const dir = resolve(c);
111
+ const underRoot = root ? isInside(root, dir) && dir !== root : false;
112
+ const named = dir.split(sep).pop() === runId;
113
+ if ((underRoot || named) && existsSync(dir))
114
+ return dir;
115
+ }
116
+ return null;
117
+ }
118
+ function isInside(parent, child) {
119
+ const rel = relative(parent, child);
120
+ return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
121
+ }
122
+ /** Total size of the files under `path` (symlinks are not followed). */
123
+ export function dirSize(path) {
124
+ if (!existsSync(path))
125
+ return 0;
126
+ const st = lstatSync(path);
127
+ if (!st.isDirectory())
128
+ return st.isFile() ? st.size : 0;
129
+ let total = 0;
130
+ for (const name of readdirSync(path))
131
+ total += dirSize(join(path, name));
132
+ return total;
133
+ }
134
+ //# sourceMappingURL=retention.js.map
@@ -0,0 +1,18 @@
1
+ import type { Kysely } from 'kysely';
2
+ import type { Database } from '../schema.js';
3
+ export declare function getPlatformSetting<T = unknown>(db: Kysely<Database>, key: string): Promise<T | null>;
4
+ /** Insert or replace one platform setting. */
5
+ export declare function setPlatformSetting<T = unknown>(db: Kysely<Database>, key: string, value: T): Promise<void>;
6
+ /**
7
+ * How much run history to keep. `keepRuns` is per project; `keepDays` is by age; either may be
8
+ * null (no limit). `auto` makes the server apply them once a day.
9
+ */
10
+ export interface RetentionSettings {
11
+ keepRuns: number | null;
12
+ keepDays: number | null;
13
+ auto: boolean;
14
+ }
15
+ export declare const DEFAULT_RETENTION: RetentionSettings;
16
+ export declare function getRetentionSettings(db: Kysely<Database>): Promise<RetentionSettings>;
17
+ export declare function setRetentionSettings(db: Kysely<Database>, value: Partial<RetentionSettings>): Promise<RetentionSettings>;
18
+ //# sourceMappingURL=settings.d.ts.map
@@ -0,0 +1,43 @@
1
+ import { enc, nowIso, readJson } from '../col.js';
2
+ import { newId } from '../ids.js';
3
+ export async function getPlatformSetting(db, key) {
4
+ const row = await db
5
+ .selectFrom('platform_settings')
6
+ .select('value_json')
7
+ .where('key', '=', key)
8
+ .executeTakeFirst();
9
+ return row ? readJson(row.value_json) : null;
10
+ }
11
+ /** Insert or replace one platform setting. */
12
+ export async function setPlatformSetting(db, key, value) {
13
+ const now = nowIso();
14
+ const existing = await db
15
+ .selectFrom('platform_settings')
16
+ .select('id')
17
+ .where('key', '=', key)
18
+ .executeTakeFirst();
19
+ if (existing) {
20
+ await db
21
+ .updateTable('platform_settings')
22
+ .set({ value_json: enc.json(value), updated_at: now })
23
+ .where('id', '=', existing.id)
24
+ .execute();
25
+ }
26
+ else {
27
+ await db
28
+ .insertInto('platform_settings')
29
+ .values({ id: newId(), key, value_json: enc.json(value), created_at: now, updated_at: now })
30
+ .execute();
31
+ }
32
+ }
33
+ export const DEFAULT_RETENTION = { keepRuns: null, keepDays: null, auto: false };
34
+ export async function getRetentionSettings(db) {
35
+ const v = await getPlatformSetting(db, 'retention');
36
+ return { ...DEFAULT_RETENTION, ...(v ?? {}) };
37
+ }
38
+ export async function setRetentionSettings(db, value) {
39
+ const next = { ...(await getRetentionSettings(db)), ...value };
40
+ await setPlatformSetting(db, 'retention', next);
41
+ return next;
42
+ }
43
+ //# sourceMappingURL=settings.js.map
package/dist/schema.d.ts CHANGED
@@ -485,6 +485,13 @@ export interface UserPreferencesTable {
485
485
  created_at: Ts;
486
486
  updated_at: Ts;
487
487
  }
488
+ export interface PlatformSettingsTable {
489
+ id: string;
490
+ key: string;
491
+ value_json: Json<unknown>;
492
+ created_at: Ts;
493
+ updated_at: Ts;
494
+ }
488
495
  export interface Database {
489
496
  organizations: OrganizationsTable;
490
497
  workspaces: WorkspacesTable;
@@ -518,6 +525,7 @@ export interface Database {
518
525
  schedule_runs: ScheduleRunsTable;
519
526
  audit_log: AuditLogTable;
520
527
  user_preferences: UserPreferencesTable;
528
+ platform_settings: PlatformSettingsTable;
521
529
  }
522
530
  /** Platform tables in foreign-key order (parents first). Used by export/import/switch/prune. */
523
531
  export declare const TABLES_IN_FK_ORDER: Array<keyof Database>;
package/dist/schema.js CHANGED
@@ -32,6 +32,7 @@ export const TABLES_IN_FK_ORDER = [
32
32
  'schedules',
33
33
  'schedule_runs',
34
34
  'audit_log',
35
+ 'platform_settings',
35
36
  ];
36
37
  export const MIGRATION_TABLE = 'sdods_migrations';
37
38
  export const MIGRATION_LOCK_TABLE = 'sdods_migrations_lock';
@@ -0,0 +1,23 @@
1
+ import type { SdodsDb } from './create-db.js';
2
+ export interface StorageStats {
3
+ driver: 'sqlite' | 'postgres';
4
+ /** SQLite file path, or the Postgres URL with the password masked. */
5
+ location: string;
6
+ /** Database size in bytes (SQLite: file + WAL; Postgres: pg_database_size). Null when unknown. */
7
+ dbBytes: number | null;
8
+ artifactsDir: string | null;
9
+ artifactsBytes: number;
10
+ counts: Record<'runs' | 'scenarios' | 'artifacts' | 'auditLog' | 'users' | 'projects', number>;
11
+ oldestRunAt: string | null;
12
+ newestRunAt: string | null;
13
+ migrations: {
14
+ applied: number;
15
+ pending: number;
16
+ latest: string | null;
17
+ };
18
+ }
19
+ /** What the database holds and how much space it and the run artifacts take. */
20
+ export declare function storageStats(adb: SdodsDb, opts?: {
21
+ artifactsRoot?: string;
22
+ }): Promise<StorageStats>;
23
+ //# sourceMappingURL=storage.d.ts.map
@@ -0,0 +1,76 @@
1
+ import { existsSync, statSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { sql } from 'kysely';
4
+ import { redactUrl } from './driver.js';
5
+ import { migrationStatus } from './migrate.js';
6
+ import { dirSize } from './repos/retention.js';
7
+ import { countRows } from './switch/export.js';
8
+ /** What the database holds and how much space it and the run artifacts take. */
9
+ export async function storageStats(adb, opts = {}) {
10
+ const sqlite = adb.driver === 'sqlite';
11
+ const path = adb.config.sqlitePath;
12
+ let dbBytes;
13
+ if (sqlite) {
14
+ if (path && path !== ':memory:') {
15
+ const abs = resolve(path);
16
+ dbBytes = [abs, `${abs}-wal`]
17
+ .filter((f) => existsSync(f))
18
+ .reduce((n, f) => n + statSync(f).size, 0);
19
+ }
20
+ else {
21
+ const res = await sql `select page_count * page_size as n from pragma_page_count(), pragma_page_size()`.execute(adb.db);
22
+ dbBytes = Number(res.rows[0]?.n ?? 0);
23
+ }
24
+ }
25
+ else {
26
+ const res = await sql `select pg_database_size(current_database()) as n`
27
+ .execute(adb.db)
28
+ .catch(() => null);
29
+ dbBytes = res ? Number(res.rows[0]?.n ?? 0) : null;
30
+ }
31
+ const count = (t) => countRows(adb, t);
32
+ const counts = {
33
+ runs: await count('runs'),
34
+ scenarios: await count('scenarios'),
35
+ artifacts: await count('artifacts'),
36
+ auditLog: await count('audit_log'),
37
+ users: await count('users'),
38
+ projects: await count('projects'),
39
+ };
40
+ const range = await adb.db
41
+ .selectFrom('runs')
42
+ .select([
43
+ sql `min(coalesce(started_at, created_at))`.as('oldest'),
44
+ sql `max(coalesce(started_at, created_at))`.as('newest'),
45
+ ])
46
+ .executeTakeFirst();
47
+ const mig = await migrationStatus(adb);
48
+ const applied = mig.filter((m) => m.executedAt);
49
+ const artifactsDir = opts.artifactsRoot ? resolve(opts.artifactsRoot) : null;
50
+ return {
51
+ driver: adb.driver,
52
+ location: sqlite
53
+ ? path && path !== ':memory:'
54
+ ? resolve(path)
55
+ : ':memory:'
56
+ : redactUrl(adb.config.databaseUrl),
57
+ dbBytes,
58
+ artifactsDir,
59
+ artifactsBytes: artifactsDir ? dirSize(artifactsDir) : 0,
60
+ counts,
61
+ oldestRunAt: toIso(range?.oldest),
62
+ newestRunAt: toIso(range?.newest),
63
+ migrations: {
64
+ applied: applied.length,
65
+ pending: mig.length - applied.length,
66
+ latest: applied.at(-1)?.name ?? null,
67
+ },
68
+ };
69
+ }
70
+ function toIso(v) {
71
+ if (v == null)
72
+ return null;
73
+ const d = v instanceof Date ? v : new Date(String(v));
74
+ return Number.isNaN(d.getTime()) ? null : d.toISOString();
75
+ }
76
+ //# sourceMappingURL=storage.js.map
@@ -0,0 +1,42 @@
1
+ import type { SdodsDb } from '../create-db.js';
2
+ import { type ImportResult } from './import.js';
3
+ export interface BackupManifest {
4
+ id: string;
5
+ createdAt: string;
6
+ driver: 'sqlite' | 'postgres';
7
+ /** Free text: `manual`, `pre-restore`, … */
8
+ label: string;
9
+ sdodsVersion: string | null;
10
+ tables: Array<{
11
+ table: string;
12
+ rows: number;
13
+ }>;
14
+ }
15
+ export interface BackupInfo extends BackupManifest {
16
+ dir: string;
17
+ bytes: number;
18
+ rows: number;
19
+ }
20
+ export declare function isBackupId(id: string): boolean;
21
+ /** Export every table to `<root>/<id>/` (JSONL, the same format as `sdods db export`) + manifest. */
22
+ export declare function createBackup(adb: SdodsDb, root: string, opts?: {
23
+ label?: string;
24
+ sdodsVersion?: string | null;
25
+ }): Promise<BackupInfo>;
26
+ /** Backups under `root`, newest first. Folders without a readable manifest are ignored. */
27
+ export declare function listBackups(root: string): BackupInfo[];
28
+ export declare function getBackup(root: string, id: string): BackupInfo | null;
29
+ export declare function deleteBackup(root: string, id: string): boolean;
30
+ /**
31
+ * Replace the database contents with a backup (or any `sdods db export` folder). Takes a
32
+ * `pre-restore` backup of the current data into `safetyRoot` first, then empties every platform
33
+ * and td_* table and imports the backup. Migrations must already be at the latest version.
34
+ */
35
+ export declare function restoreBackup(adb: SdodsDb, dir: string, opts?: {
36
+ safetyRoot?: string;
37
+ sdodsVersion?: string | null;
38
+ }): Promise<{
39
+ safety: BackupInfo | null;
40
+ imported: ImportResult;
41
+ }>;
42
+ //# sourceMappingURL=backup.d.ts.map
@@ -0,0 +1,97 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { join, resolve } from 'node:path';
3
+ import { dirSize } from '../repos/retention.js';
4
+ import { TABLES_IN_FK_ORDER } from '../schema.js';
5
+ import { listTdTables } from '../test-data.js';
6
+ import { exportAll } from './export.js';
7
+ import { importAll } from './import.js';
8
+ /** Backup ids are timestamps (`2026-10-14T21-03-22-123Z`), so they sort and are safe in a path. */
9
+ const ID_RE = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z(?:-[a-z0-9-]{1,32})?$/;
10
+ export function isBackupId(id) {
11
+ return ID_RE.test(id);
12
+ }
13
+ /** Export every table to `<root>/<id>/` (JSONL, the same format as `sdods db export`) + manifest. */
14
+ export async function createBackup(adb, root, opts = {}) {
15
+ const createdAt = new Date().toISOString();
16
+ const id = createdAt.replace(/[:.]/g, '-');
17
+ const dir = join(resolve(root), id);
18
+ mkdirSync(dir, { recursive: true });
19
+ const res = await exportAll(adb, dir);
20
+ const manifest = {
21
+ id,
22
+ createdAt,
23
+ driver: adb.driver,
24
+ label: opts.label ?? 'manual',
25
+ sdodsVersion: opts.sdodsVersion ?? null,
26
+ tables: res.tables.map((t) => ({ table: t.table, rows: t.rows })),
27
+ };
28
+ writeFileSync(join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
29
+ return toInfo(dir, manifest);
30
+ }
31
+ /** Backups under `root`, newest first. Folders without a readable manifest are ignored. */
32
+ export function listBackups(root) {
33
+ const base = resolve(root);
34
+ if (!existsSync(base))
35
+ return [];
36
+ const out = [];
37
+ for (const name of readdirSync(base)) {
38
+ if (!isBackupId(name))
39
+ continue;
40
+ const manifest = readManifest(join(base, name));
41
+ if (manifest)
42
+ out.push(toInfo(join(base, name), manifest));
43
+ }
44
+ return out.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
45
+ }
46
+ export function getBackup(root, id) {
47
+ if (!isBackupId(id))
48
+ return null;
49
+ const dir = join(resolve(root), id);
50
+ const manifest = readManifest(dir);
51
+ return manifest ? toInfo(dir, manifest) : null;
52
+ }
53
+ export function deleteBackup(root, id) {
54
+ const b = getBackup(root, id);
55
+ if (!b)
56
+ return false;
57
+ rmSync(b.dir, { recursive: true, force: true });
58
+ return true;
59
+ }
60
+ /**
61
+ * Replace the database contents with a backup (or any `sdods db export` folder). Takes a
62
+ * `pre-restore` backup of the current data into `safetyRoot` first, then empties every platform
63
+ * and td_* table and imports the backup. Migrations must already be at the latest version.
64
+ */
65
+ export async function restoreBackup(adb, dir, opts = {}) {
66
+ const src = resolve(dir);
67
+ if (!existsSync(src) || !readdirSync(src).some((f) => f.endsWith('.jsonl')))
68
+ throw new Error(`${src} is not a backup: no <table>.jsonl files in it.`);
69
+ const safety = opts.safetyRoot
70
+ ? await createBackup(adb, opts.safetyRoot, {
71
+ label: 'pre-restore',
72
+ sdodsVersion: opts.sdodsVersion,
73
+ })
74
+ : null;
75
+ const td = await listTdTables(adb.db, adb.driver);
76
+ await adb.db.transaction().execute(async (trx) => {
77
+ for (const t of [...td, ...[...TABLES_IN_FK_ORDER].reverse()])
78
+ await trx.deleteFrom(t).execute();
79
+ });
80
+ const imported = await importAll(adb, src);
81
+ return { safety, imported };
82
+ }
83
+ function readManifest(dir) {
84
+ const file = join(dir, 'manifest.json');
85
+ if (!existsSync(file))
86
+ return null;
87
+ try {
88
+ return JSON.parse(readFileSync(file, 'utf8'));
89
+ }
90
+ catch {
91
+ return null;
92
+ }
93
+ }
94
+ function toInfo(dir, m) {
95
+ return { ...m, dir, bytes: dirSize(dir), rows: m.tables.reduce((n, t) => n + t.rows, 0) };
96
+ }
97
+ //# sourceMappingURL=backup.js.map
@@ -2,4 +2,5 @@ export * from './export.js';
2
2
  export * from './import.js';
3
3
  export * from './switch.js';
4
4
  export * from './prune.js';
5
+ export * from './backup.js';
5
6
  //# sourceMappingURL=index.d.ts.map
@@ -2,4 +2,5 @@ export * from './export.js';
2
2
  export * from './import.js';
3
3
  export * from './switch.js';
4
4
  export * from './prune.js';
5
+ export * from './backup.js';
5
6
  //# sourceMappingURL=index.js.map
@@ -1,10 +1,14 @@
1
1
  import type { SdodsDb } from '../create-db.js';
2
+ import { type DeleteRunsOptions } from '../repos/retention.js';
2
3
  export interface PruneOptions {
3
4
  keepRuns?: number;
4
5
  keepDays?: number;
5
6
  projectSlug?: string;
6
7
  artifactsRoot?: string;
7
8
  dryRun?: boolean;
9
+ /** Recorded in the audit log (`prune` by default; the scheduler passes `retention`). */
10
+ reason?: string;
11
+ actor?: DeleteRunsOptions['actor'];
8
12
  }
9
13
  export interface PruneResult {
10
14
  deleted: Array<{
@@ -13,7 +17,17 @@ export interface PruneResult {
13
17
  createdAt: string;
14
18
  }>;
15
19
  kept: number;
20
+ /** Runs that matched but were left alone because they are still queued or running. */
21
+ skipped: Array<{
22
+ id: string;
23
+ reason: string;
24
+ }>;
25
+ freedBytes: number;
16
26
  }
17
- /** Delete old runs (and their artifact directories) beyond `keepRuns` per project or older than `keepDays`. */
27
+ /**
28
+ * Delete old runs (and their artifact directories) beyond `keepRuns` per project or older than
29
+ * `keepDays`. Live runs are never deleted. Goes through {@link deleteRuns}, so it is
30
+ * transactional, audited and clears references to the deleted runs.
31
+ */
18
32
  export declare function prune(adb: SdodsDb, opts: PruneOptions): Promise<PruneResult>;
19
33
  //# sourceMappingURL=prune.d.ts.map