diff --git a/backlog/tasks/task-238.5 - Split-immersion-tracker-query-layer-into-focused-read-model-modules.md b/backlog/tasks/task-238.5 - Split-immersion-tracker-query-layer-into-focused-read-model-modules.md index 55f63f6..bd9d507 100644 --- a/backlog/tasks/task-238.5 - Split-immersion-tracker-query-layer-into-focused-read-model-modules.md +++ b/backlog/tasks/task-238.5 - Split-immersion-tracker-query-layer-into-focused-read-model-modules.md @@ -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 -- [ ] #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. ## 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`. + +## Implementation Notes + + +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. + + +## Final Summary + + +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. + diff --git a/changes/2026-03-27-stats-server-runtime-fallback.md b/changes/2026-03-27-stats-server-runtime-fallback.md new file mode 100644 index 0000000..7c9ab0a --- /dev/null +++ b/changes/2026-03-27-stats-server-runtime-fallback.md @@ -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. diff --git a/src/core/services/__tests__/stats-server.test.ts b/src/core/services/__tests__/stats-server.test.ts index 7d2e588..471ebe3 100644 --- a/src/core/services/__tests__/stats-server.test.ts +++ b/src/core/services/__tests__/stats-server.test.ts @@ -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; + } + }); }); diff --git a/src/core/services/stats-server.ts b/src/core/services/stats-server.ts index 2e2964c..4cf5a68 100644 --- a/src/core/services/stats-server.ts +++ b/src/core/services/stats-server.ts @@ -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 = { '.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 { + 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 { + 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, + response: Response, +): Promise { + 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 }>; @@ -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).Bun?.serve; + const server = bunServe + ? bunServe({ + fetch: app.fetch, + port: config.port, + hostname: '127.0.0.1', + }) + : startNodeStatsServer(app, config.port); return { close: () => {