@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/.tsbuildinfo +1 -1
- package/dist/driver.d.ts +2 -0
- package/dist/driver.js +6 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/migrations/0012_platform_settings.d.ts +10 -0
- package/dist/migrations/0012_platform_settings.js +16 -0
- package/dist/migrations/index.js +2 -0
- package/dist/repos/index.d.ts +2 -0
- package/dist/repos/index.js +2 -0
- package/dist/repos/retention.d.ts +49 -0
- package/dist/repos/retention.js +134 -0
- package/dist/repos/settings.d.ts +18 -0
- package/dist/repos/settings.js +43 -0
- package/dist/schema.d.ts +8 -0
- package/dist/schema.js +1 -0
- package/dist/storage.d.ts +23 -0
- package/dist/storage.js +76 -0
- package/dist/switch/backup.d.ts +42 -0
- package/dist/switch/backup.js +97 -0
- package/dist/switch/index.d.ts +1 -0
- package/dist/switch/index.js +1 -0
- package/dist/switch/prune.d.ts +15 -1
- package/dist/switch/prune.js +18 -21
- package/package.json +2 -2
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
|
package/dist/migrations/index.js
CHANGED
|
@@ -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
|
package/dist/repos/index.d.ts
CHANGED
package/dist/repos/index.js
CHANGED
|
@@ -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
|
@@ -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
|
package/dist/storage.js
ADDED
|
@@ -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
|
package/dist/switch/index.d.ts
CHANGED
package/dist/switch/index.js
CHANGED
package/dist/switch/prune.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|