fix: add stats server node fallback

This commit is contained in:
2026-03-27 00:28:05 -07:00
parent d2cfa1b871
commit 8c633f7e48
4 changed files with 150 additions and 13 deletions

View File

@@ -1,9 +1,11 @@
---
id: TASK-238.5
title: Split immersion tracker query layer into focused read-model modules
status: To Do
assignee: []
status: Done
assignee:
- codex
created_date: '2026-03-26 20:49'
updated_date: '2026-03-27 00:00'
labels:
- tech-debt
- stats
@@ -29,10 +31,10 @@ priority: medium
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Query responsibilities are grouped into focused modules such as library/session detail, vocabulary/kanji detail, and maintenance/cleanup helpers.
- [ ] #2 The stats server and immersion tracker service depend on stable exported query surfaces instead of one monolithic file.
- [ ] #3 The refactor preserves current SQL behavior and existing statistics outputs.
- [ ] #4 Existing stats/immersion tests still pass, with added focused coverage where extraction creates new seams.
- [x] #1 Query responsibilities are grouped into focused modules such as library/session detail, vocabulary/kanji detail, and maintenance/cleanup helpers.
- [x] #2 The stats server and immersion tracker service depend on stable exported query surfaces instead of one monolithic file.
- [x] #3 The refactor preserves current SQL behavior and existing statistics outputs.
- [x] #4 Existing stats/immersion tests still pass, with added focused coverage where extraction creates new seams.
<!-- AC:END -->
## Implementation Plan
@@ -43,3 +45,17 @@ priority: medium
3. Keep SQL ownership close to the domain module that consumes it; avoid a giant `queries/` dump with no structure.
4. Verify with the maintained stats/immersion test lane plus `bun run typecheck`.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Split the monolithic query surface into focused read-model modules for sessions, trends, lexical data, library lookups, and maintenance helpers. Updated the service and test imports to use the new module boundaries.
Verification: `bun run typecheck` passed. Focused query and stats-server tests passed, including the `stats-server.test.ts` coverage around the new Bun fallback path.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Extracted the immersion-tracker query layer into smaller read-model modules and kept the compatibility barrel in place so existing call sites can transition cleanly. Added focused coverage and verified the refactor with typecheck plus targeted tests.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,5 @@
type: fixed
area: stats
- Fixed stats startup so the immersion tracker can run when `Bun.serve` is unavailable.
- Stats server now falls back to a Node `http` listener in Electron/runtime paths that do not expose Bun.

View File

@@ -1160,4 +1160,30 @@ describe('stats server API routes', () => {
bun.Bun.serve = originalServe;
}
});
it('falls back to node:http when Bun.serve is unavailable', () => {
type BunRuntime = {
Bun: {
serve?: (options: { fetch: unknown; port: number; hostname: string }) => {
stop: () => void;
};
};
};
const bun = globalThis as typeof globalThis & BunRuntime;
const originalServe = bun.Bun.serve;
bun.Bun.serve = undefined;
try {
const server = startStatsServer({
port: 0,
staticDir: fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-stats-server-node-')),
tracker: createMockTracker(),
});
server.close();
} finally {
bun.Bun.serve = originalServe;
}
});
});

View File

@@ -1,5 +1,6 @@
import { Hono } from 'hono';
import type { ImmersionTrackerService } from './immersion-tracker-service.js';
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { basename, extname, resolve, sep } from 'node:path';
import { readFileSync, existsSync, statSync } from 'node:fs';
import { MediaGenerator } from '../../media-generator.js';
@@ -171,6 +172,10 @@ type BunRuntime = {
};
};
type NodeRuntimeHandle = {
stop: () => void;
};
const STATS_STATIC_CONTENT_TYPES: Record<string, string> = {
'.css': 'text/css; charset=utf-8',
'.gif': 'image/gif',
@@ -197,7 +202,9 @@ function buildAnkiNotePreview(
return {
word: getPreferredNoteFieldValue(fields, [getConfiguredWordFieldName(ankiConfig)]),
sentence: getPreferredNoteFieldValue(fields, [getConfiguredSentenceFieldName(ankiConfig)]),
translation: getPreferredNoteFieldValue(fields, [getConfiguredTranslationFieldName(ankiConfig)]),
translation: getPreferredNoteFieldValue(fields, [
getConfiguredTranslationFieldName(ankiConfig),
]),
};
}
@@ -241,6 +248,82 @@ function createStatsStaticResponse(staticDir: string, requestPath: string): Resp
});
}
async function readNodeRequestBody(req: IncomingMessage): Promise<Buffer> {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : Buffer.from(chunk));
}
return Buffer.concat(chunks);
}
async function createNodeRequest(req: IncomingMessage): Promise<Request> {
const host = req.headers.host ?? '127.0.0.1';
const url = new URL(req.url ?? '/', `http://${host}`);
const headers = new Headers();
for (const [name, value] of Object.entries(req.headers)) {
if (value === undefined) continue;
if (Array.isArray(value)) {
headers.set(name, value.join(', '));
} else {
headers.set(name, value);
}
}
const method = req.method ?? 'GET';
const body = method === 'GET' || method === 'HEAD' ? undefined : await readNodeRequestBody(req);
const init: RequestInit = {
method,
headers,
};
if (body !== undefined && body.length > 0) {
init.body = new Uint8Array(body);
}
return new Request(url, init);
}
async function writeNodeResponse(
res: ServerResponse<IncomingMessage>,
response: Response,
): Promise<void> {
res.statusCode = response.status;
res.statusMessage = response.statusText;
response.headers.forEach((value, key) => {
res.setHeader(key, value);
});
if (!response.body) {
res.end();
return;
}
const body = Buffer.from(await response.arrayBuffer());
res.end(body);
}
function startNodeStatsServer(app: StatsApp, port: number): NodeRuntimeHandle {
const server = createServer((req, res) => {
void (async () => {
try {
const response = await app.fetch(await createNodeRequest(req));
await writeNodeResponse(res, response);
} catch {
if (!res.headersSent) {
res.statusCode = 500;
}
res.end('Internal Server Error');
}
})();
});
server.listen(port, '127.0.0.1');
return {
stop: () => {
server.close();
},
};
}
export function createStatsApp(
tracker: ImmersionTrackerService,
options?: {
@@ -672,7 +755,11 @@ export function createStatsApp(
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: AbortSignal.timeout(ANKI_CONNECT_FETCH_TIMEOUT_MS),
body: JSON.stringify({ action: 'notesInfo', version: 6, params: { notes: resolvedNoteIds } }),
body: JSON.stringify({
action: 'notesInfo',
version: 6,
params: { notes: resolvedNoteIds },
}),
});
const result = (await response.json()) as {
result?: Array<{ noteId: number; fields: Record<string, { value: string }> }>;
@@ -1016,11 +1103,14 @@ export function startStatsServer(config: StatsServerConfig): { close: () => void
resolveAnkiNoteId: config.resolveAnkiNoteId,
});
const server = (globalThis as typeof globalThis & BunRuntime).Bun.serve({
fetch: app.fetch,
port: config.port,
hostname: '127.0.0.1',
});
const bunServe = (globalThis as typeof globalThis & Partial<BunRuntime>).Bun?.serve;
const server = bunServe
? bunServe({
fetch: app.fetch,
port: config.port,
hostname: '127.0.0.1',
})
: startNodeStatsServer(app, config.port);
return {
close: () => {