feat(migration): add safe legacy import preview

This commit is contained in:
chick
2026-07-16 13:51:11 +08:00
parent ef9a634a02
commit 8a307781c6
4 changed files with 693 additions and 1 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
"type": "module", "type": "module",
"exports": "./src/index.ts", "exports": "./src/index.ts",
"scripts": { "scripts": {
"test": "vitest run --root ../.. apps/api/src/app.test.ts apps/api/src/start.test.ts apps/api/src/infrastructure/database/database.test.ts", "test": "vitest run --root ../.. apps/api/src/app.test.ts apps/api/src/start.test.ts apps/api/src/infrastructure/database/database.test.ts apps/api/src/application/legacy-import/legacy-import.test.ts",
"typecheck": "tsc -p tsconfig.json" "typecheck": "tsc -p tsconfig.json"
}, },
"dependencies": { "dependencies": {
@@ -0,0 +1,288 @@
import Database from 'better-sqlite3';
import { lstat, mkdtemp, readFile, rename, symlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
import {
confirmLegacyImport,
previewLegacyImport,
type LegacyImportPreview,
} from './legacy-import.js';
const databases: Database.Database[] = [];
afterEach(() => {
for (const database of databases.splice(0)) database.close();
});
function database(): Database.Database {
const result = new Database(':memory:');
result.pragma('foreign_keys = ON');
migrateDatabase(result);
databases.push(result);
return result;
}
async function legacyFile(value: unknown): Promise<string> {
const directory = await mkdtemp(join(tmpdir(), 'legacy-import-'));
const path = join(directory, 'legacy.json');
await writeFile(path, JSON.stringify(value), { mode: 0o600 });
return path;
}
const password = 'phase-two-super-secret-value';
const valid = {
server: { host: '0.0.0.0', port: 8788 },
instances: [
{
id: 'alpha',
name: 'Alpha',
url: 'https://203.0.113.8/path?discard=yes#fragment',
description: 'primary',
tags: ['prod', 'blue'],
auth: { mode: 'password', password },
capabilities: ['status.read', 'sim.restart'],
},
{ id: 'beta', url: 'http://[2001:4860:4860::8888]:8080', auth: { mode: 'none' } },
],
};
function scan(value: unknown): string {
if (value instanceof Error) return JSON.stringify({ name: value.name, message: value.message });
return JSON.stringify(value);
}
function scanDatabaseText(database: Database.Database): string {
const tables = database
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'")
.all() as { name: string }[];
const values: unknown[] = [];
for (const { name } of tables) {
const quotedTable = `"${name.replaceAll('"', '""')}"`;
const columns = database.prepare(`PRAGMA table_info(${quotedTable})`).all() as {
name: string;
type: string;
}[];
const textColumns = columns.filter(({ type }) => type.toUpperCase().includes('TEXT'));
if (textColumns.length === 0) continue;
const selection = textColumns
.map(({ name: column }) => `"${column.replaceAll('"', '""')}"`)
.join(',');
values.push(...database.prepare(`SELECT ${selection} FROM ${quotedTable}`).all());
}
return JSON.stringify(values);
}
async function rejection(action: () => Promise<unknown>): Promise<Error> {
try {
await action();
} catch (error) {
return error as Error;
}
throw new Error('expected rejection');
}
describe('legacy import', () => {
it('previews a deeply frozen, redacted, digest-bound plan without writing the database', async () => {
const path = await legacyFile(valid);
const db = database();
const before = db.serialize();
const preview = await previewLegacyImport(path, db);
expect(preview.counts).toEqual({
total: 2,
planned: 2,
conflict: 0,
unchanged: 0,
secretPending: 1,
});
expect(preview.instances[0]).toEqual({
id: 'alpha',
name: 'Alpha',
baseUrl: 'https://203.0.113.8/path',
description: 'primary',
tags: ['prod', 'blue'],
capabilities: ['status.read', 'sim.restart'],
credentialPresent: true,
status: 'planned',
});
expect(preview.source.digest).toMatch(/^[a-f0-9]{64}$/);
expect(preview.planDigest).toMatch(/^[a-f0-9]{64}$/);
expect(preview.source.identity).toMatchObject({
device: expect.any(String),
inode: expect.any(String),
});
expect(Object.isFrozen(preview)).toBe(true);
expect(Object.isFrozen(preview.instances[0]?.tags)).toBe(true);
expect(scan(preview)).not.toContain(password);
expect(db.serialize()).toEqual(before);
});
it.each([
['bad JSON', '{'],
['array root', '[]'],
['missing instances', '{}'],
['instances object', '{"instances":{}}'],
[
'dangerous key',
'{"instances":[{"id":"x","url":"https://203.0.113.1","__proto__":{"polluted":true}}]}',
],
[
'duplicate id',
'{"instances":[{"id":"x","url":"https://203.0.113.1"},{"id":"x","url":"https://203.0.113.2"}]}',
],
['credentials in URL', '{"instances":[{"id":"x","url":"https://u:p@203.0.113.1"}]}'],
['hostname', '{"instances":[{"id":"x","url":"https://example.com"}]}'],
['loopback', '{"instances":[{"id":"x","url":"http://127.0.0.1"}]}'],
['malformed member', '{"instances":[{"id":"ok","url":"https://203.0.113.1"},null]}'],
])('rejects %s as an all-or-nothing malformed preview', async (_name, contents) => {
const path = await legacyFile({});
await writeFile(path, contents);
const error = await rejection(() => previewLegacyImport(path));
expect(scan(error)).not.toContain(password);
});
it('rejects symlinks and non-regular files', async () => {
const path = await legacyFile(valid);
const link = `${path}.link`;
await symlink(path, link);
await expect(previewLegacyImport(link)).rejects.toThrow(/regular|symlink/i);
const details = await lstat(link);
expect(details.isSymbolicLink()).toBe(true);
await expect(previewLegacyImport(join(path, 'child'))).rejects.toThrow();
});
it('rejects oversized source files before parsing without echoing their content', async () => {
const path = await legacyFile({});
const marker = 'oversized-secret-marker';
await writeFile(path, JSON.stringify({ instances: [], padding: marker.repeat(100_000) }));
const error = await rejection(() => previewLegacyImport(path));
expect(error.message).toMatch(/large|size|limit/i);
expect(scan(error)).not.toContain(marker);
});
it('imports atomically, ignores legacy server, records pending secrets without storing credentials, and is idempotent', async () => {
const path = await legacyFile(valid);
const db = database();
const preview = await previewLegacyImport(path, db);
const result = await confirmLegacyImport(path, preview, db);
expect(result).toMatchObject({ imported: 2, unchanged: 0, secretPending: ['alpha'] });
expect(scan(result)).not.toContain(password);
expect(
db
.prepare(
'SELECT id,name,base_url,auth_mode,enabled,config_revision FROM instances ORDER BY id',
)
.all(),
).toEqual([
{
id: 'alpha',
name: 'Alpha',
base_url: 'https://203.0.113.8/path',
auth_mode: 'none',
enabled: 1,
config_revision: 1,
},
{
id: 'beta',
name: 'beta',
base_url: 'http://[2001:4860:4860::8888]:8080',
auth_mode: 'none',
enabled: 1,
config_revision: 1,
},
]);
expect(
db.prepare('SELECT instance_id,tag FROM instance_tags ORDER BY instance_id,tag').all(),
).toEqual([
{ instance_id: 'alpha', tag: 'blue' },
{ instance_id: 'alpha', tag: 'prod' },
]);
expect(db.prepare('SELECT COUNT(*) AS count FROM secret_references').get()).toEqual({
count: 0,
});
expect(
db
.prepare("SELECT COUNT(*) AS count FROM app_settings WHERE key LIKE 'legacy-import.%'")
.get(),
).toEqual({ count: 2 });
expect(scanDatabaseText(db)).not.toContain(password);
const secondPreview = await previewLegacyImport(path, db);
expect(secondPreview.counts).toMatchObject({ planned: 0, unchanged: 2, conflict: 0 });
const second = await confirmLegacyImport(path, secondPreview, db);
expect(second).toMatchObject({ imported: 0, unchanged: 2, secretPending: ['alpha'] });
expect(db.prepare('SELECT id,config_revision FROM instances ORDER BY id').all()).toEqual([
{ id: 'alpha', config_revision: 1 },
{ id: 'beta', config_revision: 1 },
]);
});
it('binds confirmation to source identity, source digest, and plan digest', async () => {
const path = await legacyFile(valid);
const db = database();
const preview = await previewLegacyImport(path, db);
await writeFile(path, JSON.stringify({ instances: [] }));
await expect(confirmLegacyImport(path, preview, db)).rejects.toThrow(
/changed|digest|identity/i,
);
expect(db.prepare('SELECT COUNT(*) AS count FROM instances').get()).toEqual({ count: 0 });
const fresh = await legacyFile(valid);
const good = await previewLegacyImport(fresh, db);
const replacement = `${fresh}.new`;
await writeFile(replacement, await readFile(fresh));
await rename(replacement, fresh);
const replacementPreview = await previewLegacyImport(fresh, db);
await expect(confirmLegacyImport(fresh, good, db)).rejects.toThrow(/changed|identity/i);
const tampered = { ...replacementPreview, planDigest: '0'.repeat(64) } as LegacyImportPreview;
await expect(confirmLegacyImport(fresh, tampered, db)).rejects.toThrow(/digest/i);
});
it('reports conflicts and confirmation rejects the entire transaction', async () => {
const path = await legacyFile(valid);
const db = database();
const now = new Date().toISOString();
db.prepare('INSERT INTO instances VALUES (?,?,?,?,?,?,?,?)').run(
'alpha',
'Different',
'https://203.0.113.99',
'none',
1,
7,
now,
now,
);
db.prepare('INSERT INTO instances VALUES (?,?,?,?,?,?,?,?)').run(
'owner',
'Owner',
'http://[2001:4860:4860::8888]:8080',
'none',
1,
1,
now,
now,
);
const preview = await previewLegacyImport(path, db);
expect(preview.instances.map((item) => item.status)).toEqual(['conflict', 'conflict']);
expect(preview.counts.conflict).toBe(2);
await expect(confirmLegacyImport(path, preview, db)).rejects.toThrow(/conflict/i);
expect(db.prepare('SELECT COUNT(*) AS count FROM instances').get()).toEqual({ count: 2 });
});
it('rolls back all writes on any database failure', async () => {
const path = await legacyFile(valid);
const db = database();
db.exec(
"CREATE TRIGGER fail_beta BEFORE INSERT ON instances WHEN NEW.id = 'beta' BEGIN SELECT RAISE(ABORT, 'forced'); END",
);
const preview = await previewLegacyImport(path, db);
await expect(confirmLegacyImport(path, preview, db)).rejects.toThrow(/forced/);
expect(db.prepare('SELECT COUNT(*) AS count FROM instances').get()).toEqual({ count: 0 });
expect(db.prepare('SELECT COUNT(*) AS count FROM app_settings').get()).toEqual({ count: 0 });
});
});
@@ -0,0 +1,393 @@
import type Database from 'better-sqlite3';
import { createHash } from 'node:crypto';
import { constants } from 'node:fs';
import { lstat, open } from 'node:fs/promises';
import { isIP } from 'node:net';
export type LegacyImportStatus = 'planned' | 'conflict' | 'unchanged';
export interface LegacyImportInstancePreview {
readonly id: string;
readonly name: string;
readonly baseUrl: string;
readonly description: string;
readonly tags: readonly string[];
readonly capabilities: readonly string[];
readonly credentialPresent: boolean;
readonly status: LegacyImportStatus;
}
export interface LegacyImportPreview {
readonly source: {
readonly digest: string;
readonly identity: { readonly device: string; readonly inode: string };
};
readonly planDigest: string;
readonly counts: {
readonly total: number;
readonly planned: number;
readonly conflict: number;
readonly unchanged: number;
readonly secretPending: number;
};
readonly instances: readonly LegacyImportInstancePreview[];
}
export interface LegacyImportResult {
readonly imported: number;
readonly unchanged: number;
readonly secretPending: readonly string[];
readonly sourceDigest: string;
readonly planDigest: string;
}
interface NormalizedInstance extends Omit<LegacyImportInstancePreview, 'status'> {
readonly authMode: 'none' | 'password';
}
interface SourceRead {
readonly bytes: Buffer;
readonly digest: string;
readonly identity: { readonly device: string; readonly inode: string };
}
interface ExistingInstance {
id: string;
name: string;
base_url: string;
auth_mode: string;
}
const dangerousKeys = new Set(['__proto__', 'prototype', 'constructor']);
const MAX_LEGACY_CONFIG_BYTES = 1024 * 1024;
const metadataKey = (id: string) => `legacy-import.instance.${id}`;
const sha256 = (value: Buffer | string): string => createHash('sha256').update(value).digest('hex');
export class LegacyImportError extends Error {
constructor(message: string) {
super(message);
this.name = 'LegacyImportError';
}
}
function deepFreeze<T>(value: T): T {
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
Object.freeze(value);
for (const child of Object.values(value)) deepFreeze(child);
}
return value;
}
function sameIdentity(
left: { dev: number | bigint; ino: number | bigint },
right: { dev: number | bigint; ino: number | bigint },
): boolean {
return String(left.dev) === String(right.dev) && String(left.ino) === String(right.ino);
}
async function securelyRead(path: string): Promise<SourceRead> {
let beforePath;
try {
beforePath = await lstat(path, { bigint: true });
} catch (error) {
throw new LegacyImportError(`Cannot inspect legacy config: ${(error as Error).message}`);
}
if (!beforePath.isFile() || beforePath.isSymbolicLink())
throw new LegacyImportError('Legacy config must be a regular non-symlink file');
if (beforePath.size > BigInt(MAX_LEGACY_CONFIG_BYTES))
throw new LegacyImportError('Legacy config exceeds the size limit');
let handle;
try {
handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
const before = await handle.stat({ bigint: true });
if (!before.isFile() || !sameIdentity(beforePath, before))
throw new LegacyImportError('Legacy config identity changed before read');
if (before.size > BigInt(MAX_LEGACY_CONFIG_BYTES))
throw new LegacyImportError('Legacy config exceeds the size limit');
const bytes = await handle.readFile();
const after = await handle.stat({ bigint: true });
const afterPath = await lstat(path, { bigint: true });
if (
!after.isFile() ||
!afterPath.isFile() ||
afterPath.isSymbolicLink() ||
!sameIdentity(before, after) ||
!sameIdentity(after, afterPath) ||
before.size !== after.size ||
before.mtimeNs !== after.mtimeNs ||
before.ctimeNs !== after.ctimeNs
) {
throw new LegacyImportError('Legacy config changed while it was being read');
}
return {
bytes,
digest: sha256(bytes),
identity: { device: String(after.dev), inode: String(after.ino) },
};
} catch (error) {
if (error instanceof LegacyImportError) throw error;
throw new LegacyImportError(`Cannot safely read legacy config: ${(error as Error).message}`);
} finally {
await handle?.close();
}
}
function parseJson(bytes: Buffer): unknown {
try {
return JSON.parse(bytes.toString('utf8'), (key, value: unknown) => {
if (dangerousKeys.has(key))
throw new LegacyImportError(`Dangerous JSON key is prohibited: ${key}`);
return value;
});
} catch (error) {
if (error instanceof LegacyImportError) throw error;
throw new LegacyImportError(`Legacy config is not valid JSON: ${(error as Error).message}`);
}
}
function normalizeBaseUrl(raw: string): string {
let url: URL;
try {
url = new URL(raw);
} catch {
throw new LegacyImportError('Instance URL is invalid');
}
if (url.protocol !== 'http:' && url.protocol !== 'https:')
throw new LegacyImportError('Instance URL must use http or https');
if (url.username || url.password)
throw new LegacyImportError('Instance URL must not contain credentials');
const hostname = url.hostname.replace(/^\[|\]$/g, '').toLowerCase();
const ipv4 = hostname.split('.').map(Number);
const isIpv4 =
ipv4.length === 4 && ipv4.every((part) => Number.isInteger(part) && part >= 0 && part <= 255);
const prohibitedIpv4 =
isIpv4 &&
(ipv4[0] === 127 ||
ipv4[0] === 0 ||
(ipv4[0] === 169 && ipv4[1] === 254) ||
(ipv4[0] !== undefined && ipv4[0] >= 224));
const prohibitedIpv6 =
hostname === '::' ||
hostname === '::1' ||
/^fe[89ab][0-9a-f]:/.test(hostname) ||
hostname.startsWith('ff') ||
hostname.startsWith('::ffff:') ||
hostname.startsWith('64:ff9b:');
const prohibitedSpecial =
hostname === '100.100.100.200' || hostname === '168.63.129.16' || hostname.startsWith('2002:');
if (isIP(hostname) === 0 || prohibitedIpv4 || prohibitedIpv6 || prohibitedSpecial)
throw new LegacyImportError(
'Instance target must be a permitted IP literal; hostnames and local/metadata addresses are prohibited',
);
url.hash = '';
url.search = '';
return url.toString().replace(/\/$/, '');
}
function uniqueStrings(value: unknown): readonly string[] {
return Array.isArray(value) ? [...new Set(value.map(String))] : [];
}
function normalize(raw: unknown): readonly NormalizedInstance[] {
if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
throw new LegacyImportError('Legacy config root must be an object');
if (
!Object.hasOwn(raw, 'instances') ||
!Array.isArray((raw as { instances?: unknown }).instances)
)
throw new LegacyImportError('Legacy config instances must be an array');
const ids = new Set<string>();
return (raw as { instances: unknown[] }).instances.map((candidate, index) => {
if (candidate === null || typeof candidate !== 'object' || Array.isArray(candidate))
throw new LegacyImportError(`instances[${index}] must be an object`);
const item = candidate as Record<string, unknown>;
const id = String(item.id || '').trim();
if (!id || !/^[a-zA-Z0-9_.-]+$/.test(id))
throw new LegacyImportError(
`instances[${index}].id is required and may only contain letters, numbers, _, -, .`,
);
if (ids.has(id)) throw new LegacyImportError(`Duplicate instance id: ${id}`);
ids.add(id);
const rawUrl = String(item.url || '').trim();
if (!rawUrl) throw new LegacyImportError(`instances[${index}].url is required`);
const auth =
item.auth !== null && typeof item.auth === 'object'
? (item.auth as Record<string, unknown>)
: undefined;
const credential = auth?.password ?? item.password ?? '';
const authMode = credential ? 'password' : String(auth?.mode || 'none');
if (authMode !== 'none' && authMode !== 'password')
throw new LegacyImportError(`instances[${index}].auth.mode must be none or password`);
return {
id,
name: String(item.name || id || `SimAdmin ${index + 1}`).trim(),
baseUrl: normalizeBaseUrl(rawUrl),
description: String(item.description || '').trim(),
tags: uniqueStrings(item.tags),
capabilities: uniqueStrings(item.capabilities),
credentialPresent: Boolean(credential),
authMode,
};
});
}
function metadata(instance: NormalizedInstance): string {
return JSON.stringify({
source: 'legacy-config',
description: instance.description,
capabilities: instance.capabilities,
secretPending: instance.credentialPresent,
requestedAuthMode: instance.authMode,
secretPurpose: instance.credentialPresent ? 'instance-password' : null,
});
}
function statusFor(
database: Database.Database | undefined,
item: NormalizedInstance,
): LegacyImportStatus {
if (!database) return 'planned';
const byId = database
.prepare('SELECT id,name,base_url,auth_mode FROM instances WHERE id = ?')
.get(item.id) as ExistingInstance | undefined;
const byUrl = database
.prepare('SELECT id FROM instances WHERE base_url = ?')
.get(item.baseUrl) as { id: string } | undefined;
if (!byId) return byUrl ? 'conflict' : 'planned';
if (byUrl && byUrl.id !== item.id) return 'conflict';
const tags = (
database
.prepare('SELECT tag FROM instance_tags WHERE instance_id = ? ORDER BY tag')
.all(item.id) as {
tag: string;
}[]
).map(({ tag }) => tag);
const expectedTags = [...item.tags].sort();
const savedMetadata = database
.prepare('SELECT value_json FROM app_settings WHERE key = ?')
.get(metadataKey(item.id)) as { value_json: string } | undefined;
return byId.name === item.name &&
byId.base_url === item.baseUrl &&
byId.auth_mode === (item.credentialPresent ? 'none' : item.authMode) &&
JSON.stringify(tags) === JSON.stringify(expectedTags) &&
savedMetadata?.value_json === metadata(item)
? 'unchanged'
: 'conflict';
}
function digestPlan(source: SourceRead, instances: readonly LegacyImportInstancePreview[]): string {
const normalizedPlan = instances.map((instance) => ({
id: instance.id,
name: instance.name,
baseUrl: instance.baseUrl,
description: instance.description,
tags: instance.tags,
capabilities: instance.capabilities,
credentialPresent: instance.credentialPresent,
}));
return sha256(
JSON.stringify({
sourceDigest: source.digest,
sourceIdentity: source.identity,
instances: normalizedPlan,
}),
);
}
export async function previewLegacyImport(
path: string,
database?: Database.Database,
): Promise<LegacyImportPreview> {
const source = await securelyRead(path);
const normalized = normalize(parseJson(source.bytes));
const instances = normalized.map(({ authMode: _authMode, ...item }) => ({
...item,
status: statusFor(database, { ...item, authMode: _authMode }),
}));
const counts = {
total: instances.length,
planned: instances.filter(({ status }) => status === 'planned').length,
conflict: instances.filter(({ status }) => status === 'conflict').length,
unchanged: instances.filter(({ status }) => status === 'unchanged').length,
secretPending: instances.filter(({ credentialPresent }) => credentialPresent).length,
};
return deepFreeze({
source: { digest: source.digest, identity: source.identity },
planDigest: digestPlan(source, instances),
counts,
instances,
});
}
function validateConfirmation(preview: LegacyImportPreview, current: LegacyImportPreview): void {
if (
!preview ||
preview.source?.digest !== current.source.digest ||
preview.source?.identity?.device !== current.source.identity.device ||
preview.source?.identity?.inode !== current.source.identity.inode
)
throw new LegacyImportError('Legacy config source changed since preview');
if (preview.planDigest !== current.planDigest)
throw new LegacyImportError('Legacy import plan digest mismatch');
if (current.counts.conflict > 0)
throw new LegacyImportError('Legacy import plan contains database conflicts');
}
export async function confirmLegacyImport(
path: string,
preview: LegacyImportPreview,
database: Database.Database,
): Promise<LegacyImportResult> {
const current = await previewLegacyImport(path, database);
validateConfirmation(preview, current);
// Re-read independently inside confirmation: data used for writes is never taken from the caller preview.
const source = await securelyRead(path);
if (
source.digest !== current.source.digest ||
source.identity.device !== current.source.identity.device ||
source.identity.inode !== current.source.identity.inode
)
throw new LegacyImportError('Legacy config changed during confirmation');
const normalized = normalize(parseJson(source.bytes));
const now = new Date().toISOString();
const plannedIds = new Set(
current.instances.filter(({ status }) => status === 'planned').map(({ id }) => id),
);
database.transaction(() => {
const insertInstance = database.prepare(
'INSERT INTO instances (id,name,base_url,auth_mode,enabled,config_revision,created_at,updated_at) VALUES (?,?,?,?,1,1,?,?)',
);
const insertTag = database.prepare(
'INSERT INTO instance_tags (instance_id,tag,created_at) VALUES (?,?,?)',
);
const insertMetadata = database.prepare(
'INSERT INTO app_settings (key,value_json,created_at,updated_at) VALUES (?,?,?,?)',
);
for (const item of normalized) {
if (!plannedIds.has(item.id)) continue;
insertInstance.run(
item.id,
item.name,
item.baseUrl,
item.credentialPresent ? 'none' : item.authMode,
now,
now,
);
for (const tag of item.tags) insertTag.run(item.id, tag, now);
insertMetadata.run(metadataKey(item.id), metadata(item), now, now);
}
})();
return deepFreeze({
imported: current.counts.planned,
unchanged: current.counts.unchanged,
secretPending: normalized
.filter(({ credentialPresent }) => credentialPresent)
.map(({ id }) => id),
sourceDigest: current.source.digest,
planDigest: current.planDigest,
});
}
+11
View File
@@ -10,5 +10,16 @@ export { startApi } from './start.js';
export type { StartApiOptions } from './start.js'; export type { StartApiOptions } from './start.js';
export { backupDatabase, restoreDatabase } from './infrastructure/database/backup.js'; export { backupDatabase, restoreDatabase } from './infrastructure/database/backup.js';
export { openDatabase } from './infrastructure/database/database.js'; export { openDatabase } from './infrastructure/database/database.js';
export {
LegacyImportError,
confirmLegacyImport,
previewLegacyImport,
} from './application/legacy-import/legacy-import.js';
export type {
LegacyImportInstancePreview,
LegacyImportPreview,
LegacyImportResult,
LegacyImportStatus,
} from './application/legacy-import/legacy-import.js';
export { MIGRATIONS, migrateDatabase } from './infrastructure/database/migrations.js'; export { MIGRATIONS, migrateDatabase } from './infrastructure/database/migrations.js';
export * as databaseSchema from './infrastructure/database/schema.js'; export * as databaseSchema from './infrastructure/database/schema.js';