Compare commits

...

7 Commits

14 changed files with 226 additions and 49 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
type: added
area: launcher
- Added `subminer sync <host>` to merge immersion stats and watch history between two machines over SSH. Each side snapshots its database (`VACUUM INTO`), snapshots are exchanged with `scp`, and each machine merges the other's data as an insert-only union keyed on session UUIDs / video keys / series title keys, so re-syncing is idempotent and nothing is double-counted. Lifetime totals and daily/monthly rollups are updated incrementally (history older than the session retention window is preserved); remote-only historical rollups are copied only when they do not conflict with retained local session history. `subminer sync --snapshot <file>` and `subminer sync --merge <file>` expose the underlying steps for manual transfers. The command refuses to run while the stats daemon or an mpv session is active (`--force` overrides), keeps that guard in place through local/remote merges, reports remote stderr on failures, and aborts on stats schema version mismatches.
- Added `subminer sync <host>` to merge immersion stats and watch history between two machines over SSH. Each side snapshots its database (`VACUUM INTO`), snapshots are exchanged with `scp`, and each machine merges the other's data as an insert-only union keyed on session UUIDs / video keys / series title keys, so re-syncing is idempotent and nothing is double-counted. Lifetime totals and daily/monthly rollups are updated incrementally (history older than the session retention window is preserved); remote-only historical rollups are copied only when they do not conflict with retained local session history. `subminer sync --snapshot <file>` and `subminer sync --merge <file>` expose the underlying steps for manual transfers. The command refuses to run while the stats daemon or a live mpv session is active (`--force` overrides), ignores stale mpv socket files, keeps the guard in place through local/remote merges, supplies standard SubMiner and Bun paths to non-interactive SSH commands, verifies the remote launcher starts, reports remote stderr on failures, and aborts on stats schema version mismatches.
+1 -1
View File
@@ -90,7 +90,7 @@ subminer sync macbook --remote-cmd ~/bin/subminer # custom remote launcher path
How it works: each side takes a consistent snapshot of its database (`VACUUM INTO`), the snapshots are exchanged over `scp`, and each machine merges the other's snapshot into its own database. The merge is an insert-only union keyed on stable identifiers (session UUIDs, video keys, series title keys, word/kanji identity), so it is safe to re-run at any time — syncing twice changes nothing, and nothing is ever overwritten or summed twice. Lifetime totals and rollup charts are updated incrementally, so history older than the session retention window is preserved on both sides.
Close SubMiner (and stop the background stats daemon, `subminer stats -s`) on both machines before syncing; the command refuses to run while a SubMiner process may be writing the database (`--force` overrides). Both machines must be on the same SubMiner version — the sync aborts on a stats schema mismatch.
Close SubMiner (and stop the background stats daemon, `subminer stats -s`) on both machines before syncing; the command refuses to run while a SubMiner process may be writing the database (`--force` overrides). The mpv safety check requires a live socket connection, so a stale socket file left after mpv exits does not block sync. Both machines must be on the same SubMiner version — the sync aborts on a stats schema mismatch. Remote sync checks standard SubMiner and Bun locations (`~/.local/bin`, `~/.bun/bin`, Homebrew, `/usr/local/bin`, `/usr/bin`, and `/bin`) even when the non-interactive SSH shell omits them from `PATH`.
Two lower-level modes are used internally over SSH and also work standalone for manual transfers (e.g. via a USB drive):
+1 -1
View File
@@ -197,7 +197,7 @@ This flow requires `mpv.exe` to be discoverable. Leave `mpv.executablePath` blan
- `subminer logs -e`: export a sanitized ZIP of today's local-date logs, or the most recent logs when no current-day log exists. The exported copy masks common PII and secrets; on-disk logs are unchanged.
- `subminer config`: config file helpers (`path`, `show`).
- `subminer mpv`: mpv helpers (`status`, `socket`, `idle`).
- `subminer sync <host>`: sync immersion stats and watch history with another machine over SSH. The host is the SSH destination (`user@host` or an SSH config alias). Use `--snapshot <file>` to write a consistent local stats DB snapshot, `--merge <file>` to merge a snapshot into the local stats DB, and `--force` to skip the running stats/mpv safety check. Advanced options: `--db <file>` overrides the local stats DB path, and `--remote-cmd <cmd>` overrides the `subminer` command used on the remote host.
- `subminer sync <host>`: sync immersion stats and watch history with another machine over SSH. The host is the SSH destination (`user@host` or an SSH config alias). Remote launcher checks include standard SubMiner and Bun paths even when SSH omits them from `PATH`. Use `--snapshot <file>` to write a consistent local stats DB snapshot, `--merge <file>` to merge a snapshot into the local stats DB, and `--force` to skip the running stats/mpv safety check. Advanced options: `--db <file>` overrides the local stats DB path, and `--remote-cmd <cmd>` overrides the `subminer` command used on the remote host.
- `subminer dictionary <path>`: generates a Yomitan-importable character dictionary ZIP from a file/directory target.
- Use `subminer dictionary --candidates <path>` and `subminer dictionary --select <id> <path>` to correct AniList character-dictionary matches for a whole series.
- `subminer texthooker`: texthooker-only shortcut (same behavior as `--texthooker`). A _texthooker_ is a web page that displays the current subtitle line as selectable text, so browser-based dictionary extensions and other tools can read along with playback.
@@ -0,0 +1,33 @@
# Remote Sync Runtime PATH Design
## Problem
`subminer sync <host>` can locate a remote launcher while still failing to run
it. The generated launcher uses `#!/usr/bin/env bun`, and non-interactive SSH
shells commonly omit Bun's install directory from `PATH`.
## Design
Remote launcher probes and invocations will use a deterministic `PATH` prefix
covering SubMiner and Bun's supported/common locations:
- `$HOME/.local/bin`
- `$HOME/.bun/bin`
- `/opt/homebrew/bin`
- `/usr/local/bin`
- `/usr/bin`
- `/bin`
Resolution will run `<candidate> --help` under that PATH instead of only using
`command -v`. This confirms both the launcher and its shebang runtime work
before sync creates or transfers snapshots. The same prefixed command returned
by resolution will be used for remote snapshot and merge operations.
`--remote-cmd` remains a launcher path override and receives the same runtime
PATH. Remote commands continue to be shell-quoted where user-controlled.
## Validation
Add focused resolver coverage that simulates an SSH shell where only the
prefixed PATH can start SubMiner. Run launcher unit tests, type checking, docs
tests/build, and the SubMiner launcher/docs verification lanes.
@@ -0,0 +1,31 @@
# SSH Resolver Testability Design
## Problem
The remote launcher resolver test installs a temporary `ssh` executable and
mutates `process.env.PATH` after Bun starts. Bun's synchronous process launcher
does not reliably use that late PATH change, so the focused test fails locally
and in CI even though the command-building behavior is correct.
## Design
Allow `resolveRemoteSubminerCommand` to accept an optional remote runner that
defaults to the production `runSsh` function. Existing callers retain the same
behavior and two-argument API. The focused test will provide a small runner,
exercise the real candidate and PATH construction, and assert the host and
remote command passed across the SSH boundary.
Alternatives rejected:
- Explicitly forwarding `process.env` to `spawnSync` still depends on Bun's
executable-resolution behavior and changes production code to support a test
harness.
- Module-level mocking couples the test to loader behavior and obscures the
resolver's actual dependency.
## Validation
First update the focused test and confirm it remains red while the resolver
ignores the injected runner. Then add the minimal runner parameter and confirm
the focused test passes. Run the launcher verification lane, type checking,
and the repository's default handoff gate.
@@ -0,0 +1,26 @@
# Sync Stale Socket Guard Design
## Problem
The sync quiescence guard treats any configured mpv socket path as a running
session when the filesystem entry exists. Unix socket files can remain after
mpv exits, so a stale `/tmp/subminer-socket` blocks the remote merge even when
nothing is listening.
## Design
Reuse the launcher's existing Unix socket connection check. The sync guard will
block only when it can connect to the configured socket. Missing, stale, or
otherwise unreachable socket paths will not block sync, and sync will not
delete them.
Because socket connection checks are asynchronous, the sync dispatch, merge
mode, host mode, and quiescence checks will become async. The already-async
launcher entrypoint will await sync dispatch. Snapshot, transfer, merge,
cleanup, and error behavior otherwise remain unchanged.
## Validation
Add focused tests proving a stale socket is ignored and a live socket remains a
hard failure. Re-run sync command and socket tests, type checking, launcher
tests, docs checks, and the SubMiner launcher/docs verification lanes.
+38 -13
View File
@@ -6,7 +6,7 @@ import path from 'node:path';
import type { Args } from '../types.js';
import { createEmptyMergeSummary } from '../sync/sync-shared.js';
import type { LauncherCommandContext } from './context.js';
import { runSyncCommand, type SyncCommandDeps } from './sync-command.js';
import { ensureTrackerQuiescent, runSyncCommand, type SyncCommandDeps } from './sync-command.js';
function makeContext(overrides: Partial<Args>): LauncherCommandContext {
return {
@@ -35,7 +35,29 @@ function ok(stdout = ''): { status: number; stdout: string; stderr: string } {
return { status: 0, stdout, stderr: '' };
}
test('runSyncCommand dispatches snapshot, merge, host, and missing-target modes', () => {
test('ensureTrackerQuiescent ignores stale sockets but rejects live sockets', async () => {
const context = makeContext({ syncDbPath: '/tmp/local.sqlite' });
context.mpvSocketPath = '/tmp/subminer-socket';
let socketConnectable = false;
const deps: Partial<SyncCommandDeps> = {
realpathSync: (() => '/tracker.sqlite') as unknown as typeof fs.realpathSync,
findLiveStatsDaemonPid: () => null,
canConnectUnixSocket: async () => socketConnectable,
fail: (message: string): never => {
throw new Error(message);
},
};
await ensureTrackerQuiescent(context, '/tmp/local.sqlite', deps);
socketConnectable = true;
await assert.rejects(
async () => ensureTrackerQuiescent(context, '/tmp/local.sqlite', deps),
/mpv\/SubMiner session appears to be running/,
);
});
test('runSyncCommand dispatches snapshot, merge, host, and missing-target modes', async () => {
const calls: string[] = [];
const deps: Partial<SyncCommandDeps> = {
createDbSnapshot: (dbPath: string, outPath: string) => {
@@ -46,7 +68,7 @@ test('runSyncCommand dispatches snapshot, merge, host, and missing-target modes'
return createEmptyMergeSummary();
},
formatMergeSummary: () => 'summary',
ensureTrackerQuiescent: () => {
ensureTrackerQuiescent: async () => {
calls.push('quiescent');
},
assertSafeSshHost: (host: string) => {
@@ -66,7 +88,7 @@ test('runSyncCommand dispatches snapshot, merge, host, and missing-target modes'
};
assert.equal(
runSyncCommand(
await runSyncCommand(
makeContext({ syncDbPath: '/tmp/local.sqlite', syncSnapshotPath: '/tmp/out.sqlite' }),
deps,
),
@@ -74,23 +96,26 @@ test('runSyncCommand dispatches snapshot, merge, host, and missing-target modes'
);
assert.ok(calls.includes('snapshot:/tmp/local.sqlite->/tmp/out.sqlite'));
runSyncCommand(
await runSyncCommand(
makeContext({ syncDbPath: '/tmp/local.sqlite', syncMergePath: '/tmp/in.sqlite' }),
deps,
);
assert.ok(calls.includes('quiescent'));
assert.ok(calls.includes('merge:/tmp/local.sqlite<-/tmp/in.sqlite'));
runSyncCommand(makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box' }), deps);
await runSyncCommand(
makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box' }),
deps,
);
assert.ok(calls.includes('host:media-box'));
assert.throws(
await assert.rejects(
() => runSyncCommand(makeContext({ syncDbPath: '/tmp/local.sqlite' }), deps),
/sync requires a host, --snapshot <file>, or --merge <file>/,
);
});
test('runHostSync keeps tracker quiescent through local and remote merge and cleans up after failure', () => {
test('runHostSync keeps tracker quiescent through local and remote merge and cleans up after failure', async () => {
const calls: string[] = [];
let localTmpDir = '';
const deps: Partial<SyncCommandDeps> = {
@@ -103,7 +128,7 @@ test('runHostSync keeps tracker quiescent through local and remote merge and cle
return createEmptyMergeSummary();
},
formatMergeSummary: () => 'summary',
ensureTrackerQuiescent: () => {
ensureTrackerQuiescent: async () => {
calls.push('quiescent');
},
assertSafeSshHost: () => {},
@@ -127,7 +152,7 @@ test('runHostSync keeps tracker quiescent through local and remote merge and cle
},
};
assert.throws(
await assert.rejects(
() =>
runSyncCommand(makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box' }), deps),
/Remote merge failed on media-box[\s\S]*remote merge exploded/,
@@ -139,12 +164,12 @@ test('runHostSync keeps tracker quiescent through local and remote merge and cle
assert.equal(fs.existsSync(localTmpDir), false);
});
test('runHostSync includes remote snapshot stderr in failures', () => {
test('runHostSync includes remote snapshot stderr in failures', async () => {
const deps: Partial<SyncCommandDeps> = {
createDbSnapshot: (_dbPath: string, outPath: string) => {
fs.writeFileSync(outPath, 'snapshot');
},
ensureTrackerQuiescent: () => {},
ensureTrackerQuiescent: async () => {},
assertSafeSshHost: () => {},
resolveRemoteSubminerCommand: () => 'subminer',
runSsh: (_host: string, command: string) => {
@@ -157,7 +182,7 @@ test('runHostSync includes remote snapshot stderr in failures', () => {
runScp: () => {},
};
assert.throws(
await assert.rejects(
() =>
runSyncCommand(makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box' }), deps),
/Remote snapshot failed on media-box[\s\S]*snapshot permission denied/,
+20 -19
View File
@@ -17,6 +17,7 @@ import {
shellQuote,
} from '../sync/ssh.js';
import { resolvePathMaybe } from '../util.js';
import { canConnectUnixSocket } from '../mpv.js';
import type { LauncherCommandContext } from './context.js';
import type { RemoteRunResult } from '../sync/ssh.js';
@@ -31,13 +32,13 @@ export interface SyncCommandDeps {
runSsh: typeof runSsh;
fail: typeof fail;
log: typeof log;
existsSync: typeof fs.existsSync;
canConnectUnixSocket: typeof canConnectUnixSocket;
realpathSync: typeof fs.realpathSync;
mkdtempSync: typeof fs.mkdtempSync;
rmSync: typeof fs.rmSync;
consoleLog: typeof console.log;
writeStdout: typeof process.stdout.write;
ensureTrackerQuiescent: (context: LauncherCommandContext, dbPath: string) => void;
ensureTrackerQuiescent: (context: LauncherCommandContext, dbPath: string) => Promise<void>;
}
function resolveDbPath(context: LauncherCommandContext): string {
@@ -54,11 +55,11 @@ function isTrackerDb(dbPath: string, deps: SyncCommandDeps): boolean {
}
}
export function ensureTrackerQuiescent(
export async function ensureTrackerQuiescent(
context: LauncherCommandContext,
dbPath: string,
inputDeps: Partial<SyncCommandDeps> = {},
): void {
): Promise<void> {
const deps = resolveSyncCommandDeps(inputDeps);
if (context.args.syncForce) return;
// A running SubMiner only holds the tracker's own database; --db pointed
@@ -70,7 +71,7 @@ export function ensureTrackerQuiescent(
`The SubMiner stats server is running (pid ${daemonPid}). Stop it with "subminer stats -s" (or close SubMiner) before syncing, or pass --force.`,
);
}
if (context.mpvSocketPath && deps.existsSync(context.mpvSocketPath)) {
if (context.mpvSocketPath && (await deps.canConnectUnixSocket(context.mpvSocketPath))) {
deps.fail(
`An mpv/SubMiner session appears to be running (socket ${context.mpvSocketPath}). Close it before syncing, or pass --force.`,
);
@@ -88,13 +89,13 @@ const defaultSyncCommandDeps: SyncCommandDeps = {
runSsh,
fail,
log,
existsSync: fs.existsSync,
canConnectUnixSocket,
realpathSync: fs.realpathSync,
mkdtempSync: fs.mkdtempSync,
rmSync: fs.rmSync,
consoleLog: console.log,
writeStdout: process.stdout.write.bind(process.stdout),
ensureTrackerQuiescent: (context, dbPath) => ensureTrackerQuiescent(context, dbPath),
ensureTrackerQuiescent: async (context, dbPath) => ensureTrackerQuiescent(context, dbPath),
};
function resolveSyncCommandDeps(inputDeps: Partial<SyncCommandDeps> = {}): SyncCommandDeps {
@@ -112,13 +113,13 @@ export function runSnapshotMode(
deps.consoleLog(outPath);
}
export function runMergeMode(
export async function runMergeMode(
context: LauncherCommandContext,
dbPath: string,
inputDeps: Partial<SyncCommandDeps> = {},
): void {
): Promise<void> {
const deps = resolveSyncCommandDeps(inputDeps);
deps.ensureTrackerQuiescent(context, dbPath);
await deps.ensureTrackerQuiescent(context, dbPath);
const snapshotPath = resolvePathMaybe(context.args.syncMergePath);
const summary = deps.mergeSnapshotIntoDb(dbPath, snapshotPath);
deps.consoleLog(deps.formatMergeSummary(summary));
@@ -134,17 +135,17 @@ function formatRemoteRunError(message: string, run: RemoteRunResult): string {
return stderr ? `${message}\n${stderr}` : message;
}
export function runHostSync(
export async function runHostSync(
context: LauncherCommandContext,
dbPath: string,
inputDeps: Partial<SyncCommandDeps> = {},
): void {
): Promise<void> {
const deps = resolveSyncCommandDeps(inputDeps);
const { args } = context;
const host = args.syncHost;
deps.assertSafeSshHost(host);
deps.ensureTrackerQuiescent(context, dbPath);
await deps.ensureTrackerQuiescent(context, dbPath);
const remoteCmd = deps.resolveRemoteSubminerCommand(host, args.syncRemoteCmd || null);
deps.log('debug', args.logLevel, `Remote subminer command: ${remoteCmd}`);
@@ -183,12 +184,12 @@ export function runHostSync(
deps.runScp(localSnapshot, `${host}:${incomingSnapshot}`);
deps.consoleLog(`\nMerging ${host} -> local:`);
deps.ensureTrackerQuiescent(context, dbPath);
await deps.ensureTrackerQuiescent(context, dbPath);
const summary = deps.mergeSnapshotIntoDb(dbPath, pulledSnapshot);
deps.consoleLog(deps.formatMergeSummary(summary));
deps.consoleLog(`\nMerging local -> ${host}:`);
deps.ensureTrackerQuiescent(context, dbPath);
await deps.ensureTrackerQuiescent(context, dbPath);
const mergeRun = deps.runSsh(
host,
`${remoteCmd} sync --merge ${shellQuote(incomingSnapshot)}${forceFlag}`,
@@ -216,10 +217,10 @@ export function runHostSync(
}
}
export function runSyncCommand(
export async function runSyncCommand(
context: LauncherCommandContext,
inputDeps: Partial<SyncCommandDeps> = {},
): boolean {
): Promise<boolean> {
const deps = resolveSyncCommandDeps(inputDeps);
const { args } = context;
if (!args.sync) return false;
@@ -228,9 +229,9 @@ export function runSyncCommand(
if (args.syncSnapshotPath) {
runSnapshotMode(context, dbPath, deps);
} else if (args.syncMergePath) {
runMergeMode(context, dbPath, deps);
await runMergeMode(context, dbPath, deps);
} else if (args.syncHost) {
runHostSync(context, dbPath, deps);
await runHostSync(context, dbPath, deps);
} else {
deps.fail('sync requires a host, --snapshot <file>, or --merge <file>.');
}
+1 -1
View File
@@ -108,7 +108,7 @@ async function main(): Promise<void> {
return;
}
if (runSyncCommand(context)) {
if (await runSyncCommand(context)) {
return;
}
+1 -1
View File
@@ -1683,7 +1683,7 @@ async function sleepMs(ms: number): Promise<void> {
await new Promise<void>((resolve) => setTimeout(resolve, ms));
}
async function canConnectUnixSocket(socketPath: string): Promise<boolean> {
export async function canConnectUnixSocket(socketPath: string): Promise<boolean> {
return await new Promise<boolean>((resolve) => {
const socket = net.createConnection(socketPath);
let settled = false;
+21 -1
View File
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { assertSafeSshHost, runScp, shellQuote } from './ssh.js';
import { assertSafeSshHost, resolveRemoteSubminerCommand, runScp, shellQuote } from './ssh.js';
test('assertSafeSshHost rejects option-like hosts', () => {
assert.throws(() => assertSafeSshHost('-oProxyCommand=touch pwned'), /looks like an option/);
@@ -29,3 +29,23 @@ test('runScp rejects option-like remote host components', () => {
/SSH host that looks like an option/,
);
});
test('resolveRemoteSubminerCommand verifies the launcher under the remote runtime PATH', () => {
const calls: Array<{ host: string; remoteCommand: string }> = [];
const command = resolveRemoteSubminerCommand('macbook', null, (host, remoteCommand) => {
calls.push({ host, remoteCommand });
return { status: 0, stdout: '', stderr: '' };
});
assert.equal(
command,
'PATH="$HOME/.local/bin:$HOME/.bun/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH" subminer',
);
assert.deepEqual(calls, [
{
host: 'macbook',
remoteCommand:
'PATH="$HOME/.local/bin:$HOME/.bun/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH" subminer --help >/dev/null 2>&1',
},
]);
});
+18 -10
View File
@@ -71,23 +71,31 @@ export function shellQuote(value: string): string {
return `'${value.replaceAll("'", `'\\''`)}'`;
}
const REMOTE_RUNTIME_PATH =
'PATH="$HOME/.local/bin:$HOME/.bun/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH"';
/**
* Non-interactive SSH shells often miss ~/.local/bin in PATH, so probe the
* configured command first and fall back to the default install location.
* Non-interactive SSH shells often miss user-installed launchers and Bun.
* Probe the launcher under the same deterministic PATH used by sync itself.
*/
export function resolveRemoteSubminerCommand(host: string, preferred: string | null): string {
// Trusted defaults stay unquoted so the remote shell expands `~`; the
export function resolveRemoteSubminerCommand(
host: string,
preferred: string | null,
runRemote: typeof runSsh = runSsh,
): string {
// Trusted defaults stay unquoted so the remote shell expands `~`; a
// user-supplied override is shell-quoted to prevent command injection.
const candidates: Array<{ value: string; probe: string }> = preferred
? [{ value: preferred, probe: shellQuote(preferred) }]
const candidates: Array<{ value: string; invocation: string }> = preferred
? [{ value: preferred, invocation: shellQuote(preferred) }]
: [
{ value: 'subminer', probe: 'subminer' },
{ value: '~/.local/bin/subminer', probe: '~/.local/bin/subminer' },
{ value: 'subminer', invocation: 'subminer' },
{ value: '~/.local/bin/subminer', invocation: '~/.local/bin/subminer' },
];
for (const candidate of candidates) {
const probe = runSsh(host, `command -v ${candidate.probe} >/dev/null 2>&1`);
const command = `${REMOTE_RUNTIME_PATH} ${candidate.invocation}`;
const probe = runRemote(host, `${command} --help >/dev/null 2>&1`);
if (probe.status === 0) {
return candidate.value;
return command;
}
}
throw new Error(
@@ -53,6 +53,7 @@ export function insertFixtureSession(dbPath: string, input: FixtureSessionInput)
const db = new Database(dbPath, { readwrite: true });
const stamp = String(input.startedAtMs);
try {
db.run('PRAGMA foreign_keys = ON');
let animeId: number | null = null;
if (input.animeTitleKey) {
const existing = db
@@ -5,7 +5,7 @@ import os from 'node:os';
import path from 'node:path';
import { Database as BunDatabase } from 'bun:sqlite';
import { ensureSchema } from '../../src/core/services/immersion-tracker/storage.js';
import { createImmersionDbFixture } from './immersion-db-fixture.js';
import { createImmersionDbFixture, insertFixtureSession } from './immersion-db-fixture.js';
type SchemaRow = { type: string; name: string; tbl_name: string; sql: string | null };
@@ -107,3 +107,35 @@ test('fixture schema stays aligned with production sync-touched tables and index
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('fixture session inserts enforce foreign keys', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-fixture-foreign-keys-'));
const fixturePath = path.join(dir, 'fixture.sqlite');
try {
createImmersionDbFixture(fixturePath);
const db = new BunDatabase(fixturePath, { readwrite: true });
try {
db.run(`
CREATE TRIGGER remove_fixture_video
BEFORE INSERT ON imm_sessions
BEGIN
DELETE FROM imm_videos WHERE video_id = NEW.video_id;
END
`);
} finally {
db.close();
}
assert.throws(
() =>
insertFixtureSession(fixturePath, {
uuid: 'foreign-key-check',
videoKey: 'foreign-key-check',
startedAtMs: Date.UTC(2026, 6, 9),
}),
/FOREIGN KEY constraint failed/,
);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});