refactor(app): migrate main + preload to TypeScript with typed seams (Phase 5d-3c-ii)

Convert the app's main and preload source from .js to .ts (git mv preserves
history), adding types where they carry value: the core-consumption seam
(BuildIndexOptions/BuildIndexResult, FileInfo), the indexer service/worker
factories, and the preload IPC bridge. Module-to-module specifiers use the
real .ts extension (mirroring scripts/, since Node type-stripping does not
rewrite .js->.ts); the worker's runtime path stays indexer-worker.js because
that is the built output.

Toolchain:
- Add app/tsconfig.json: strict but noImplicitAny:false (the app orchestrates
  the already-strict core; annotating every SQLite-handle helper is low-value
  churn) + allowImportingTsExtensions (safe under noEmit).
- Add @types/better-sqlite3 for the injected binding.
- electron.vite.config.ts inputs -> .ts; refresh the stale CommonJS comment.
- typecheck script runs root + app projects. Root tsconfig excludes the
  app-importing tests (app-*.test.mjs, recap-capture-query.test.mjs) so the
  lenient app files are not dragged into the strict root program; the app
  source is covered by app/tsconfig.json instead. See docs/adr/0005.

Verified: npm run typecheck (root + app) clean; suite 121/121; electron-vite
build emits all 6 main entries + preload with no .ts/node:sqlite residue in
the bundles.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
tommy0103
2026-07-09 16:25:59 +08:00
co-authored by Claude Opus 4.8
parent 60d47a852e
commit 01a390fa10
21 changed files with 299 additions and 151 deletions
+226
View File
@@ -0,0 +1,226 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import chokidarModule from 'chokidar';
const DEFAULT_PROJECTS_DIR = path.join(os.homedir(), '.claude', 'projects');
const DEFAULT_DEBOUNCE_MS = 2000;
const DEFAULT_STABILITY_MS = 500;
const DEFAULT_HEARTBEAT_MS = 30000;
const DEFAULT_WATCH_RETRY_MS = 5000;
type TimerHandle = ReturnType<typeof setTimeout>;
interface Timers {
setTimeout: (fn: () => void, ms?: number) => TimerHandle;
clearTimeout: (handle: TimerHandle) => void;
setInterval?: (fn: () => void, ms?: number) => TimerHandle;
clearInterval?: (handle: TimerHandle) => void;
}
interface Watcher {
close(): unknown;
}
interface IndexerServiceOptions {
projectsDir?: string;
watchDirs?: string | string[];
debounceMs?: number;
stabilityMs?: number;
heartbeatMs?: number;
watchRetryMs?: number;
buildIndex?: (args: { reason?: string; changedPaths?: string[] }) => unknown;
writeHeartbeat?: () => void;
watchProjects?: (onChange: (changedPath: string) => void) => Watcher | null;
chokidar?: any;
timers?: Timers;
logger?: { warn?: (msg: string) => void };
}
function createIndexerService({
projectsDir = DEFAULT_PROJECTS_DIR,
watchDirs = [projectsDir],
debounceMs = DEFAULT_DEBOUNCE_MS,
stabilityMs = DEFAULT_STABILITY_MS,
heartbeatMs = DEFAULT_HEARTBEAT_MS,
watchRetryMs = DEFAULT_WATCH_RETRY_MS,
buildIndex,
writeHeartbeat = () => {},
watchProjects,
chokidar,
timers = {
setTimeout,
clearTimeout,
setInterval,
clearInterval,
},
logger = console,
}: IndexerServiceOptions = {}) {
if (typeof buildIndex !== 'function') throw new Error('createIndexerService() requires buildIndex');
const watch = watchProjects || ((onChange) => {
const roots = [...new Set((Array.isArray(watchDirs) ? watchDirs : [watchDirs]).filter(Boolean))];
const existingRoots = roots.filter(root => fs.existsSync(root));
if (!existingRoots.length) return null;
const watchers: any[] = [];
const onFileChange = (filename) => {
const name = filename ? String(filename) : '';
if (!name || name.endsWith('.jsonl') || name.endsWith('.json')) onChange(name);
};
for (const root of existingRoots) {
const watcher = (chokidar || chokidarModule).watch(root, {
cwd: root,
ignoreInitial: true,
awaitWriteFinish: {
stabilityThreshold: Math.max(stabilityMs, 500),
pollInterval: 100,
},
ignored: (targetPath, stats) => {
if (stats?.isDirectory()) return false;
if (!stats) return false;
return !String(targetPath).endsWith('.jsonl') && !String(targetPath).endsWith('.json');
},
});
watcher
.on('add', onFileChange)
.on('change', onFileChange)
.on('unlink', onFileChange)
.on('error', (error) => {
logger.warn?.(`Obelisk watcher failed: ${(error as Error).message}`);
});
watchers.push(watcher);
}
return {
close() {
return Promise.all(watchers.map(w => Promise.resolve(w.close?.())));
},
};
});
let buildTimer: TimerHandle | null = null;
let stabilityTimer: TimerHandle | null = null;
let heartbeatTimer: TimerHandle | null = null;
let watchRetryTimer: TimerHandle | null = null;
let watcher: Watcher | null = null;
let stopped = false;
let running = false;
let pending = false;
let lastReason: string | null = null;
let changedPaths = new Set<string>();
let idlePromise = Promise.resolve();
const addChangedPath = (changedPath?: string | string[]) => {
if (Array.isArray(changedPath)) {
for (const p of changedPath) addChangedPath(p);
return;
}
const name = changedPath ? String(changedPath) : '';
if (name) changedPaths.add(name);
};
const takeChangedPaths = () => {
if (!changedPaths.size) return undefined;
const paths = [...changedPaths];
changedPaths = new Set();
return paths;
};
const runBuildNow = (reason = "manual", paths: string[] | undefined = undefined) => {
addChangedPath(paths);
if (stopped) return idlePromise;
if (running) {
pending = true;
return idlePromise;
}
running = true;
pending = false;
const buildChangedPaths = takeChangedPaths();
idlePromise = (async () => {
await buildIndex({ reason, changedPaths: buildChangedPaths });
writeHeartbeat();
})()
.catch((error) => {
// A build in flight when the service is stopped (e.g. a manual rebuild
// tears down the worker) is a deliberate cancellation, not a failure.
if (!stopped) logger.warn?.(`Obelisk index build failed: ${(error as Error).message}`);
})
.finally(() => {
running = false;
if (pending && !stopped) {
pending = false;
runBuildNow('pending');
}
});
return idlePromise;
};
const scheduleBuild = (reason = "change", changedPath: string | undefined = undefined) => {
if (stopped) return;
addChangedPath(changedPath);
lastReason = reason;
if (running) pending = true;
if (buildTimer) timers.clearTimeout(buildTimer);
if (stabilityTimer) timers.clearTimeout(stabilityTimer);
buildTimer = timers.setTimeout(() => {
buildTimer = null;
if (stabilityMs <= 0) {
runBuildNow(lastReason || reason);
return;
}
stabilityTimer = timers.setTimeout(() => {
stabilityTimer = null;
runBuildNow(lastReason || reason);
}, stabilityMs);
}, debounceMs);
};
const startWatching = () => {
if (stopped || watcher) return;
watcher = watch((changedPath) => scheduleBuild('watch', changedPath));
if (!watcher) {
watchRetryTimer = timers.setTimeout(() => {
watchRetryTimer = null;
startWatching();
}, watchRetryMs);
}
};
const start = ({ buildOnStart = true } = {}) => {
stopped = false;
if (buildOnStart) scheduleBuild('startup');
startWatching();
if (typeof timers.setInterval === 'function') {
heartbeatTimer = timers.setInterval(() => {
try {
writeHeartbeat();
} catch (error) {
logger.warn?.(`Obelisk heartbeat failed: ${(error as Error).message}`);
}
}, heartbeatMs);
}
};
const stop = () => {
stopped = true;
pending = false;
if (buildTimer) timers.clearTimeout(buildTimer);
buildTimer = null;
if (stabilityTimer) timers.clearTimeout(stabilityTimer);
stabilityTimer = null;
if (watchRetryTimer) timers.clearTimeout(watchRetryTimer);
watchRetryTimer = null;
if (heartbeatTimer && typeof timers.clearInterval === 'function') timers.clearInterval(heartbeatTimer);
heartbeatTimer = null;
if (watcher?.close) watcher.close();
watcher = null;
};
return {
start,
stop,
scheduleBuild,
runBuildNow,
idle: () => idlePromise,
};
}
export { createIndexerService };