From 95e46b8ccfbd402168718a13ff8540039c37fdb1 Mon Sep 17 00:00:00 2001 From: Ross Miller Date: Mon, 20 Jul 2026 20:15:26 -0500 Subject: [PATCH 001/126] feat: add the TextGeneration port with a fake and contract tests This is the largest port in phase 2 S4. It covers every call this app makes to an AI text model, 9 generateObject call sites and 6 streamText call sites across server/routes/books.ts, server/routes/chat.ts, server/routes/profile.ts, server/routes/covers.ts, and server/services/generation-manager.ts. One of those streamText call sites, the profile interview in server/routes/profile.ts, filters the SDK's fullStream down to text-delta parts after driving a tool-calling loop. It gets its own method, runToolConversation, rather than being forced through plain streamText, because a tool call needs to run a caller-supplied execute function as a side effect, which streamText has no way to express. The signal parameter on every method is cancellation only, never a timeout. The adapter will own the five minute request timeout by combining this signal with its own AbortSignal.timeout, which is why the interface says so explicitly in its header comment. The fake records every request per method so a later service test can assert a prompt contained the profile context, and requires an explicit scripted response for generateObject rather than guessing a default, since there is no type safe placeholder for an arbitrary caller schema. The contract pins that scripted chunks arrive in order, that an aborted signal stops both streaming methods mid stream, and that a scripted tool call invokes that tool's execute with schema validated input without ever surfacing the call itself on the iterable. Claude-Session: https://claude.ai/code/session_01FSkESkJTRpt3Ye2cM7c5n5 --- server/ports/text-generation.contract.ts | 216 ++++++++++++++++++++++ server/ports/text-generation.fake.test.ts | 4 + server/ports/text-generation.fake.ts | 124 +++++++++++++ server/ports/text-generation.ts | 125 +++++++++++++ 4 files changed, 469 insertions(+) create mode 100644 server/ports/text-generation.contract.ts create mode 100644 server/ports/text-generation.fake.test.ts create mode 100644 server/ports/text-generation.fake.ts create mode 100644 server/ports/text-generation.ts diff --git a/server/ports/text-generation.contract.ts b/server/ports/text-generation.contract.ts new file mode 100644 index 0000000..b5b4426 --- /dev/null +++ b/server/ports/text-generation.contract.ts @@ -0,0 +1,216 @@ +import { describe, expect, it } from 'vitest' +import { z } from 'zod' +import type { TextChunk } from './text-generation.js' +import type { FakeTextGeneration } from './text-generation.fake.js' + +/** + * The TextGeneration contract. Fake-only: every subject exercised here is an + * in-memory script, because a real subject would spend money against a live + * provider, so no adapter is ever wired into this contract. + * + * `makeSubject` is typed to the fake's own shape rather than the bare + * `TextGeneration` port, because two of the behaviours this contract must + * pin, that scripted responses arrive in order and that the fake records + * what it was asked, are only observable through the fake's scripting and + * recording surface. There is no plain-port way to express either, and + * because this port never gets a real adapter, there is no future subject + * that would need the narrower type. + */ +export function describeTextGenerationContract( + label: string, + makeSubject: () => FakeTextGeneration | Promise, +): void { + const model = { provider: 'anthropic', model: 'claude-test' } as const + + describe(`TextGeneration contract (${label})`, () => { + describe('streamText', () => { + it('yields the scripted chunks in order', async () => { + const textGen = await makeSubject() + textGen.scriptStreamText(['Once ', 'upon ', 'a time.']) + + const received: string[] = [] + for await (const chunk of textGen.streamText({ model, prompt: 'Tell a story' })) { + received.push(chunk) + } + + expect(received).toEqual(['Once ', 'upon ', 'a time.']) + }) + + it('stops the stream when the signal is aborted mid-stream', async () => { + const textGen = await makeSubject() + textGen.scriptStreamText(['first', 'second', 'third']) + const controller = new AbortController() + + const received: string[] = [] + await expect((async () => { + for await (const chunk of textGen.streamText({ model, prompt: 'x', signal: controller.signal })) { + received.push(chunk) + if (received.length === 1) controller.abort() + } + })()).rejects.toThrow() + + expect(received).toEqual(['first']) + }) + + it('records the request, so a caller can assert what prompt was sent', async () => { + const textGen = await makeSubject() + textGen.scriptStreamText(['ok']) + + for await (const _chunk of textGen.streamText({ + model, + system: 'Be terse.', + prompt: 'Reader profile: likes brevity. Write a chapter.', + })) { /* drain */ } + + expect(textGen.requests.streamText).toHaveLength(1) + expect(textGen.requests.streamText[0].prompt).toContain('likes brevity') + expect(textGen.requests.streamText[0].system).toBe('Be terse.') + }) + }) + + describe('generateObject', () => { + const schema = z.object({ questions: z.array(z.string()) }) + + it('returns a value satisfying the given schema', async () => { + const textGen = await makeSubject() + textGen.scriptGenerateObject({ questions: ['What is 2+2?'] }) + + const result = await textGen.generateObject({ model, schema, prompt: 'Write one quiz question' }) + + expect(schema.safeParse(result).success).toBe(true) + expect(result.questions).toEqual(['What is 2+2?']) + }) + + it('rejects when the scripted value does not satisfy the schema', async () => { + const textGen = await makeSubject() + textGen.scriptGenerateObject({ questions: 'not an array' }) + + await expect(textGen.generateObject({ model, schema, prompt: 'x' })).rejects.toThrow() + }) + + it('records the request, so a caller can assert what prompt was sent', async () => { + const textGen = await makeSubject() + textGen.scriptGenerateObject({ questions: [] }) + + await textGen.generateObject({ model, schema, prompt: 'Reader profile: likes code examples' }) + + expect(textGen.requests.generateObject[0].prompt).toContain('likes code examples') + }) + + it('rejects immediately when the signal is already aborted', async () => { + const textGen = await makeSubject() + textGen.scriptGenerateObject({ questions: [] }) + const controller = new AbortController() + controller.abort() + + await expect( + textGen.generateObject({ model, schema, prompt: 'x', signal: controller.signal }), + ).rejects.toThrow() + }) + }) + + describe('runToolConversation', () => { + it('yields scripted text chunks in order', async () => { + const textGen = await makeSubject() + textGen.scriptToolConversation([ + { type: 'text', text: 'Hello' }, + { type: 'text', text: 'there' }, + ]) + + const received: string[] = [] + for await (const part of textGen.runToolConversation({ model, system: 'sys', messages: [], tools: {}, maxSteps: 2 })) { + received.push(part.text) + } + + expect(received).toEqual(['Hello', 'there']) + }) + + it('invokes the requested tool with schema-validated input, so its side effects run, and never surfaces the call on the iterable', async () => { + const textGen = await makeSubject() + const calls: Array<{ name: string }> = [] + textGen.scriptToolConversation([{ type: 'tool-call', tool: 'save_profile', input: { name: 'Ross' } }]) + + const parts: TextChunk[] = [] + for await (const part of textGen.runToolConversation({ + model, + system: 'sys', + messages: [], + maxSteps: 2, + tools: { + save_profile: { + description: 'Saves the profile', + inputSchema: z.object({ name: z.string() }), + execute: async (input: { name: string }) => { + calls.push({ name: input.name }) + return 'saved' + }, + }, + }, + })) { + parts.push(part) + } + + expect(calls).toEqual([{ name: 'Ross' }]) + expect(parts).toEqual([]) + }) + + it('stops after maxSteps scripted steps even when more are scripted', async () => { + const textGen = await makeSubject() + textGen.scriptToolConversation([ + { type: 'text', text: 'one' }, + { type: 'text', text: 'two' }, + { type: 'text', text: 'three' }, + ]) + + const received: string[] = [] + for await (const part of textGen.runToolConversation({ model, system: 'sys', messages: [], tools: {}, maxSteps: 2 })) { + received.push(part.text) + } + + expect(received).toEqual(['one', 'two']) + }) + + it('stops the stream when the signal is aborted mid-conversation', async () => { + const textGen = await makeSubject() + textGen.scriptToolConversation([ + { type: 'text', text: 'one' }, + { type: 'text', text: 'two' }, + ]) + const controller = new AbortController() + + const received: string[] = [] + await expect((async () => { + for await (const part of textGen.runToolConversation({ + model, + system: 'sys', + messages: [], + tools: {}, + maxSteps: 5, + signal: controller.signal, + })) { + received.push(part.text) + if (received.length === 1) controller.abort() + } + })()).rejects.toThrow() + + expect(received).toEqual(['one']) + }) + + it('records the request, so a caller can assert what the conversation contained', async () => { + const textGen = await makeSubject() + textGen.scriptToolConversation([{ type: 'text', text: 'ok' }]) + + for await (const _part of textGen.runToolConversation({ + model, + system: 'Interview the reader.', + messages: [{ role: 'user', content: 'I like code examples' }], + tools: {}, + maxSteps: 2, + })) { /* drain */ } + + expect(textGen.requests.runToolConversation[0].messages).toEqual([{ role: 'user', content: 'I like code examples' }]) + expect(textGen.requests.runToolConversation[0].system).toBe('Interview the reader.') + }) + }) + }) +} diff --git a/server/ports/text-generation.fake.test.ts b/server/ports/text-generation.fake.test.ts new file mode 100644 index 0000000..f08ed5a --- /dev/null +++ b/server/ports/text-generation.fake.test.ts @@ -0,0 +1,4 @@ +import { describeTextGenerationContract } from './text-generation.contract.js' +import { createFakeTextGeneration } from './text-generation.fake.js' + +describeTextGenerationContract('fake', () => createFakeTextGeneration()) diff --git a/server/ports/text-generation.fake.ts b/server/ports/text-generation.fake.ts new file mode 100644 index 0000000..05c1645 --- /dev/null +++ b/server/ports/text-generation.fake.ts @@ -0,0 +1,124 @@ +import type { + GenerateObjectRequest, + RunToolConversationRequest, + StreamTextRequest, + TextChunk, + TextGeneration, +} from './text-generation.js' + +/** + * In-memory TextGeneration. Every method records the request it received + * and returns a scripted response instead of calling any model, so it never + * touches a network and never costs money. + * + * Responses are queued per method (`scriptStreamText`, `scriptGenerateObject`, + * `scriptToolConversation`): each call consumes the oldest still-queued + * response, and once the queue is empty falls back to a fixed, deterministic + * default. `generateObject` has no such default, because there is no + * type-safe placeholder value for an arbitrary caller-supplied schema — an + * unscripted call fails loudly instead of silently returning a value that + * might not satisfy the caller's schema. + */ + +export type ToolConversationStep = + | { type: 'text'; text: string } + | { type: 'tool-call'; tool: string; input: unknown } + +export interface FakeTextGeneration extends TextGeneration { + /** Every request handed to each method, in call order, so a test can assert what a caller actually sent (e.g. that a prompt contained the profile context). */ + requests: { + streamText: StreamTextRequest[] + generateObject: GenerateObjectRequest[] + runToolConversation: RunToolConversationRequest[] + } + /** Queues the chunks the next streamText() call yields, in order. */ + scriptStreamText(chunks: string[]): void + /** Queues the value the next generateObject() call resolves with, parsed through the caller's schema (so an invalid value rejects, exactly as a real model returning malformed output would). */ + scriptGenerateObject(value: unknown): void + /** Queues the steps the next runToolConversation() call works through, in order. A 'tool-call' step invokes that tool's execute() as a side effect and yields nothing; a 'text' step yields one TextChunk. */ + scriptToolConversation(steps: ToolConversationStep[]): void +} + +const DEFAULT_STREAM_CHUNKS = ['[fake streamed text]'] +const DEFAULT_TOOL_CONVERSATION_STEPS: ToolConversationStep[] = [{ type: 'text', text: '[fake tool conversation response]' }] + +export function createFakeTextGeneration(): FakeTextGeneration { + const requests: FakeTextGeneration['requests'] = { + streamText: [], + generateObject: [], + runToolConversation: [], + } + + const streamQueue: string[][] = [] + const objectQueue: unknown[] = [] + const toolConversationQueue: ToolConversationStep[][] = [] + + function scriptStreamText(chunks: string[]): void { + streamQueue.push(chunks) + } + + function scriptGenerateObject(value: unknown): void { + objectQueue.push(value) + } + + function scriptToolConversation(steps: ToolConversationStep[]): void { + toolConversationQueue.push(steps) + } + + async function* yieldChunks(chunks: string[], signal?: AbortSignal): AsyncGenerator { + for (const chunk of chunks) { + if (signal?.aborted) throw signal.reason + yield chunk + } + } + + function streamText(req: StreamTextRequest): AsyncIterable { + requests.streamText.push(req) + const chunks = streamQueue.shift() ?? DEFAULT_STREAM_CHUNKS + return yieldChunks(chunks, req.signal) + } + + async function generateObject(req: GenerateObjectRequest): Promise { + requests.generateObject.push(req as GenerateObjectRequest) + if (req.signal?.aborted) throw req.signal.reason + if (objectQueue.length === 0) { + throw new Error( + 'createFakeTextGeneration: no scripted generateObject response queued. Call scriptGenerateObject(value) before the code under test invokes generateObject().', + ) + } + const value = objectQueue.shift() + return req.schema.parse(value) + } + + async function* runSteps(steps: ToolConversationStep[], req: RunToolConversationRequest): AsyncGenerator { + for (const step of steps.slice(0, req.maxSteps)) { + if (req.signal?.aborted) throw req.signal.reason + if (step.type === 'text') { + yield { type: 'text', text: step.text } + continue + } + const tool = req.tools[step.tool] + if (!tool) { + throw new Error(`createFakeTextGeneration: scripted tool call names "${step.tool}", which is not in this request's tools.`) + } + const input = tool.inputSchema.parse(step.input) + await tool.execute(input) + } + } + + function runToolConversation(req: RunToolConversationRequest): AsyncIterable { + requests.runToolConversation.push(req) + const steps = toolConversationQueue.shift() ?? DEFAULT_TOOL_CONVERSATION_STEPS + return runSteps(steps, req) + } + + return { + requests, + scriptStreamText, + scriptGenerateObject, + scriptToolConversation, + streamText, + generateObject, + runToolConversation, + } +} diff --git a/server/ports/text-generation.ts b/server/ports/text-generation.ts new file mode 100644 index 0000000..a906bbd --- /dev/null +++ b/server/ports/text-generation.ts @@ -0,0 +1,125 @@ +import type { z } from 'zod' +import type { ProviderId } from '@shared/provider.js' + +/** + * Abstracts every call this app makes to an AI text model: streaming plain + * text token by token, generating a value that satisfies a Zod schema, and + * running a short multi-step tool-calling conversation. + * + * Today those three shapes are each called directly against the Vercel AI + * SDK from five route/service modules: 9 `generateObject` call sites and 6 + * `streamText` call sites (one of which drives tool calling and is really a + * `runToolConversation` call in disguise). This port exists so those call + * sites can depend on a shape instead of the SDK, so `services/model-client.ts` + * and the SDK itself become adapter-only concerns. + * + * `signal` on every method means CANCELLATION ONLY, e.g. a "generate all + * chapters" background task being cancelled. It is never a timeout. The + * adapter owns the five-minute request timeout, by combining this signal + * with its own `AbortSignal.timeout(...)`. Every current call site + * hand-rolls a `createTimeout()`/`AbortController` pair for that timeout + * today; this port is what lets all of those collapse into the adapter. + */ + +/** Which provider and model to call. Validating that the pair is well-formed is the adapter's job. */ +export interface ModelRef { + provider: ProviderId + model: string +} + +/** One turn of a chat-style conversation. */ +export interface ChatMessage { + role: 'user' | 'assistant' + content: string +} + +export interface StreamTextRequest { + model: ModelRef + system?: string + /** + * Single-shot instruction. Every real call site sends either `prompt` or + * `messages`, never both and never neither — the type does not enforce + * that, it just mirrors how the two fields are used today. + */ + prompt?: string + messages?: ChatMessage[] + signal?: AbortSignal +} + +export interface GenerateObjectRequest { + model: ModelRef + schema: z.ZodType + prompt: string + system?: string + /** Extra schema metadata forwarded to the provider call. Only one real call site (the next-book suggestion) sets these. */ + schemaName?: string + schemaDescription?: string + signal?: AbortSignal +} + +/** + * One tool the model may call during {@link TextGeneration.runToolConversation}. + * `execute` receives its input already validated against `inputSchema`. The + * one real caller (the profile interview in `server/routes/profile.ts`) uses + * `execute` purely for its side effect, persisting the learning profile, and + * never reads the resolved value, but the shape describes a tool in + * general, not that one caller. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export interface ToolSpec { + description: string + inputSchema: z.ZodType + execute: (input: TInput) => Promise +} + +export interface RunToolConversationRequest { + model: ModelRef + system: string + messages: ChatMessage[] + tools: Record + /** Upper bound on model round-trips, e.g. one step to call a tool and one more to respond to its result. */ + maxSteps: number + signal?: AbortSignal +} + +/** + * A text delta from {@link TextGeneration.runToolConversation}. The only + * part type a real caller reads today — tool calls and their results are + * side effects of `ToolSpec.execute`, not stream output. + */ +export interface TextChunk { + type: 'text' + text: string +} + +export interface TextGeneration { + /** + * Streams plain-text output. Covers 5 call sites: the table-of-contents + * stream (initial generation and revision) and the chapter-1 stream in + * `server/routes/books.ts`, the chapter stream in + * `server/services/generation-manager.ts`, and the inline reader-chat + * stream in `server/routes/chat.ts`. + */ + streamText(req: StreamTextRequest): AsyncIterable + + /** + * Generates a value satisfying `schema`. Covers 9 call sites: quiz + * generation, skill classification, the final quiz, and profile/next-book/ + * book-detail suggestions in `server/routes/books.ts`; skill suggestions + * in `server/routes/profile.ts`; the cover-prompt suggestion in + * `server/routes/covers.ts`; and quiz generation in + * `server/services/generation-manager.ts`. + */ + generateObject(req: GenerateObjectRequest): Promise + + /** + * Runs a short tool-calling conversation and yields only the model's text + * output. A tool call happens as a side effect of invoking + * `ToolSpec.execute` and is never surfaced on the returned iterable. + * Covers exactly one call site: the profile interview in + * `server/routes/profile.ts`, which today filters the SDK's `fullStream` + * down to `text-delta` parts. This method is that filtered stream, + * pre-filtered. + */ + runToolConversation(req: RunToolConversationRequest): AsyncIterable +} From 4bbda7fe0b82ea99836d4cee7528277d17f16631 Mon Sep 17 00:00:00 2001 From: Ross Miller Date: Mon, 20 Jul 2026 20:15:45 -0500 Subject: [PATCH 002/126] feat: add the KeyVault port with a fake and contract tests Covers server/services/key-store.ts, which mixes two concerns today, an in-memory get, set, remove, has, and status store, plus how that store is populated and persisted. The path and env var fallback are resolved once at module load, and an Electron versus standalone branch decides whether writes touch disk at all. Only the store is this port. The data dir path, the env var fallback, and the Electron branch stay adapter behaviour for the future file-key-vault adapter to own via a factory argument instead of an import time side effect, and the port's header comment says so, so the split does not need rediscovering later. Methods take a ProviderId rather than a raw string, so the provider validation the real module runs on every call moves to the HTTP boundary, where zod already parses untrusted input, instead of living inside the vault. Unlike TextGeneration and ImageGeneration, this port keeps its contract typed to the plain KeyVault interface rather than the fake's shape, because every behaviour it pins is observable through get, set, has, status, and remove alone. That leaves the door open for a later real, filesystem backed adapter to run through the same contract unchanged. Claude-Session: https://claude.ai/code/session_01FSkESkJTRpt3Ye2cM7c5n5 --- server/ports/key-vault.contract.ts | 74 +++++++++++++++++++++++++++++ server/ports/key-vault.fake.test.ts | 4 ++ server/ports/key-vault.fake.ts | 29 +++++++++++ server/ports/key-vault.ts | 31 ++++++++++++ 4 files changed, 138 insertions(+) create mode 100644 server/ports/key-vault.contract.ts create mode 100644 server/ports/key-vault.fake.test.ts create mode 100644 server/ports/key-vault.fake.ts create mode 100644 server/ports/key-vault.ts diff --git a/server/ports/key-vault.contract.ts b/server/ports/key-vault.contract.ts new file mode 100644 index 0000000..f30aa55 --- /dev/null +++ b/server/ports/key-vault.contract.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest' +import { PROVIDERS } from '@shared/provider.js' +import type { KeyVault } from './key-vault.js' + +/** + * The KeyVault contract. Only a fake is wired in during this phase, because + * a real filesystem-backed adapter is later work, but `makeSubject` stays + * typed to the plain `KeyVault` port rather than the fake's shape, since + * every behaviour here (get/set/has/status/remove) is observable through + * the port alone, unlike TextGeneration and ImageGeneration. A future + * `describeKeyVaultContract('real', ...)` against a temp-dir-backed adapter + * can reuse this file unchanged. + */ +export function describeKeyVaultContract(label: string, makeSubject: () => KeyVault | Promise): void { + describe(`KeyVault contract (${label})`, () => { + it('returns null for a provider that was never set', async () => { + const vault = await makeSubject() + expect(vault.get('anthropic')).toBeNull() + }) + + it('round trips a key through set then get', async () => { + const vault = await makeSubject() + vault.set('anthropic', 'sk-test-123') + expect(vault.get('anthropic')).toBe('sk-test-123') + }) + + it('overwrites a key when set again', async () => { + const vault = await makeSubject() + vault.set('openai', 'first') + vault.set('openai', 'second') + expect(vault.get('openai')).toBe('second') + }) + + it('agrees with has() before and after a key is set', async () => { + const vault = await makeSubject() + expect(vault.has('google')).toBe(false) + vault.set('google', 'sk-test') + expect(vault.has('google')).toBe(true) + }) + + it('clears a key on remove, so get and has both revert', async () => { + const vault = await makeSubject() + vault.set('anthropic', 'sk-test') + vault.remove('anthropic') + expect(vault.get('anthropic')).toBeNull() + expect(vault.has('anthropic')).toBe(false) + }) + + it('removing a key that was never set is a no-op', async () => { + const vault = await makeSubject() + expect(() => vault.remove('openai')).not.toThrow() + expect(vault.has('openai')).toBe(false) + }) + + it('status() has an entry for every provider in PROVIDERS, agreeing with has()', async () => { + const vault = await makeSubject() + vault.set('anthropic', 'sk-test') + const status = vault.status() + expect(Object.keys(status).sort()).toEqual([...PROVIDERS].sort()) + for (const provider of PROVIDERS) { + expect(status[provider]).toBe(vault.has(provider)) + } + }) + + it('keeps each provider independent of the others', async () => { + const vault = await makeSubject() + vault.set('anthropic', 'sk-anthropic') + vault.set('openai', 'sk-openai') + vault.remove('anthropic') + expect(vault.get('anthropic')).toBeNull() + expect(vault.get('openai')).toBe('sk-openai') + }) + }) +} diff --git a/server/ports/key-vault.fake.test.ts b/server/ports/key-vault.fake.test.ts new file mode 100644 index 0000000..ae46134 --- /dev/null +++ b/server/ports/key-vault.fake.test.ts @@ -0,0 +1,4 @@ +import { describeKeyVaultContract } from './key-vault.contract.js' +import { createFakeKeyVault } from './key-vault.fake.js' + +describeKeyVaultContract('fake', () => createFakeKeyVault()) diff --git a/server/ports/key-vault.fake.ts b/server/ports/key-vault.fake.ts new file mode 100644 index 0000000..298fb24 --- /dev/null +++ b/server/ports/key-vault.fake.ts @@ -0,0 +1,29 @@ +import { PROVIDERS, type ProviderId } from '@shared/provider.js' +import type { KeyVault } from './key-vault.js' + +/** + * In-memory KeyVault. Holds keys in a Map for the lifetime of the fake and + * never touches disk or the environment, so a test gets a clean vault + * regardless of what is configured on the machine running it. + */ +export function createFakeKeyVault(initialKeys: Partial> = {}): KeyVault { + const keys = new Map(Object.entries(initialKeys) as Array<[ProviderId, string]>) + + return { + get(provider) { + return keys.get(provider) ?? null + }, + set(provider, key) { + keys.set(provider, key) + }, + remove(provider) { + keys.delete(provider) + }, + has(provider) { + return keys.has(provider) + }, + status() { + return Object.fromEntries(PROVIDERS.map(provider => [provider, keys.has(provider)])) as Record + }, + } +} diff --git a/server/ports/key-vault.ts b/server/ports/key-vault.ts new file mode 100644 index 0000000..5f55c25 --- /dev/null +++ b/server/ports/key-vault.ts @@ -0,0 +1,31 @@ +import type { ProviderId } from '@shared/provider.js' + +/** + * Abstracts where AI provider API keys live. `server/services/key-store.ts` + * is the current implementation, and it mixes two concerns: an in-memory + * get/set/remove/has/status store, plus how that store is populated and + * persisted, a data-dir file path resolved once at module load, a + * `{PROVIDER}_API_KEY` environment variable fallback read once at module + * load, and an Electron-vs-standalone branch that decides whether writes + * touch disk at all. + * + * Only the first concern is this port. The file path, the env var fallback, + * and the Electron branch are adapter behaviour, the future + * `adapters/file-key-vault.ts` takes its data directory as a factory + * argument instead of reaching for it at import time. A KeyVault contract + * test proves the get/set/has/status/remove round trip; it says nothing + * about persistence or the env var fallback, because the fake never + * persists anything and never reads the environment. + * + * Methods take a `ProviderId`, not a raw string, so the "is this a provider + * we know about" check the real module performs on every call moves to + * wherever an untrusted string first enters the system (already `zod` + * parsing at the HTTP boundary), rather than living inside the vault. + */ +export interface KeyVault { + get(provider: ProviderId): string | null + set(provider: ProviderId, key: string): void + remove(provider: ProviderId): void + has(provider: ProviderId): boolean + status(): Record +} From 920be3363d4182a90e2cca8628094e1f8e48b9c6 Mon Sep 17 00:00:00 2001 From: Ross Miller Date: Mon, 20 Jul 2026 20:16:03 -0500 Subject: [PATCH 003/126] feat: add the ImageGeneration port with a fake and contract tests Covers the one real call site, the cover generation background task in server/routes/covers.ts, which calls generateImageWithFallback in server/services/image-generation.ts as is. That function calls OpenAI or Google's image endpoint directly over fetch and, on a recoverable failure, retries against the next model in a provider owned fallback chain before giving up, but stops immediately on bad credentials or a content policy rejection since those will not be fixed by trying another model. The port promises only that shape, not the HTTP detail, and there is no apiKey field since the future adapter resolves it from a KeyVault itself. The fake reproduces the same chain walking algorithm without any network call, driven by a test scripting which model and provider pair should fail and why through failNextAttempt. Default fallback chains for openai and google make the fallback path exercisable out of the box, using fake model names so a test can assert generate moved to a different model without asserting a specific real one. Like TextGeneration, the contract is typed to the fake's own shape rather than the bare port, because pinning the fallback path needs failNextAttempt, and this port never gets a real adapter to need the narrower type. Claude-Session: https://claude.ai/code/session_01FSkESkJTRpt3Ye2cM7c5n5 --- server/ports/image-generation.contract.ts | 116 +++++++++++++++++++++ server/ports/image-generation.fake.test.ts | 4 + server/ports/image-generation.fake.ts | 94 +++++++++++++++++ server/ports/image-generation.ts | 48 +++++++++ 4 files changed, 262 insertions(+) create mode 100644 server/ports/image-generation.contract.ts create mode 100644 server/ports/image-generation.fake.test.ts create mode 100644 server/ports/image-generation.fake.ts create mode 100644 server/ports/image-generation.ts diff --git a/server/ports/image-generation.contract.ts b/server/ports/image-generation.contract.ts new file mode 100644 index 0000000..9c11949 --- /dev/null +++ b/server/ports/image-generation.contract.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest' +import type { FakeImageGeneration } from './image-generation.fake.js' + +/** + * The ImageGeneration contract. Fake-only: a real subject would spend money + * against a live provider, so no adapter is ever wired into this contract. + * + * `makeSubject` is typed to the fake's own shape rather than the bare + * `ImageGeneration` port, because pinning the fallback path needs + * `failNextAttempt`, which only the fake exposes, and because this port + * never gets a real adapter there is no future subject that would need the + * narrower type. + */ +export function describeImageGenerationContract( + label: string, + makeSubject: () => FakeImageGeneration | Promise, +): void { + describe(`ImageGeneration contract (${label})`, () => { + it('returns an image for a valid request, produced by the preferred model', async () => { + const imageGen = await makeSubject() + + const image = await imageGen.generate({ + provider: 'openai', + preferredModel: 'gpt-image-1', + prompt: 'a minimal abstract book cover', + signal: new AbortController().signal, + }) + + expect(image.data).toBeInstanceOf(Buffer) + expect(image.data.length).toBeGreaterThan(0) + expect(image.mediaType).toMatch(/^image\//) + expect(image._diag.modelUsed).toBe('gpt-image-1') + expect(image._diag.fellBack).toBe(false) + }) + + it('records the request', async () => { + const imageGen = await makeSubject() + + await imageGen.generate({ + provider: 'google', + preferredModel: 'imagen-test', + prompt: 'a mountain at dawn', + signal: new AbortController().signal, + }) + + expect(imageGen.requests).toHaveLength(1) + expect(imageGen.requests[0].prompt).toBe('a mountain at dawn') + }) + + it('falls back to another model in the chain when the preferred model fails recoverably, and reports fellBack', async () => { + const imageGen = await makeSubject() + imageGen.failNextAttempt('openai', 'gpt-image-1') + + const image = await imageGen.generate({ + provider: 'openai', + preferredModel: 'gpt-image-1', + prompt: 'cover art', + signal: new AbortController().signal, + }) + + expect(image._diag.fellBack).toBe(true) + expect(image._diag.modelUsed).not.toBe('gpt-image-1') + }) + + it('does not retry a fallback model on an auth failure, it rejects immediately', async () => { + const imageGen = await makeSubject() + imageGen.failNextAttempt('openai', 'gpt-image-1', 'auth') + + await expect(imageGen.generate({ + provider: 'openai', + preferredModel: 'gpt-image-1', + prompt: 'cover art', + signal: new AbortController().signal, + })).rejects.toThrow() + }) + + it('does not retry a fallback model on a content-policy failure, it rejects immediately', async () => { + const imageGen = await makeSubject() + imageGen.failNextAttempt('openai', 'gpt-image-1', 'content-policy') + + await expect(imageGen.generate({ + provider: 'openai', + preferredModel: 'gpt-image-1', + prompt: 'cover art', + signal: new AbortController().signal, + })).rejects.toThrow() + }) + + it('rejects when every model in the chain fails', async () => { + const imageGen = await makeSubject() + // anthropic has no configured fallback chain, so failing the + // preferred model exhausts the whole chain in one step. + imageGen.failNextAttempt('anthropic', 'preferred-model') + + await expect(imageGen.generate({ + provider: 'anthropic', + preferredModel: 'preferred-model', + prompt: 'cover art', + signal: new AbortController().signal, + })).rejects.toThrow() + }) + + it('rejects when the signal is already aborted', async () => { + const imageGen = await makeSubject() + const controller = new AbortController() + controller.abort() + + await expect(imageGen.generate({ + provider: 'openai', + preferredModel: 'gpt-image-1', + prompt: 'cover art', + signal: controller.signal, + })).rejects.toThrow() + }) + }) +} diff --git a/server/ports/image-generation.fake.test.ts b/server/ports/image-generation.fake.test.ts new file mode 100644 index 0000000..f4243de --- /dev/null +++ b/server/ports/image-generation.fake.test.ts @@ -0,0 +1,4 @@ +import { describeImageGenerationContract } from './image-generation.contract.js' +import { createFakeImageGeneration } from './image-generation.fake.js' + +describeImageGenerationContract('fake', () => createFakeImageGeneration()) diff --git a/server/ports/image-generation.fake.ts b/server/ports/image-generation.fake.ts new file mode 100644 index 0000000..bc93399 --- /dev/null +++ b/server/ports/image-generation.fake.ts @@ -0,0 +1,94 @@ +import type { ProviderId } from '@shared/provider.js' +import type { GeneratedImage, ImageGeneration, ImageGenerationRequest } from './image-generation.js' + +/** + * In-memory ImageGeneration. Reproduces the real adapter's fallback-chain + * shape, try the preferred model, walk a provider-owned chain on a + * recoverable failure, stop immediately on an 'auth' or 'content-policy' + * failure, without any HTTP call. The "provider" it talks to is a script + * the test supplies through `failNextAttempt`; a generated image's bytes + * are a deterministic placeholder derived from the request, never real + * image data. + * + * `openai` and `google` get a small default fallback chain out of the box + * (mirroring which providers the real `FALLBACK_CHAINS` covers) so the + * fallback path is exercisable without a test having to configure one. The + * model names in that default chain are fake and only exist so a test can + * assert that generate() moved on to a different model, never assert an + * exact real model id. + */ + +export type FakeImageFailureReason = 'auth' | 'content-policy' | 'recoverable' + +interface ScriptedFailure { + reason: FakeImageFailureReason + message?: string +} + +const DEFAULT_FALLBACK_CHAINS: Partial> = { + openai: ['fake-openai-fallback-1', 'fake-openai-fallback-2'], + google: ['fake-google-fallback-1'], +} + +export interface FakeImageGenerationOptions { + fallbackChains?: Partial> +} + +export interface FakeImageGeneration extends ImageGeneration { + /** Every request handed to generate(), in call order. */ + requests: ImageGenerationRequest[] + /** + * Makes the next attempt against `model` on `provider` fail. Consumed + * once, the same model succeeds on a later call unless failed again. + * 'auth' and 'content-policy' stop the chain walk immediately, exactly + * like the real function; 'recoverable' (the default) advances to the + * next model in the chain. + */ + failNextAttempt(provider: ProviderId, model: string, reason?: FakeImageFailureReason, message?: string): void +} + +export function createFakeImageGeneration(options: FakeImageGenerationOptions = {}): FakeImageGeneration { + const fallbackChains = { ...DEFAULT_FALLBACK_CHAINS, ...options.fallbackChains } + const requests: ImageGenerationRequest[] = [] + const scriptedFailures = new Map() + + function chainKey(provider: ProviderId, model: string): string { + return `${provider}:${model}` + } + + function failNextAttempt(provider: ProviderId, model: string, reason: FakeImageFailureReason = 'recoverable', message?: string): void { + scriptedFailures.set(chainKey(provider, model), { reason, message }) + } + + async function generate(req: ImageGenerationRequest): Promise { + requests.push(req) + if (req.signal.aborted) throw req.signal.reason + + const chain = [req.preferredModel, ...(fallbackChains[req.provider] ?? [])] + .filter((model, i, arr) => arr.indexOf(model) === i) + + let lastMessage: string | undefined + for (const model of chain) { + const key = chainKey(req.provider, model) + const failure = scriptedFailures.get(key) + if (!failure) { + return { + data: Buffer.from(`fake-image:${req.provider}:${model}`), + mediaType: 'image/png', + _diag: { modelUsed: model, fellBack: model !== req.preferredModel }, + } + } + scriptedFailures.delete(key) + if (failure.reason === 'auth') { + throw new Error(failure.message ?? `Authentication failed for ${req.provider}. Check your API key in Settings.`) + } + if (failure.reason === 'content-policy') { + throw new Error(failure.message ?? 'Image rejected by content policy. Try a different prompt.') + } + lastMessage = failure.message ?? `${req.provider}/${model} failed` + } + throw new Error(`All image models failed. Last error: ${lastMessage ?? 'unknown'}`) + } + + return { requests, failNextAttempt, generate } +} diff --git a/server/ports/image-generation.ts b/server/ports/image-generation.ts new file mode 100644 index 0000000..1a4bb38 --- /dev/null +++ b/server/ports/image-generation.ts @@ -0,0 +1,48 @@ +import type { ProviderId } from '@shared/provider.js' + +/** + * Abstracts turning a prompt into a cover image. The one real implementation + * today, `generateImageWithFallback` in `server/services/image-generation.ts`, + * calls OpenAI's or Google's image endpoint directly over `fetch` and, on a + * recoverable failure (anything except bad credentials or a content-policy + * rejection), retries against the next model in a provider-owned fallback + * chain before giving up entirely. That HTTP and provider detail is + * entirely adapter work; this port only promises the shape of that + * behaviour: try the caller's preferred model, fall back on a recoverable + * failure, stop immediately on one that will not be fixed by trying another + * model, and report which model actually produced the image. + * + * There is no `apiKey` field. The future adapter resolves the key itself + * from a `KeyVault`, the same way `generateImageWithFallback` calls + * `getKey()` today. + */ + +export interface ImageGenerationRequest { + provider: ProviderId + preferredModel: string + prompt: string + signal: AbortSignal +} + +export interface GeneratedImage { + data: Buffer + mediaType: string + /** + * Diagnostics only, never surfaced to the user. Mirrors the real + * function's own `_diag` field. + */ + _diag: { + modelUsed: string + /** True when the preferred model failed and a fallback model produced this image instead. */ + fellBack: boolean + } +} + +export interface ImageGeneration { + /** + * Covers the one real call site, the cover-generation background task in + * `server/routes/covers.ts`, which calls `generateImageWithFallback` + * (`server/services/image-generation.ts:185`) as is. + */ + generate(req: ImageGenerationRequest): Promise +} From 2a4aa5408bec9c7b4c9f420966b4f21513876118 Mon Sep 17 00:00:00 2001 From: Ross Miller Date: Mon, 20 Jul 2026 20:13:59 -0500 Subject: [PATCH 004/126] feat: add the SpeechSynthesis port, fake, and contract tests Adds server/ports/speech-synthesis.ts, its in-memory fake, and its vitest contract harness for the narration capability that today lives directly in server/services/kokoro-service.ts and server/services/audiobook-installer.ts. The install surface, meaning isInstalled, missingComponents, and install, covers both the Kokoro model and ffmpeg, matching the real code, since audiobook-installer.ts already bundles both downloads into one installer and kokoro-service.ts re-exports its isInstalled function as isModelInstalled. Two exports of kokoro-service.ts are deliberately left off the port. getRecommendedWorkerCount is a pure function of host RAM and CPU count with its own existing test suite, so it stays a free function a future service calls before handing the result to startWorkerPool. __testing resets a lazily loaded model singleton that only the real adapter has, and audiobook-installer.ts already documents its own __testing as not part of the public API contract, so it stays an adapter only seam. This port is fake only. No test in this repository may run the contract against a real kokoro adapter, since kokoro-js loads a real ONNX model. pnpm exec vitest run server/ports/speech-synthesis.fake.test.ts passes with 13 tests. pnpm typecheck and pnpm lint are both clean. Claude-Session: https://claude.ai/code/session_01FSkESkJTRpt3Ye2cM7c5n5 --- server/ports/speech-synthesis.contract.ts | 144 +++++++++++++++++++++ server/ports/speech-synthesis.fake.test.ts | 4 + server/ports/speech-synthesis.fake.ts | 134 +++++++++++++++++++ server/ports/speech-synthesis.ts | 98 ++++++++++++++ 4 files changed, 380 insertions(+) create mode 100644 server/ports/speech-synthesis.contract.ts create mode 100644 server/ports/speech-synthesis.fake.test.ts create mode 100644 server/ports/speech-synthesis.fake.ts create mode 100644 server/ports/speech-synthesis.ts diff --git a/server/ports/speech-synthesis.contract.ts b/server/ports/speech-synthesis.contract.ts new file mode 100644 index 0000000..8e8542c --- /dev/null +++ b/server/ports/speech-synthesis.contract.ts @@ -0,0 +1,144 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import type { SpeechSynthesis } from './speech-synthesis.js' + +/** + * The behavioural contract every SpeechSynthesis implementation must + * satisfy. + * + * This contract is fake only. kokoro-js loads a real ONNX model on first + * use, so no test in this repository may run it against a real kokoro + * adapter. Once server/adapters/kokoro-speech-synthesis.ts exists, it stays + * covered by its own unit tests with kokoro-js mocked at the module + * boundary, the same way server/services/kokoro-service.test.ts already + * does, never by this contract. + */ +export function describeSpeechSynthesisContract( + label: string, + makeSubject: () => SpeechSynthesis | Promise, +): void { + describe(`SpeechSynthesis contract (${label})`, () => { + let subject: SpeechSynthesis + + beforeEach(async () => { + subject = await makeSubject() + }) + + describe('listVoices', () => { + it('returns a non-empty catalogue', () => { + expect(subject.listVoices().length).toBeGreaterThan(0) + }) + + it('gives every voice the documented fields', () => { + for (const voice of subject.listVoices()) { + expect(typeof voice.id).toBe('string') + expect(voice.id.length).toBeGreaterThan(0) + expect(typeof voice.name).toBe('string') + expect(['American English', 'British English']).toContain(voice.language) + expect(['Male', 'Female']).toContain(voice.gender) + expect(typeof voice.grade).toBe('string') + } + }) + }) + + describe('isInstalled and missingComponents', () => { + it('agree with each other before anything is installed', () => { + const installed = subject.isInstalled() + const missing = subject.missingComponents() + expect(installed).toBe(!missing.model && !missing.ffmpeg) + }) + + it('still agree with each other, and both report installed, once install resolves', async () => { + await subject.install() + const installed = subject.isInstalled() + const missing = subject.missingComponents() + expect(installed).toBe(!missing.model && !missing.ffmpeg) + expect(installed).toBe(true) + }) + }) + + describe('synthesizePreview', () => { + it('returns bytes for a known voice', async () => { + const [voice] = subject.listVoices() + const bytes = await subject.synthesizePreview(voice.id) + expect(bytes).toBeInstanceOf(Buffer) + expect(bytes.length).toBeGreaterThan(0) + }) + + it('returns the same bytes on a second call for the same voice', async () => { + const [voice] = subject.listVoices() + const first = await subject.synthesizePreview(voice.id) + const second = await subject.synthesizePreview(voice.id) + expect(second.equals(first)).toBe(true) + }) + + it('fails the way the real code fails for an unknown voice', async () => { + await expect(subject.synthesizePreview('not-a-real-voice-id')).rejects.toThrow(/Unknown voice/) + }) + }) + + describe('synthesizeChapter', () => { + it('reports sentences through onSentence', async () => { + const [voice] = subject.listVoices() + const seen: Array<{ idx: number; text: string }> = [] + await subject.synthesizeChapter({ + text: 'First sentence. Second sentence. Third sentence.', + voiceId: voice.id, + speed: 1.0, + outPath: '/fake/out/chapter.wav', + onSentence: (idx, text) => seen.push({ idx, text }), + }) + expect(seen.length).toBeGreaterThan(0) + expect(seen[0].idx).toBe(0) + }) + + it('fails the way the real code fails for an unknown voice', async () => { + await expect( + subject.synthesizeChapter({ + text: 'Hello there.', + voiceId: 'not-a-real-voice-id', + speed: 1.0, + outPath: '/fake/out/chapter.wav', + }), + ).rejects.toThrow(/Unknown voice/) + }) + + it('fails the way the real code fails for an out of range speed', async () => { + const [voice] = subject.listVoices() + await expect( + subject.synthesizeChapter({ + text: 'Hello there.', + voiceId: voice.id, + speed: 3.0, + outPath: '/fake/out/chapter.wav', + }), + ).rejects.toThrow(/Invalid speed/) + }) + + it('rejects a signal that is already aborted', async () => { + const [voice] = subject.listVoices() + const controller = new AbortController() + controller.abort() + await expect( + subject.synthesizeChapter({ + text: 'Hello there.', + voiceId: voice.id, + speed: 1.0, + outPath: '/fake/out/chapter.wav', + signal: controller.signal, + }), + ).rejects.toThrow(/abort/i) + }) + }) + + describe('worker pool', () => { + it('starts and stops without error', async () => { + await expect(subject.startWorkerPool(2)).resolves.toBeUndefined() + await expect(subject.stopWorkerPool()).resolves.toBeUndefined() + }) + + it('stops without error even when no pool was started', async () => { + await expect(subject.stopWorkerPool()).resolves.toBeUndefined() + }) + }) + }) +} diff --git a/server/ports/speech-synthesis.fake.test.ts b/server/ports/speech-synthesis.fake.test.ts new file mode 100644 index 0000000..e7ea4a4 --- /dev/null +++ b/server/ports/speech-synthesis.fake.test.ts @@ -0,0 +1,4 @@ +import { describeSpeechSynthesisContract } from './speech-synthesis.contract.js' +import { createFakeSpeechSynthesis } from './speech-synthesis.fake.js' + +describeSpeechSynthesisContract('fake', () => createFakeSpeechSynthesis()) diff --git a/server/ports/speech-synthesis.fake.ts b/server/ports/speech-synthesis.fake.ts new file mode 100644 index 0000000..a88d33f --- /dev/null +++ b/server/ports/speech-synthesis.fake.ts @@ -0,0 +1,134 @@ +import type { + SpeechSynthesis, + MissingComponents, + ProgressCallback, + SynthesizeChapterRequest, +} from './speech-synthesis.js' + +/** + * Deterministic in-memory SpeechSynthesis. Never loads kokoro-js and never + * touches the real filesystem, every path in a request is treated as an + * opaque label rather than something read or written. + * + * The voice catalogue is a small synthetic set rather than a copy of the + * real 28 voice Kokoro list, so this file never drifts out of sync with + * kokoro-service.ts and so a reader can tell at a glance that the ids are + * fake rather than real Kokoro voice ids. + */ + +const FAKE_VOICES = [ + { id: 'fake-voice-male', name: 'Fake Male Voice', language: 'American English' as const, gender: 'Male' as const, grade: 'A' }, + { id: 'fake-voice-female', name: 'Fake Female Voice', language: 'British English' as const, gender: 'Female' as const, grade: 'B' }, +] + +const FAKE_MODEL_BYTES = 1_000 +const FAKE_FFMPEG_BYTES = 500 + +function splitSentences(text: string): string[] { + return text.split(/(?<=[.!?])\s+/).filter((part) => part.trim().length > 0) +} + +export interface FakeSpeechSynthesisCalls { + install: number + readonly synthesizePreview: string[] + readonly synthesizeChapter: SynthesizeChapterRequest[] + readonly startWorkerPool: number[] + stopWorkerPool: number +} + +export interface FakeSpeechSynthesis extends SpeechSynthesis { + /** A record of every call this fake has received, for tests that assert on adapter usage instead of just on results. */ + readonly calls: FakeSpeechSynthesisCalls +} + +export function createFakeSpeechSynthesis(): FakeSpeechSynthesis { + let modelInstalled = false + let ffmpegInstalled = false + const previewCache = new Map() + + const calls: FakeSpeechSynthesisCalls = { + install: 0, + synthesizePreview: [], + synthesizeChapter: [], + startWorkerPool: [], + stopWorkerPool: 0, + } + + function isKnownVoice(voiceId: string): boolean { + return FAKE_VOICES.some((voice) => voice.id === voiceId) + } + + function missingComponents(): MissingComponents { + const missingModel = !modelInstalled + const missingFfmpeg = !ffmpegInstalled + return { + model: missingModel, + ffmpeg: missingFfmpeg, + totalBytes: (missingModel ? FAKE_MODEL_BYTES : 0) + (missingFfmpeg ? FAKE_FFMPEG_BYTES : 0), + } + } + + return { + calls, + + listVoices() { + return FAKE_VOICES.map((voice) => ({ ...voice })) + }, + + isInstalled() { + return modelInstalled && ffmpegInstalled + }, + + missingComponents, + + async install(onProgress?: ProgressCallback, signal?: AbortSignal) { + calls.install++ + if (signal?.aborted) throw new Error('Install aborted') + const missing = missingComponents() + if (missing.model) { + onProgress?.({ component: 'model', bytesDownloaded: 0, bytesTotal: FAKE_MODEL_BYTES, label: 'Downloading fake model' }) + modelInstalled = true + onProgress?.({ component: 'model', bytesDownloaded: FAKE_MODEL_BYTES, bytesTotal: FAKE_MODEL_BYTES, label: 'Fake model installed' }) + } + if (missing.ffmpeg) { + onProgress?.({ component: 'ffmpeg', bytesDownloaded: 0, bytesTotal: FAKE_FFMPEG_BYTES, label: 'Downloading fake ffmpeg' }) + ffmpegInstalled = true + onProgress?.({ component: 'ffmpeg', bytesDownloaded: FAKE_FFMPEG_BYTES, bytesTotal: FAKE_FFMPEG_BYTES, label: 'Fake ffmpeg installed' }) + } + }, + + async synthesizePreview(voiceId: string) { + calls.synthesizePreview.push(voiceId) + if (!isKnownVoice(voiceId)) { + throw new Error(`Unknown voice: ${voiceId}`) + } + const cached = previewCache.get(voiceId) + if (cached) return cached + const bytes = Buffer.from(`fake-preview:${voiceId}`) + previewCache.set(voiceId, bytes) + return bytes + }, + + async synthesizeChapter(req: SynthesizeChapterRequest) { + if (req.signal?.aborted) throw new Error('Synthesis aborted') + if (!isKnownVoice(req.voiceId)) { + throw new Error(`Unknown voice: ${req.voiceId}`) + } + if (req.speed < 0.5 || req.speed > 2.0) { + throw new Error(`Invalid speed: ${req.speed} (must be 0.5-2.0)`) + } + const sentences = splitSentences(req.text) + sentences.forEach((sentence, idx) => req.onSentence?.(idx, sentence)) + if (req.signal?.aborted) throw new Error('Synthesis aborted') + calls.synthesizeChapter.push(req) + }, + + async startWorkerPool(workerCount: number) { + calls.startWorkerPool.push(workerCount) + }, + + async stopWorkerPool() { + calls.stopWorkerPool++ + }, + } +} diff --git a/server/ports/speech-synthesis.ts b/server/ports/speech-synthesis.ts new file mode 100644 index 0000000..d97cbf9 --- /dev/null +++ b/server/ports/speech-synthesis.ts @@ -0,0 +1,98 @@ +import type { VoiceInfo } from '@shared/responses.js' + +/** + * Text to speech narration for audiobook generation, backed today by the + * kokoro-js ONNX model in server/services/kokoro-service.ts and its bundled + * installer in server/services/audiobook-installer.ts. This port exists so + * that code depending on narration never imports kokoro-js or its model + * loading directly. kokoro-js downloads and runs a real ONNX model, so it + * cannot run inside a test process. + * + * The install surface, isInstalled, missingComponents, and install, covers + * both the Kokoro model and the ffmpeg binary, not narration alone. That + * matches the real code exactly. audiobook-installer.ts bundles the two + * downloads into one installer because the product only ever offers + * installing narration as a single step, and kokoro-service.ts re-exports + * that installer's isInstalled function verbatim as isModelInstalled. The + * AudioAssembly port only covers ffmpeg's runtime behaviour, probing and + * concatenating audio that is assumed to already be installed, so it has no + * install surface of its own. + * + * Two exports of kokoro-service.ts deliberately have no home on this + * interface. + * + * getRecommendedWorkerCount is a pure function of the host machine's RAM + * and CPU count, read from os.totalmem and os.cpus, not of the synthesis + * engine itself. It already has its own dedicated test suite in + * kokoro-service.test.ts that asserts on arithmetic and never touches + * synthesis behaviour, which is a sign it is a sizing policy the caller + * computes and hands to startWorkerPool, not a capability the synthesis + * engine provides. It stays a free function for the future generate + * audiobook service, or a server/domain module, to call directly and pass + * the result into startWorkerPool. + * + * __testing, exported by both kokoro-service.ts and audiobook-installer.ts, + * resets module level singleton state that only the real adapter has, a + * lazily loaded KokoroTTS instance and a soft concurrency queue. + * audiobook-installer.ts says outright in its own comment that this seam is + * not part of the public API contract. A fake has no such singleton to + * reset, and a contract every implementation must satisfy cannot include a + * method that only one implementation needs, so __testing stays an adapter + * only test seam and is not part of this port. + */ + +/** What the installer still needs to download, and roughly how many bytes. Mirrors MissingComponents in audiobook-installer.ts. */ +export interface MissingComponents { + model: boolean + ffmpeg: boolean + totalBytes: number +} + +/** One progress tick reported during install. Mirrors InstallProgress in audiobook-installer.ts. */ +export interface InstallProgress { + component: 'model' | 'ffmpeg' | 'overall' + bytesDownloaded: number + bytesTotal: number + label: string +} + +/** Receives each InstallProgress tick as install downloads whatever is missing. */ +export type ProgressCallback = (progress: InstallProgress) => void + +/** Receives one call per sentence as synthesizeChapter streams audio, so a caller can show incremental progress. */ +export type SentenceCallback = (sentenceIdx: number, sentenceText: string) => void + +export interface SynthesizeChapterRequest { + text: string + voiceId: string + speed: number + outPath: string + signal?: AbortSignal + onSentence?: SentenceCallback +} + +export interface SpeechSynthesis { + /** The narration voice catalogue, in a deliberate order that drives the voice picker UI. */ + listVoices(): VoiceInfo[] + + /** Reports whether both the Kokoro model and ffmpeg are already present. */ + isInstalled(): boolean + + /** Reports what is still missing and roughly how large the remaining download is. Agrees with isInstalled, both components report present exactly when isInstalled returns true. */ + missingComponents(): MissingComponents + + /** Downloads whatever missingComponents reports as missing, and resolves immediately if isInstalled is already true. */ + install(onProgress?: ProgressCallback, signal?: AbortSignal): Promise + + /** Synthesizes a short sample clip in the given voice, for the voice picker. Rejects for a voice id that listVoices does not report. */ + synthesizePreview(voiceId: string): Promise + + /** Narrates text to a WAV file at outPath. Rejects for an unknown voice, an out of range speed, or a signal that is already aborted. */ + synthesizeChapter(req: SynthesizeChapterRequest): Promise + + /** Prepares the engine to run up to workerCount syntheses at once. */ + startWorkerPool(workerCount: number): Promise + + /** Releases the worker pool. Safe to call even when no pool was started. */ + stopWorkerPool(): Promise +} From 0998fdd95b6ad2742d46c593d103d2222b132e79 Mon Sep 17 00:00:00 2001 From: Ross Miller Date: Mon, 20 Jul 2026 20:14:18 -0500 Subject: [PATCH 005/126] feat: add the AudioAssembly port, fake, and contract tests Adds server/ports/audio-assembly.ts, its in-memory fake, and its vitest contract harness for the ffmpeg half of audiobook generation, extracted from the ffmpeg internals of server/services/audiobook-generator.ts, meaning runFfmpeg, getAudioDurationSec, and the M4B stitch step. This is split from SpeechSynthesis because ffmpeg is a separate external binary from the Kokoro model, downloaded and located independently through audiobook-installer.ts's getFfmpegPath. File paths appear directly in this interface, since audio assembly is genuinely filesystem bound and ffmpeg only reads and writes real files. Hiding that behind an in-memory buffer API would only add indirection without removing the real dependency, so probeDurationSec's path and concatToM4b's inputs and out stay as plain paths, and the header comment says so explicitly. One real behaviour is a known gap rather than a silent omission. The current M4B stitch also embeds a cover image when available and retries without one on failure, and this port's request shape has no coverPath field, matching the shape given for this task. Adding that field, or deciding cover embedding belongs elsewhere, is left for whoever builds the real adapter. This port is fake only. No test in this repository may run the contract against a real ffmpeg backed adapter. pnpm exec vitest run server/ports/audio-assembly.fake.test.ts passes with 5 tests. pnpm typecheck and pnpm lint are both clean. Claude-Session: https://claude.ai/code/session_01FSkESkJTRpt3Ye2cM7c5n5 --- server/ports/audio-assembly.contract.ts | 89 ++++++++++++++++++++++++ server/ports/audio-assembly.fake.test.ts | 4 ++ server/ports/audio-assembly.fake.ts | 64 +++++++++++++++++ server/ports/audio-assembly.ts | 56 +++++++++++++++ 4 files changed, 213 insertions(+) create mode 100644 server/ports/audio-assembly.contract.ts create mode 100644 server/ports/audio-assembly.fake.test.ts create mode 100644 server/ports/audio-assembly.fake.ts create mode 100644 server/ports/audio-assembly.ts diff --git a/server/ports/audio-assembly.contract.ts b/server/ports/audio-assembly.contract.ts new file mode 100644 index 0000000..3e43b35 --- /dev/null +++ b/server/ports/audio-assembly.contract.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import type { AudiobookChapterEntry } from '@shared/domain.js' +import type { AudioAssembly } from './audio-assembly.js' + +/** + * The behavioural contract every AudioAssembly implementation must + * satisfy. + * + * This contract is fake only. The real adapter shells out to a downloaded + * ffmpeg binary, see audiobook-installer.ts's getFfmpegPath, which is not + * present in a test environment. No test in this repository may run this + * contract against a real ffmpeg backed adapter. + */ +export function describeAudioAssemblyContract( + label: string, + makeSubject: () => AudioAssembly | Promise, +): void { + describe(`AudioAssembly contract (${label})`, () => { + let subject: AudioAssembly + + const chapters: AudiobookChapterEntry[] = [ + { num: 1, title: 'Chapter One', mp3Path: '/fake/audio/01.mp3', durationSec: 10, startSec: 0 }, + { num: 2, title: 'Chapter Two', mp3Path: '/fake/audio/02.mp3', durationSec: 12, startSec: 10 }, + ] + + beforeEach(async () => { + subject = await makeSubject() + }) + + describe('probeDurationSec', () => { + it('returns a positive duration', async () => { + const controller = new AbortController() + const sec = await subject.probeDurationSec('/fake/audio/01.wav', controller.signal) + expect(sec).toBeGreaterThan(0) + }) + + it('rejects a signal that is already aborted', async () => { + const controller = new AbortController() + controller.abort() + await expect( + subject.probeDurationSec('/fake/audio/01.wav', controller.signal), + ).rejects.toThrow() + }) + }) + + describe('concatToM4b', () => { + it('produces output for valid inputs', async () => { + const controller = new AbortController() + await expect( + subject.concatToM4b({ + inputs: ['/fake/audio/01.wav', '/fake/audio/02.wav'], + chapters, + out: '/fake/audio/book.m4b', + bitrate: '64k', + signal: controller.signal, + }), + ).resolves.toBeUndefined() + }) + + it('leaves the output probeable afterward, with a positive duration', async () => { + const controller = new AbortController() + const out = '/fake/audio/book-probe.m4b' + await subject.concatToM4b({ + inputs: ['/fake/audio/01.wav', '/fake/audio/02.wav'], + chapters, + out, + bitrate: '64k', + signal: controller.signal, + }) + const sec = await subject.probeDurationSec(out, controller.signal) + expect(sec).toBeGreaterThan(0) + }) + + it('rejects a signal that is already aborted, instead of completing', async () => { + const controller = new AbortController() + controller.abort() + await expect( + subject.concatToM4b({ + inputs: ['/fake/audio/01.wav'], + chapters, + out: '/fake/audio/aborted.m4b', + bitrate: '64k', + signal: controller.signal, + }), + ).rejects.toThrow() + }) + }) + }) +} diff --git a/server/ports/audio-assembly.fake.test.ts b/server/ports/audio-assembly.fake.test.ts new file mode 100644 index 0000000..40657e5 --- /dev/null +++ b/server/ports/audio-assembly.fake.test.ts @@ -0,0 +1,4 @@ +import { describeAudioAssemblyContract } from './audio-assembly.contract.js' +import { createFakeAudioAssembly } from './audio-assembly.fake.js' + +describeAudioAssemblyContract('fake', () => createFakeAudioAssembly()) diff --git a/server/ports/audio-assembly.fake.ts b/server/ports/audio-assembly.fake.ts new file mode 100644 index 0000000..50c8dd8 --- /dev/null +++ b/server/ports/audio-assembly.fake.ts @@ -0,0 +1,64 @@ +import type { AudioAssembly, ConcatToM4bRequest } from './audio-assembly.js' + +/** + * Deterministic in-memory AudioAssembly. Never shells out to ffmpeg and + * never touches the real filesystem, every path is just a map key here. + * + * A duration is derived deterministically from a path string the first + * time that path is probed, and remembered from then on, so any test can + * probe an arbitrary path without seeding one first. concatToM4b sums its + * inputs' derived durations and records that total under the request's out + * path, so a later probeDurationSec call against that same out path is + * consistent with what was assembled, which is the closest an in-memory + * fake can get to the real property that concatenating several clips + * yields a file whose duration is roughly their sum. + */ + +const ABORTED_MESSAGE = 'Audiobook generation aborted' + +function derivedDurationSec(path: string): number { + let hash = 0 + for (let i = 0; i < path.length; i++) { + hash = (hash * 31 + path.charCodeAt(i)) % 9973 + } + return 1 + (hash % 300) +} + +export interface FakeAudioAssembly extends AudioAssembly { + /** Every concatToM4b request this fake has completed, most recent last, for tests that assert on adapter usage instead of just on results. */ + readonly calls: ConcatToM4bRequest[] +} + +export function createFakeAudioAssembly(): FakeAudioAssembly { + const durations = new Map() + const calls: ConcatToM4bRequest[] = [] + + function durationFor(path: string): number { + let sec = durations.get(path) + if (sec === undefined) { + sec = derivedDurationSec(path) + durations.set(path, sec) + } + return sec + } + + return { + calls, + + async probeDurationSec(path: string, signal: AbortSignal) { + if (signal.aborted) throw new Error(ABORTED_MESSAGE) + return durationFor(path) + }, + + async concatToM4b(req: ConcatToM4bRequest) { + if (req.signal.aborted) throw new Error(ABORTED_MESSAGE) + let total = 0 + for (const input of req.inputs) { + if (req.signal.aborted) throw new Error(ABORTED_MESSAGE) + total += durationFor(input) + } + durations.set(req.out, total) + calls.push(req) + }, + } +} diff --git a/server/ports/audio-assembly.ts b/server/ports/audio-assembly.ts new file mode 100644 index 0000000..25a50f1 --- /dev/null +++ b/server/ports/audio-assembly.ts @@ -0,0 +1,56 @@ +import type { AudiobookChapterEntry } from '@shared/domain.js' + +/** + * ffmpeg backed audio assembly, probing a media file's duration and + * concatenating narrated chapter audio into one M4B audiobook with chapter + * markers. Extracted from the ffmpeg internals of + * server/services/audiobook-generator.ts, specifically runFfmpeg, + * getAudioDurationSec, and the M4B stitch step inside generateAudiobook. + * + * This is deliberately split from SpeechSynthesis because ffmpeg is a + * completely separate external binary dependency from the Kokoro model. It + * is downloaded and located independently, see + * audiobook-installer.ts's getFfmpegPath, so a caller that only needs to + * stitch already narrated audio has no reason to depend on kokoro-js at + * all. + * + * File paths appear directly in this interface, in probeDurationSec's path + * and in concatToM4b's inputs and out. That is a deliberate, accepted + * exception to keeping filesystem details out of a port. Audio assembly is + * genuinely filesystem bound, ffmpeg only knows how to read and write real + * files, so hiding that behind an in-memory buffer API would force every + * adapter to stage buffers to a temp file anyway, adding indirection + * without removing the real dependency. The adapter still owns every other + * filesystem detail seen in the source, atomic tmp then rename writes, the + * concat list and FFMETADATA1 files ffmpeg reads, and locating the ffmpeg + * binary itself. + * + * One real behaviour this first cut does not carry through. The current + * M4B stitch in audiobook-generator.ts also embeds a cover image when one + * is available, and retries the stitch without a cover if embedding fails. + * ConcatToM4bRequest has no coverPath field, matching the interface this + * port was specified against, so that behaviour is a known gap rather than + * a silent omission. Adding an optional coverPath, or deciding cover + * embedding belongs somewhere else entirely, is a design call for whoever + * builds the real adapter in a later task. + */ + +export interface ConcatToM4bRequest { + /** Chapter audio files to concatenate in order, typically WAV files written by SpeechSynthesis.synthesizeChapter. */ + inputs: string[] + /** Chapter titles and timing, embedded as chapter markers in the resulting M4B. */ + chapters: AudiobookChapterEntry[] + /** Destination path for the finished M4B. */ + out: string + /** ffmpeg audio bitrate, for example '64k'. */ + bitrate: string + signal: AbortSignal +} + +export interface AudioAssembly { + /** Duration of the audio file at path, in seconds. Rejects if signal is already aborted or the file cannot be probed. */ + probeDurationSec(path: string, signal: AbortSignal): Promise + + /** Concatenates inputs in order into a single M4B at out, embedding chapters as chapter markers. Rejects if signal is already aborted or becomes aborted before the work finishes. */ + concatToM4b(req: ConcatToM4bRequest): Promise +} From b50902d1453c5605fb43fa71e224eea192c2d1f5 Mon Sep 17 00:00:00 2001 From: Ross Miller Date: Mon, 20 Jul 2026 20:14:35 -0500 Subject: [PATCH 006/126] feat: add the DiagramRenderer port, fake, and contract tests Adds server/ports/diagram-renderer.ts, its in-memory fake, and its vitest contract harness for mermaid diagram rendering during EPUB export. Read both real implementations behind the shared fastify.mermaidRenderer decoration without changing either. server/index.ts's kroki.io fallback already returns an empty string for a chart that fails to render, matching this port's contract exactly. electron/main.ts's BrowserWindow based renderer currently returns an escaped pre code fallback block instead, which is real behaviour today but does not match this contract, so the header comment flags that reconciling it is work for whoever builds the Electron adapter, most likely by returning an empty string the same way the kroki adapter does. The port also pins that an empty charts array resolves to an empty array without doing any rendering work, since the Electron implementation already special cases that to avoid opening a wasted BrowserWindow, while the kroki implementation gets the same result for free from an empty loop. This port is fake only. No test in this repository may run the contract against kroki.io or a real Electron BrowserWindow. pnpm exec vitest run server/ports/diagram-renderer.fake.test.ts passes with 4 tests. pnpm typecheck and pnpm lint are both clean. Claude-Session: https://claude.ai/code/session_01FSkESkJTRpt3Ye2cM7c5n5 --- server/ports/diagram-renderer.contract.ts | 47 ++++++++++++++++++++++ server/ports/diagram-renderer.fake.test.ts | 4 ++ server/ports/diagram-renderer.fake.ts | 31 ++++++++++++++ server/ports/diagram-renderer.ts | 39 ++++++++++++++++++ 4 files changed, 121 insertions(+) create mode 100644 server/ports/diagram-renderer.contract.ts create mode 100644 server/ports/diagram-renderer.fake.test.ts create mode 100644 server/ports/diagram-renderer.fake.ts create mode 100644 server/ports/diagram-renderer.ts diff --git a/server/ports/diagram-renderer.contract.ts b/server/ports/diagram-renderer.contract.ts new file mode 100644 index 0000000..47a50e1 --- /dev/null +++ b/server/ports/diagram-renderer.contract.ts @@ -0,0 +1,47 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import type { DiagramRenderer } from './diagram-renderer.js' + +/** + * The behavioural contract every DiagramRenderer implementation must + * satisfy. + * + * This contract is fake only. The real implementations call out to + * kroki.io over HTTP or spin up an offscreen Electron BrowserWindow, see + * diagram-renderer.ts's header comment for both, and neither belongs in a + * unit test. No test in this repository may run this contract against + * either real implementation. + */ +export function describeDiagramRendererContract( + label: string, + makeSubject: () => DiagramRenderer | Promise, +): void { + describe(`DiagramRenderer contract (${label})`, () => { + let subject: DiagramRenderer + + beforeEach(async () => { + subject = await makeSubject() + }) + + it('returns exactly one result per input chart, in the same order', async () => { + const charts = ['graph TD; A-->B', 'graph LR; X-->Y', 'sequenceDiagram; A->>B: hi'] + const results = await subject.render(charts) + expect(results).toHaveLength(charts.length) + }) + + it('yields an empty string rather than throwing for a chart that fails', async () => { + const results = await subject.render(['graph TD; A-->B', '']) + expect(results[1]).toBe('') + }) + + it('does not fail the whole batch when one chart fails', async () => { + const results = await subject.render(['', 'graph TD; A-->B']) + expect(results[0]).toBe('') + expect(results[1]).not.toBe('') + }) + + it('returns an empty array for an empty input array', async () => { + const results = await subject.render([]) + expect(results).toEqual([]) + }) + }) +} diff --git a/server/ports/diagram-renderer.fake.test.ts b/server/ports/diagram-renderer.fake.test.ts new file mode 100644 index 0000000..cc513db --- /dev/null +++ b/server/ports/diagram-renderer.fake.test.ts @@ -0,0 +1,4 @@ +import { describeDiagramRendererContract } from './diagram-renderer.contract.js' +import { createFakeDiagramRenderer } from './diagram-renderer.fake.js' + +describeDiagramRendererContract('fake', () => createFakeDiagramRenderer()) diff --git a/server/ports/diagram-renderer.fake.ts b/server/ports/diagram-renderer.fake.ts new file mode 100644 index 0000000..b50f7f7 --- /dev/null +++ b/server/ports/diagram-renderer.fake.ts @@ -0,0 +1,31 @@ +import type { DiagramRenderer } from './diagram-renderer.js' + +/** + * Deterministic in-memory DiagramRenderer. A chart whose source is empty + * or whitespace only fails to render, standing in for the malformed + * diagram source a real renderer would reject. Every other chart succeeds + * with a synthetic, clearly fake markup string that echoes the chart's + * index, so ordering is easy to assert on without depending on real + * kroki.io or Electron markup shapes. + */ + +export interface FakeDiagramRenderer extends DiagramRenderer { + /** Every charts array passed to render, most recent last, for tests that assert on adapter usage instead of just on results. */ + readonly calls: string[][] +} + +export function createFakeDiagramRenderer(): FakeDiagramRenderer { + const calls: string[][] = [] + + return { + calls, + + async render(charts: string[]) { + calls.push(charts) + if (charts.length === 0) return [] + return charts.map((chart, i) => + chart.trim().length === 0 ? '' : ``, + ) + }, + } +} diff --git a/server/ports/diagram-renderer.ts b/server/ports/diagram-renderer.ts new file mode 100644 index 0000000..a6a5672 --- /dev/null +++ b/server/ports/diagram-renderer.ts @@ -0,0 +1,39 @@ +/** + * Renders mermaid diagram source into embeddable markup for EPUB export. + * Today this happens two different ways behind the same + * fastify.mermaidRenderer decoration. server/index.ts sets a default that + * calls the kroki.io HTTP API and writes each returned PNG to a temp file. + * electron/main.ts overrides that decoration at startup, when running + * inside Electron, with an offscreen BrowserWindow that renders with the + * real mermaid.js and captures a PNG from the page. Neither file is + * touched by this port, it is written from reading both so that a future + * kroki adapter and a future Electron adapter can both satisfy it as is. + * + * An empty string result for a chart means that chart failed to render. + * Both real implementations already treat a single chart's failure as keep + * going rather than fail the whole batch. The kroki.io implementation + * already pushes an empty string on failure, matching this contract + * exactly. The Electron implementation currently pushes an escaped + * pre code fallback block showing the raw chart source instead of an + * empty string. That is real behaviour today, but it does not match this + * port's contract, so reconciling it, most likely by having the future + * Electron adapter return an empty string the same way the kroki adapter + * does, is a decision for whoever builds that adapter, not something this + * task changes in electron/main.ts. + * + * render takes no AbortSignal. Neither real implementation accepts a + * caller supplied one today. The kroki.io call uses a fixed internal + * AbortSignal.timeout, and the Electron renderer uses a fixed internal per + * chart timeout, so there is nothing for the port to expose yet. + */ + +export interface DiagramRenderer { + /** + * Renders each chart and returns one markup string per chart, in the + * same order as charts. A chart that fails to render yields an empty + * string at that index rather than rejecting the whole call. An empty + * charts array resolves to an empty array without doing any rendering + * work. + */ + render(charts: string[]): Promise +} From 3a759f26d17aecd12ccdfa6680c4b239630228ad Mon Sep 17 00:00:00 2001 From: Ross Miller Date: Mon, 20 Jul 2026 20:15:07 -0500 Subject: [PATCH 007/126] feat: add the EpubImport and EpubExport ports with fakes and contracts Adds the first two ports for phase 2 of the server hexagonal restructure, EpubImport and EpubExport. Both stay fake only in tests, since a fake never needs a real EPUB parser or the epub-gen-memory library. EpubImport separates parsing from persistence. The current epub-importer.ts imports the book store and saves a book while it parses, so read() returns pure data instead, meaning meta fields, an ordered chapters array, and optional cover bytes, and it writes nothing at all. The contract test proves this by calling read and preview twice each and mutating the first result, since a reader with nothing to write to can only hand back independent values on every call. EpubExport wraps the epub-gen-memory usage inside the export-epub route. Its fake encodes the build request as a JSON manifest rather than a real EPUB archive, which still lets the contract test check that swapping chapter order changes the output, without a real parser in the port. Each port gets an interface with a header comment explaining what it abstracts, a deterministic fake, a contract test function that exercises the interface as a black box, and a fake test file that runs the contract and adds a few whitebox checks. Claude-Session: https://claude.ai/code/session_01FSkESkJTRpt3Ye2cM7c5n5 --- server/ports/epub-export.contract.ts | 56 +++++++++++++++++++ server/ports/epub-export.fake.test.ts | 46 ++++++++++++++++ server/ports/epub-export.fake.ts | 24 +++++++++ server/ports/epub-export.ts | 38 +++++++++++++ server/ports/epub-import.contract.ts | 74 +++++++++++++++++++++++++ server/ports/epub-import.fake.test.ts | 34 ++++++++++++ server/ports/epub-import.fake.ts | 78 +++++++++++++++++++++++++++ server/ports/epub-import.ts | 57 ++++++++++++++++++++ 8 files changed, 407 insertions(+) create mode 100644 server/ports/epub-export.contract.ts create mode 100644 server/ports/epub-export.fake.test.ts create mode 100644 server/ports/epub-export.fake.ts create mode 100644 server/ports/epub-export.ts create mode 100644 server/ports/epub-import.contract.ts create mode 100644 server/ports/epub-import.fake.test.ts create mode 100644 server/ports/epub-import.fake.ts create mode 100644 server/ports/epub-import.ts diff --git a/server/ports/epub-export.contract.ts b/server/ports/epub-export.contract.ts new file mode 100644 index 0000000..d33d4fb --- /dev/null +++ b/server/ports/epub-export.contract.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' +import type { EpubExport } from './epub-export.js' + +/** + * Behavior every EpubExport implementation must satisfy. Assertions stay + * black-box: they never assume how the bytes are encoded, only that valid + * input produces bytes and that chapter order actually reaches the output, + * so the same suite works for a fake today and a real epub-gen-memory + * adapter later. + */ +export function describeEpubExportContract(label: string, makeSubject: () => EpubExport | Promise) { + describe(`EpubExport contract (${label})`, () => { + it('produces non-empty bytes for valid input', async () => { + const subject = await makeSubject() + const buffer = await subject.build({ + title: 'A Book', + author: 'Tutor', + chapters: [ + { title: 'Chapter One', html: '

one

' }, + { title: 'Chapter Two', html: '

two

' }, + ], + }) + + expect(Buffer.isBuffer(buffer)).toBe(true) + expect(buffer.length).toBeGreaterThan(0) + }) + + it('builds successfully without the optional css or coverPath', async () => { + const subject = await makeSubject() + const buffer = await subject.build({ + title: 'No Extras', + author: 'Tutor', + chapters: [{ title: 'Only Chapter', html: '

only

' }], + }) + + expect(buffer.length).toBeGreaterThan(0) + }) + + it('preserves chapter order: swapping the order changes the output', async () => { + const subject = await makeSubject() + const request = { + title: 'Order Test', + author: 'Tutor', + chapters: [ + { title: 'First', html: '

1

' }, + { title: 'Second', html: '

2

' }, + ], + } + + const forward = await subject.build(request) + const reversed = await subject.build({ ...request, chapters: [...request.chapters].reverse() }) + + expect(forward.equals(reversed)).toBe(false) + }) + }) +} diff --git a/server/ports/epub-export.fake.test.ts b/server/ports/epub-export.fake.test.ts new file mode 100644 index 0000000..c0a9543 --- /dev/null +++ b/server/ports/epub-export.fake.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import { createFakeEpubExport } from './epub-export.fake.js' +import { describeEpubExportContract } from './epub-export.contract.js' + +describeEpubExportContract('fake', () => createFakeEpubExport()) + +describe('createFakeEpubExport (whitebox)', () => { + it('encodes the request as a decodable manifest, preserving title, author, css, coverPath, and chapters', async () => { + const fake = createFakeEpubExport() + const buffer = await fake.build({ + title: 'My Book', + author: 'Tutor', + css: '.katex {}', + coverPath: '/covers/1.png', + chapters: [ + { title: 'One', html: '

1

' }, + { title: 'Two', html: '

2

' }, + ], + }) + + const manifest = JSON.parse(buffer.toString('utf-8')) + expect(manifest).toEqual({ + title: 'My Book', + author: 'Tutor', + css: '.katex {}', + coverPath: '/covers/1.png', + chapters: [ + { title: 'One', html: '

1

' }, + { title: 'Two', html: '

2

' }, + ], + }) + }) + + it('encodes missing css and coverPath as null rather than dropping the keys', async () => { + const fake = createFakeEpubExport() + const buffer = await fake.build({ + title: 'No Extras', + author: 'Tutor', + chapters: [{ title: 'Only', html: '

only

' }], + }) + + const manifest = JSON.parse(buffer.toString('utf-8')) + expect(manifest.css).toBeNull() + expect(manifest.coverPath).toBeNull() + }) +}) diff --git a/server/ports/epub-export.fake.ts b/server/ports/epub-export.fake.ts new file mode 100644 index 0000000..9417e46 --- /dev/null +++ b/server/ports/epub-export.fake.ts @@ -0,0 +1,24 @@ +import type { EpubBuildRequest, EpubExport } from './epub-export.js' + +/** + * Deterministic in-memory EpubExport. Rather than a real EPUB (a zip + * archive, which would need epub-gen-memory to produce or verify), this + * fake encodes the request as a JSON manifest and returns it as bytes. That + * keeps the output decodable in tests, including the black-box "chapter + * order changes the output" property the contract test relies on, without + * pulling in the real dependency. + */ +export function createFakeEpubExport(): EpubExport { + return { + async build(req: EpubBuildRequest): Promise { + const manifest = { + title: req.title, + author: req.author, + css: req.css ?? null, + coverPath: req.coverPath ?? null, + chapters: req.chapters.map(chapter => ({ title: chapter.title, html: chapter.html })), + } + return Buffer.from(JSON.stringify(manifest), 'utf-8') + }, + } +} diff --git a/server/ports/epub-export.ts b/server/ports/epub-export.ts new file mode 100644 index 0000000..4f5444a --- /dev/null +++ b/server/ports/epub-export.ts @@ -0,0 +1,38 @@ +/** + * Renders a book's chapters into EPUB bytes. + * + * Abstracts the epub-gen-memory usage inline in the POST + * /api/books/:id/export-epub route handler in server/routes/books.ts. That + * handler builds an options object (title, author, numberChaptersInTOC, + * prependChapterTitles, an optional cover file URL, and optional inlined + * CSS) plus an ordered chapter array, then calls epub-gen-memory's default + * export to get a Buffer back. This port keeps that library, and the CJS + * double-default handling it needs under Electron, entirely inside the + * adapter. + * + * numberChaptersInTOC and prependChapterTitles are always false in current + * usage, so they are not part of this surface. coverPath takes a filesystem + * path rather than the file:// URL epub-gen-memory wants; the adapter owns + * that conversion. + */ + +export interface EpubExportChapter { + title: string + /** Chapter body as HTML, already rendered from markdown. */ + html: string +} + +export interface EpubBuildRequest { + title: string + author: string + /** Inlined stylesheet, e.g. KaTeX CSS when a chapter has math. */ + css?: string + /** Filesystem path to a cover image, if the book has one. */ + coverPath?: string + /** Chapters in the order they must appear in the EPUB. */ + chapters: EpubExportChapter[] +} + +export interface EpubExport { + build(req: EpubBuildRequest): Promise +} diff --git a/server/ports/epub-import.contract.ts b/server/ports/epub-import.contract.ts new file mode 100644 index 0000000..a2bb1d0 --- /dev/null +++ b/server/ports/epub-import.contract.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest' +import type { EpubImport } from './epub-import.js' + +const SAMPLE_BYTES = Buffer.from('pretend this is epub bytes') + +/** + * Behavior every EpubImport implementation must satisfy. Treats the subject + * as a black box returning canned or parsed data, never inspecting how it + * got there, so the same suite can run against a fake today and a real + * EPUB-parsing adapter later. + */ +export function describeEpubImportContract(label: string, makeSubject: () => EpubImport | Promise) { + describe(`EpubImport contract (${label})`, () => { + it('preview returns the documented EpubPreview fields', async () => { + const subject = await makeSubject() + const preview = await subject.preview(SAMPLE_BYTES) + + expect(typeof preview.title).toBe('string') + expect(typeof preview.chapterCount).toBe('number') + expect(typeof preview.hasCover).toBe('boolean') + if (preview.subtitle !== undefined) expect(typeof preview.subtitle).toBe('string') + if (preview.coverBase64 !== undefined) expect(typeof preview.coverBase64).toBe('string') + }) + + it('read returns chapters in a stable order', async () => { + const subject = await makeSubject() + const first = await subject.read(SAMPLE_BYTES) + const second = await subject.read(SAMPLE_BYTES) + + expect(first.chapters.length).toBeGreaterThan(0) + for (const chapter of first.chapters) { + expect(typeof chapter.title).toBe('string') + expect(typeof chapter.description).toBe('string') + expect(typeof chapter.markdown).toBe('string') + } + // Reading the same bytes twice must recover the same order every + // time. A real EPUB has one true spine order; nothing about reading + // it should be allowed to shuffle the result. + expect(second.chapters.map(c => c.title)).toEqual(first.chapters.map(c => c.title)) + }) + + it('preview persists nothing: repeated calls return independent values', async () => { + const subject = await makeSubject() + const first = await subject.preview(SAMPLE_BYTES) + const second = await subject.preview(SAMPLE_BYTES) + expect(second).toEqual(first) + + // A reader with nothing to write to can only hand back independent + // values. If preview() were secretly caching to, or reading back + // from, shared storage, mutating one result would leak into the + // next call's answer. + first.title = 'MUTATED' + expect(second.title).not.toBe('MUTATED') + }) + + it('read persists nothing: repeated calls return independent values', async () => { + const subject = await makeSubject() + const first = await subject.read(SAMPLE_BYTES) + const second = await subject.read(SAMPLE_BYTES) + expect(second).toEqual(first) + + // Same argument as above, exercised on the richer shape: mutating + // the chapters array, the meta, and the cover of one result must + // never be visible through a separate call. + first.chapters.push({ title: 'MUTATED', description: '', markdown: '' }) + first.meta.title = 'MUTATED' + if (first.cover) first.cover.mediaType = 'MUTATED' + + expect(second.chapters).not.toEqual(first.chapters) + expect(second.meta.title).not.toBe('MUTATED') + if (second.cover) expect(second.cover.mediaType).not.toBe('MUTATED') + }) + }) +} diff --git a/server/ports/epub-import.fake.test.ts b/server/ports/epub-import.fake.test.ts new file mode 100644 index 0000000..bd48b43 --- /dev/null +++ b/server/ports/epub-import.fake.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { createFakeEpubImport } from './epub-import.fake.js' +import { describeEpubImportContract } from './epub-import.contract.js' + +describeEpubImportContract('fake', () => createFakeEpubImport()) + +describe('createFakeEpubImport (whitebox)', () => { + it('read returns the fixture chapters in the given order', async () => { + const fake = createFakeEpubImport({ + book: { + meta: { title: 'Ordered Book' }, + chapters: [ + { title: 'Alpha', description: 'a', markdown: 'a-content' }, + { title: 'Beta', description: 'b', markdown: 'b-content' }, + { title: 'Gamma', description: 'c', markdown: 'c-content' }, + ], + }, + }) + + const book = await fake.read(Buffer.from('irrelevant')) + expect(book.chapters.map(c => c.title)).toEqual(['Alpha', 'Beta', 'Gamma']) + }) + + it('records every call it receives, in order, with the input size', async () => { + const fake = createFakeEpubImport() + await fake.preview(Buffer.from('abc')) + await fake.read(Buffer.from('abcde')) + + expect(fake.calls).toEqual([ + { method: 'preview', byteLength: 3 }, + { method: 'read', byteLength: 5 }, + ]) + }) +}) diff --git a/server/ports/epub-import.fake.ts b/server/ports/epub-import.fake.ts new file mode 100644 index 0000000..10a5861 --- /dev/null +++ b/server/ports/epub-import.fake.ts @@ -0,0 +1,78 @@ +import type { EpubPreview } from '@shared/responses.js' +import type { EpubImport, ImportedBook } from './epub-import.js' + +/** Canned responses a fake should return, overriding the built-in defaults. */ +export interface EpubImportFixture { + preview?: EpubPreview + book?: ImportedBook +} + +const DEFAULT_PREVIEW: EpubPreview = { + title: 'Fake Imported Book', + subtitle: 'A Fixture For Contract Tests', + chapterCount: 2, + hasCover: true, + coverBase64: 'data:image/png;base64,ZmFrZS1jb3Zlcg==', +} + +const DEFAULT_BOOK: ImportedBook = { + meta: { + title: 'Fake Imported Book', + subtitle: 'A Fixture For Contract Tests', + showTitleOnCover: true, + }, + chapters: [ + { title: 'Chapter One', description: 'The first chapter.', markdown: '# Chapter One\n\nContent.' }, + { title: 'Chapter Two', description: 'The second chapter.', markdown: '# Chapter Two\n\nMore content.' }, + ], + cover: { data: Buffer.from('fake-cover-bytes'), mediaType: 'image/png' }, +} + +function cloneBook(book: ImportedBook): ImportedBook { + return { + meta: { ...book.meta }, + chapters: book.chapters.map(chapter => ({ ...chapter })), + cover: book.cover ? { data: Buffer.from(book.cover.data), mediaType: book.cover.mediaType } : undefined, + } +} + +function clonePreview(preview: EpubPreview): EpubPreview { + return { ...preview } +} + +/** One call this fake received, recorded for contract-test assertions. */ +export interface FakeEpubImportCall { + method: 'preview' | 'read' + byteLength: number +} + +export interface FakeEpubImport extends EpubImport { + readonly calls: FakeEpubImportCall[] +} + +/** + * Deterministic in-memory EpubImport. Ignores the actual EPUB bytes and + * always answers with the fixture (or built-in defaults), which is enough + * to pin the port's documented behavior without a real EPUB parser. + * + * Returns a fresh clone on every call rather than a shared reference, the + * same way a real adapter re-parsing the buffer would, so nothing about one + * call's result can leak into another. + */ +export function createFakeEpubImport(fixture: EpubImportFixture = {}): FakeEpubImport { + const preview = fixture.preview ?? DEFAULT_PREVIEW + const book = fixture.book ?? DEFAULT_BOOK + const calls: FakeEpubImportCall[] = [] + + return { + calls, + async preview(bytes) { + calls.push({ method: 'preview', byteLength: bytes.length }) + return clonePreview(preview) + }, + async read(bytes) { + calls.push({ method: 'read', byteLength: bytes.length }) + return cloneBook(book) + }, + } +} diff --git a/server/ports/epub-import.ts b/server/ports/epub-import.ts new file mode 100644 index 0000000..730a618 --- /dev/null +++ b/server/ports/epub-import.ts @@ -0,0 +1,57 @@ +import type { EpubPreview } from '@shared/responses.js' + +/** + * Parses an EPUB file into the data Tutor needs to preview or import it. + * + * Abstracts server/services/epub-importer.ts, whose current implementation + * mixes EPUB parsing with persistence. It imports the book store and calls + * saveBook, saveToc, saveChapter, and saveCover directly, so today the only + * way to check "did we parse this EPUB correctly" is to also exercise the + * filesystem. This port removes that coupling. read() returns pure data, + * meaning the meta fields, an ordered chapters array, and optional cover + * bytes, and it writes nothing. A later service takes that data, assigns + * the book its id, timestamps, and status, and persists it. + * + * EpubPreview is the shape already sent over the wire by + * POST /api/books/import/preview, defined once in shared/responses.ts. + */ + +/** One chapter recovered from the EPUB's spine, in reading order. */ +export interface ImportedChapter { + title: string + description: string + /** Chapter body, already converted to markdown. */ + markdown: string +} + +/** The cover image embedded in the EPUB, if it has one. */ +export interface ImportedCover { + data: Buffer + mediaType: string +} + +/** + * Book-level fields recovered from the EPUB itself. Deliberately excludes + * anything that belongs to persistence, such as id, status, timestamps, or + * chapter counts, and anything supplied by the importing user rather than + * the file, such as tags or series. Those are the persisting service's job. + */ +export interface ImportedBookMeta { + title: string + subtitle?: string + showTitleOnCover?: boolean +} + +/** The full result of reading an EPUB: pure data, nothing persisted. */ +export interface ImportedBook { + meta: ImportedBookMeta + chapters: ImportedChapter[] + cover?: ImportedCover +} + +export interface EpubImport { + /** Metadata only, for the import confirmation screen. Cheap: no chapter conversion. */ + preview(bytes: Buffer): Promise + /** Full conversion to importable data. Must not write anything. */ + read(bytes: Buffer): Promise +} From 6a997f96bcab1c51aa425bc4f0fe442748cb6729 Mon Sep 17 00:00:00 2001 From: Ross Miller Date: Mon, 20 Jul 2026 20:15:21 -0500 Subject: [PATCH 008/126] feat: add the BackgroundTasks port with signal-only task handles Adds the BackgroundTasks port, covering task tracking for EPUB export, cover generation, audiobook install and generation, and generate-all chapters jobs. The port exists mainly to enforce one rule that server/services/task-manager.ts cannot enforce today. A caller only ever receives a TaskHandle exposing an AbortSignal, never the controller object that can trigger cancellation. Whether a task is cancelled is the manager's decision alone, made through cancel(taskId). The contract test checks that the handle's own keys are exactly id and signal, and separately that cancel actually aborts that same signal. The fake also reproduces the real manager's eviction behavior, where a finished, failed, or cancelled task is deleted from its internal map after a delay. server/constants.ts pins that delay at 60 seconds today, and since ports stay free of imports outside shared types, the fake and the contract each pin the same number as a literal rather than importing it. The contract test advances fake timers to one millisecond before the delay and asserts the task still exists, then advances one more millisecond and asserts it is gone. The contract also pins list, get, report, succeed, and fail, that findActive finds a running task and stops finding it once the task ends, and that subscribe delivers every event in order until its returned unsubscribe function is called. Claude-Session: https://claude.ai/code/session_01FSkESkJTRpt3Ye2cM7c5n5 --- server/ports/background-tasks.contract.ts | 180 +++++++++++++++++++++ server/ports/background-tasks.fake.test.ts | 18 +++ server/ports/background-tasks.fake.ts | 122 ++++++++++++++ server/ports/background-tasks.ts | 61 +++++++ 4 files changed, 381 insertions(+) create mode 100644 server/ports/background-tasks.contract.ts create mode 100644 server/ports/background-tasks.fake.test.ts create mode 100644 server/ports/background-tasks.fake.ts create mode 100644 server/ports/background-tasks.ts diff --git a/server/ports/background-tasks.contract.ts b/server/ports/background-tasks.contract.ts new file mode 100644 index 0000000..0155a1d --- /dev/null +++ b/server/ports/background-tasks.contract.ts @@ -0,0 +1,180 @@ +import { describe, expect, it, vi } from 'vitest' +import type { TaskEvent } from '@shared/events.js' +import type { BackgroundTasks, StartTaskSpec } from './background-tasks.js' + +const SPEC: StartTaskSpec = { type: 'generate-epub', bookId: 'book-1', bookTitle: 'Test Book', total: 5 } + +/** + * Mirrors TASK_CLEANUP_DELAY_MS in server/constants.ts. This is a pin, not + * a guess: the eviction test below asserts a finished task survives one + * millisecond short of this delay and is gone one millisecond after it. + */ +const EVICTION_DELAY_MS = 60_000 + +/** + * Behavior every BackgroundTasks implementation must satisfy. Written + * against the BackgroundTasks surface only, so this suite can run against + * the fake now and the real in-memory adapter later. + */ +export function describeBackgroundTasksContract(label: string, makeSubject: () => BackgroundTasks | Promise) { + describe(`BackgroundTasks contract (${label})`, () => { + it('a started task appears in list and get, running with zero progress', async () => { + const subject = await makeSubject() + const handle = subject.start(SPEC) + + const got = subject.get(handle.id) + expect(got).toBeDefined() + expect(got?.status).toBe('running') + expect(got?.type).toBe(SPEC.type) + expect(got?.bookId).toBe(SPEC.bookId) + expect(got?.bookTitle).toBe(SPEC.bookTitle) + expect(got?.progress.current).toBe(0) + expect(got?.progress.total).toBe(SPEC.total) + + expect(subject.list().some(t => t.id === handle.id)).toBe(true) + }) + + it('the handle exposes a signal and never an AbortController', async () => { + const subject = await makeSubject() + const handle = subject.start(SPEC) + + expect(handle.signal).toBeInstanceOf(AbortSignal) + expect(handle.signal.aborted).toBe(false) + // Exactly id and signal: nothing that could let a caller trigger + // cancellation itself, only observe it. + expect(Object.keys(handle).sort()).toEqual(['id', 'signal']) + }) + + it('report updates progress on a running task', async () => { + const subject = await makeSubject() + const handle = subject.start(SPEC) + + subject.report(handle.id, 3, 'Halfway there') + + const got = subject.get(handle.id) + expect(got?.progress).toEqual({ current: 3, total: SPEC.total, label: 'Halfway there' }) + }) + + it('succeed moves the task to done and records the result', async () => { + const subject = await makeSubject() + const handle = subject.start(SPEC) + + subject.succeed(handle.id, { path: '/out.epub' }) + + const got = subject.get(handle.id) + expect(got?.status).toBe('done') + expect(got?.result).toEqual({ path: '/out.epub' }) + }) + + it('fail moves the task to error and records the message', async () => { + const subject = await makeSubject() + const handle = subject.start(SPEC) + + subject.fail(handle.id, 'boom') + + const got = subject.get(handle.id) + expect(got?.status).toBe('error') + expect(got?.error).toBe('boom') + }) + + it('cancel moves a running task to cancelled, returns true, and aborts its signal', async () => { + const subject = await makeSubject() + const handle = subject.start(SPEC) + + const result = subject.cancel(handle.id) + + expect(result).toBe(true) + expect(subject.get(handle.id)?.status).toBe('cancelled') + expect(handle.signal.aborted).toBe(true) + }) + + it('cancel returns false for a task that already reached a terminal status', async () => { + const subject = await makeSubject() + const handle = subject.start(SPEC) + subject.succeed(handle.id) + + expect(subject.cancel(handle.id)).toBe(false) + expect(subject.get(handle.id)?.status).toBe('done') + }) + + it('cancel returns false for an unknown task id', async () => { + const subject = await makeSubject() + expect(subject.cancel('no-such-task')).toBe(false) + }) + + it('findActive finds a running task and stops finding it once the task finishes', async () => { + const subject = await makeSubject() + const handle = subject.start(SPEC) + + expect(subject.findActive(SPEC.bookId, SPEC.type)?.id).toBe(handle.id) + + subject.succeed(handle.id) + + expect(subject.findActive(SPEC.bookId, SPEC.type)).toBeUndefined() + }) + + it('findActive without a type matches any running type for the book; a mismatched type does not match', async () => { + const subject = await makeSubject() + const handle = subject.start(SPEC) + + expect(subject.findActive(SPEC.bookId)?.id).toBe(handle.id) + expect(subject.findActive(SPEC.bookId, 'generate-cover')).toBeUndefined() + expect(subject.findActive('some-other-book', SPEC.type)).toBeUndefined() + }) + + it('subscribe receives task_created, task_progress, and task_done in order, and stops after unsubscribe', async () => { + const subject = await makeSubject() + const events: TaskEvent[] = [] + const unsubscribe = subject.subscribe(event => events.push(event)) + + const handle = subject.start(SPEC) + subject.report(handle.id, 1, 'Working') + subject.succeed(handle.id, { ok: true }) + + expect(events.map(e => e.type)).toEqual(['task_created', 'task_progress', 'task_done']) + + unsubscribe() + const handle2 = subject.start(SPEC) + subject.succeed(handle2.id) + + expect(events).toHaveLength(3) + }) + + it('subscribe receives task_error and task_cancelled for those transitions', async () => { + const subject = await makeSubject() + const events: TaskEvent[] = [] + subject.subscribe(event => events.push(event)) + + const failed = subject.start(SPEC) + subject.fail(failed.id, 'boom') + + const cancelled = subject.start(SPEC) + subject.cancel(cancelled.id) + + expect(events.map(e => e.type)).toEqual([ + 'task_created', 'task_error', + 'task_created', 'task_cancelled', + ]) + }) + + it('evicts a finished task after the cleanup delay, and not a moment before', async () => { + vi.useFakeTimers() + try { + const subject = await makeSubject() + const handle = subject.start(SPEC) + subject.succeed(handle.id) + + expect(subject.get(handle.id)).toBeDefined() + + vi.advanceTimersByTime(EVICTION_DELAY_MS - 1) + expect(subject.get(handle.id)).toBeDefined() + + vi.advanceTimersByTime(1) + expect(subject.get(handle.id)).toBeUndefined() + expect(subject.list().some(t => t.id === handle.id)).toBe(false) + } finally { + vi.useRealTimers() + } + }) + }) +} diff --git a/server/ports/background-tasks.fake.test.ts b/server/ports/background-tasks.fake.test.ts new file mode 100644 index 0000000..e3f50a8 --- /dev/null +++ b/server/ports/background-tasks.fake.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { createFakeBackgroundTasks } from './background-tasks.fake.js' +import { describeBackgroundTasksContract } from './background-tasks.contract.js' + +describeBackgroundTasksContract('fake', () => createFakeBackgroundTasks()) + +describe('createFakeBackgroundTasks (whitebox)', () => { + it('gives independent tasks to independently created fakes', () => { + const a = createFakeBackgroundTasks() + const b = createFakeBackgroundTasks() + + const handle = a.start({ type: 'generate-cover', bookId: 'book-1', bookTitle: 'A', total: 1 }) + + expect(a.get(handle.id)).toBeDefined() + expect(b.get(handle.id)).toBeUndefined() + expect(b.list()).toEqual([]) + }) +}) diff --git a/server/ports/background-tasks.fake.ts b/server/ports/background-tasks.fake.ts new file mode 100644 index 0000000..cfc4c5f --- /dev/null +++ b/server/ports/background-tasks.fake.ts @@ -0,0 +1,122 @@ +import type { TaskEvent } from '@shared/events.js' +import type { BackgroundTasks, StartTaskSpec, Task, TaskHandle } from './background-tasks.js' + +/** + * Mirrors TASK_CLEANUP_DELAY_MS in server/constants.ts (60 seconds, today). + * Ports stay free of imports outside @shared/*, so the value is pinned here + * directly rather than imported. The contract test pins the same number; if + * the real constant ever changes, the eviction-timing test is what catches + * the drift. + */ +const EVICTION_DELAY_MS = 60_000 + +/** + * Deterministic in-memory BackgroundTasks. State lives in closures created + * fresh by each call to this factory, unlike the real manager's + * module-level Map, so independent fakes in different tests never see each + * other's tasks. + */ +export function createFakeBackgroundTasks(): BackgroundTasks { + const tasks = new Map() + const controllers = new Map() + const subscribers = new Set<(event: TaskEvent) => void>() + + const emit = (event: TaskEvent): void => { + for (const cb of subscribers) { + try { + cb(event) + } catch { + // A broken subscriber must not stop the others from being notified. + } + } + } + + const scheduleEviction = (taskId: string): void => { + setTimeout(() => { + tasks.delete(taskId) + controllers.delete(taskId) + }, EVICTION_DELAY_MS) + } + + return { + start(spec: StartTaskSpec): TaskHandle { + const id = crypto.randomUUID() + const controller = new AbortController() + const task: Task = { + id, + type: spec.type, + bookId: spec.bookId, + bookTitle: spec.bookTitle, + status: 'running', + progress: { current: 0, total: spec.total, label: 'Starting...' }, + } + tasks.set(id, task) + controllers.set(id, controller) + emit({ type: 'task_created', task: { ...task } }) + return { id, signal: controller.signal } + }, + + report(taskId, current, label) { + const task = tasks.get(taskId) + if (!task || task.status !== 'running') return + task.progress = { ...task.progress, current, label } + emit({ type: 'task_progress', taskId, progress: task.progress }) + }, + + succeed(taskId, result) { + const task = tasks.get(taskId) + if (!task) return + task.status = 'done' + task.result = result + task.progress = { ...task.progress, current: task.progress.total, label: 'Complete' } + emit({ type: 'task_done', taskId, taskType: task.type, result }) + scheduleEviction(taskId) + }, + + fail(taskId, error) { + const task = tasks.get(taskId) + if (!task) return + task.status = 'error' + task.error = error + emit({ type: 'task_error', taskId, taskType: task.type, error }) + scheduleEviction(taskId) + }, + + cancel(taskId) { + const task = tasks.get(taskId) + const controller = controllers.get(taskId) + if (!task || !controller || task.status !== 'running') return false + controller.abort() + task.status = 'cancelled' + emit({ type: 'task_cancelled', taskId }) + scheduleEviction(taskId) + return true + }, + + get(taskId) { + const task = tasks.get(taskId) + return task ? { ...task } : undefined + }, + + list() { + return Array.from(tasks.values()).map(task => ({ ...task })) + }, + + findActive(bookId, type) { + for (const task of tasks.values()) { + if (task.bookId === bookId && task.status === 'running' && (!type || task.type === type)) { + const controller = controllers.get(task.id) + if (controller) return { id: task.id, signal: controller.signal } + } + } + return undefined + }, + + subscribe(cb) { + subscribers.add(cb) + return () => { + subscribers.delete(cb) + } + }, + } +} diff --git a/server/ports/background-tasks.ts b/server/ports/background-tasks.ts new file mode 100644 index 0000000..2f86770 --- /dev/null +++ b/server/ports/background-tasks.ts @@ -0,0 +1,61 @@ +import type { ClientTask, TaskType } from '@shared/responses.js' +import type { TaskEvent } from '@shared/events.js' + +/** + * Tracks long-running background jobs (EPUB export, cover generation, + * audiobook install and generation, "generate all chapters") so a route + * can kick a job off, the client can poll or subscribe to its progress + * over SSE, and the job can be cancelled mid-flight. + * + * Abstracts server/services/task-manager.ts, which today is a bare module + * of exported functions closing over a single module-level Map. A port + * makes that swappable and lets services depend on shape rather than + * concrete functions. + * + * The one rule this port exists to enforce: callers receive a TaskHandle + * exposing only an AbortSignal, never the controller object that can + * trigger it. Whether a task is cancelled is the manager's decision alone, + * made through cancel(taskId). A caller that only holds a signal can + * observe cancellation but can never cause it behind the manager's back. + * + * Task is this port's own name for a task snapshot. Its shape is identical + * to ClientTask in shared/responses.ts, which is the same data serialized + * for the client, so it is defined here as an alias rather than a second, + * independently maintained copy of the same fields. + */ + +/** A background task's current snapshot, as returned by get() and list(). */ +export type Task = ClientTask + +/** Stops a subscribe() callback from receiving further events. */ +export type Unsubscribe = () => void + +/** + * What a caller gets back from start() or findActive(): enough to identify + * the task and observe its cancellation, and nothing that could cause it. + */ +export interface TaskHandle { + id: string + signal: AbortSignal +} + +export interface StartTaskSpec { + type: TaskType + bookId: string + bookTitle: string + total: number +} + +export interface BackgroundTasks { + start(spec: StartTaskSpec): TaskHandle + report(taskId: string, current: number, label: string): void + succeed(taskId: string, result?: unknown): void + fail(taskId: string, error: string): void + /** Returns true if a running task was found and cancelled, false otherwise. */ + cancel(taskId: string): boolean + get(taskId: string): Task | undefined + list(): Task[] + /** The running task for a book, optionally narrowed to one task type. */ + findActive(bookId: string, type?: TaskType): TaskHandle | undefined + subscribe(cb: (event: TaskEvent) => void): Unsubscribe +} From f6f3115d402394213d73ca52dfe77c01e73c8b73 Mon Sep 17 00:00:00 2001 From: Ross Miller Date: Mon, 20 Jul 2026 20:15:34 -0500 Subject: [PATCH 009/126] feat: add the Clock and OsFileManager ports with fakes and contracts Adds the last two ports for this slice of phase 2, Clock and OsFileManager. Clock replaces the raw new Date().toISOString() and randomUUID() calls spread across roughly fifteen call sites in services and routes. Its fake starts at a fixed instant and never moves on its own, and adds advance and set methods beyond the port interface so a test can control it directly, which is the whole reason this port is worth having on its own. OsFileManager wraps the platform switch in the audiobook reveal route, which spawns open, explorer.exe, or xdg-open depending on process.platform and swallows any failure rather than throwing. The port keeps that same best-effort contract. reveal() resolves whether or not the OS command actually succeeded. The contract test only pins that reveal resolves, since whether a file manager window actually opened is not something a test can observe, and a fake-specific test checks that the fake records every path it was asked to reveal. Both Clock and OsFileManager can be contract tested against a real adapter once one exists, so their contract tests stay written against the interface only, with no fake-specific assertions mixed in. Claude-Session: https://claude.ai/code/session_01FSkESkJTRpt3Ye2cM7c5n5 --- server/ports/clock.contract.ts | 37 +++++++++++++++++++++++ server/ports/clock.fake.test.ts | 31 +++++++++++++++++++ server/ports/clock.fake.ts | 34 +++++++++++++++++++++ server/ports/clock.ts | 19 ++++++++++++ server/ports/os-file-manager.contract.ts | 25 +++++++++++++++ server/ports/os-file-manager.fake.test.ts | 18 +++++++++++ server/ports/os-file-manager.fake.ts | 18 +++++++++++ server/ports/os-file-manager.ts | 20 ++++++++++++ 8 files changed, 202 insertions(+) create mode 100644 server/ports/clock.contract.ts create mode 100644 server/ports/clock.fake.test.ts create mode 100644 server/ports/clock.fake.ts create mode 100644 server/ports/clock.ts create mode 100644 server/ports/os-file-manager.contract.ts create mode 100644 server/ports/os-file-manager.fake.test.ts create mode 100644 server/ports/os-file-manager.fake.ts create mode 100644 server/ports/os-file-manager.ts diff --git a/server/ports/clock.contract.ts b/server/ports/clock.contract.ts new file mode 100644 index 0000000..9ccbbc5 --- /dev/null +++ b/server/ports/clock.contract.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import type { Clock } from './clock.js' + +/** + * Behavior every Clock implementation must satisfy. Written against the + * Clock surface only, so this suite can run against the fake now and a + * real system-clock adapter later. + */ +export function describeClockContract(label: string, makeSubject: () => Clock | Promise) { + describe(`Clock contract (${label})`, () => { + it('nowIso returns a valid ISO 8601 string', async () => { + const subject = await makeSubject() + const iso = subject.nowIso() + + expect(typeof iso).toBe('string') + // Round-tripping through Date must reproduce the exact string. That + // is true for any timestamp shaped like Date.prototype.toISOString, + // and false for anything that only looks like a timestamp. + expect(new Date(iso).toISOString()).toBe(iso) + }) + + it('newId returns a non-empty string', async () => { + const subject = await makeSubject() + const id = subject.newId() + + expect(typeof id).toBe('string') + expect(id.length).toBeGreaterThan(0) + }) + + it('newId returns distinct values across calls', async () => { + const subject = await makeSubject() + const ids = Array.from({ length: 20 }, () => subject.newId()) + + expect(new Set(ids).size).toBe(ids.length) + }) + }) +} diff --git a/server/ports/clock.fake.test.ts b/server/ports/clock.fake.test.ts new file mode 100644 index 0000000..f9b4fef --- /dev/null +++ b/server/ports/clock.fake.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { createFakeClock } from './clock.fake.js' +import { describeClockContract } from './clock.contract.js' + +describeClockContract('fake', () => createFakeClock()) + +describe('createFakeClock (whitebox)', () => { + it('is deterministic: starts at a fixed instant and does not move on its own', () => { + const clock = createFakeClock('2025-06-15T12:00:00.000Z') + expect(clock.nowIso()).toBe('2025-06-15T12:00:00.000Z') + expect(clock.nowIso()).toBe('2025-06-15T12:00:00.000Z') + }) + + it('advance moves the clock forward by the given milliseconds', () => { + const clock = createFakeClock('2025-06-15T12:00:00.000Z') + clock.advance(90_000) + expect(clock.nowIso()).toBe('2025-06-15T12:01:30.000Z') + }) + + it('set jumps the clock to an exact instant', () => { + const clock = createFakeClock('2025-06-15T12:00:00.000Z') + clock.set('2030-01-01T00:00:00.000Z') + expect(clock.nowIso()).toBe('2030-01-01T00:00:00.000Z') + }) + + it('newId is sequential and readable, not random', () => { + const clock = createFakeClock() + expect(clock.newId()).toBe('fake-id-1') + expect(clock.newId()).toBe('fake-id-2') + }) +}) diff --git a/server/ports/clock.fake.ts b/server/ports/clock.fake.ts new file mode 100644 index 0000000..b6050d8 --- /dev/null +++ b/server/ports/clock.fake.ts @@ -0,0 +1,34 @@ +import type { Clock } from './clock.js' + +const DEFAULT_START_ISO = '2024-01-01T00:00:00.000Z' + +/** A Clock that also lets a test control what time it reports. */ +export interface FakeClock extends Clock { + /** Moves the fake clock forward by this many milliseconds. */ + advance(ms: number): void + /** Jumps the fake clock to an exact instant. */ + set(iso: string): void +} + +/** + * Deterministic, controllable Clock. Starts at a fixed instant (2024-01-01 + * by default, or whatever startIso is given) and never moves on its own, + * so nowIso() is stable across a whole test unless advance() or set() is + * called. newId() returns short, sequential, readable ids rather than + * random UUIDs, which is both deterministic and easy to assert on. + */ +export function createFakeClock(startIso: string = DEFAULT_START_ISO): FakeClock { + let currentMs = new Date(startIso).getTime() + let idCounter = 0 + + return { + nowIso: () => new Date(currentMs).toISOString(), + newId: () => `fake-id-${++idCounter}`, + advance(ms) { + currentMs += ms + }, + set(iso) { + currentMs = new Date(iso).getTime() + }, + } +} diff --git a/server/ports/clock.ts b/server/ports/clock.ts new file mode 100644 index 0000000..043384c --- /dev/null +++ b/server/ports/clock.ts @@ -0,0 +1,19 @@ +/** + * The current time and fresh unique ids, as a single seam. + * + * Roughly 15 call sites across services and routes call + * `new Date().toISOString()` directly today, and id generation for new + * books and imports calls `randomUUID()` from node:crypto. Both make a + * service's output different on every run, which makes its tests either + * loose (assert "looks like an ISO string") or brittle (mock the module). + * Routing both through one port lets a service depend on "what time is it" + * and "give me a fresh id" as a value it receives, not a global it reaches + * for, and lets a service test assert exact timestamps and ids by + * injecting a controllable fake. + */ +export interface Clock { + /** The current instant, in the same format as `new Date().toISOString()`. */ + nowIso(): string + /** A fresh, unique identifier, filling the role `randomUUID()` fills today. */ + newId(): string +} diff --git a/server/ports/os-file-manager.contract.ts b/server/ports/os-file-manager.contract.ts new file mode 100644 index 0000000..581e5ca --- /dev/null +++ b/server/ports/os-file-manager.contract.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import type { OsFileManager } from './os-file-manager.js' + +/** + * Behavior every OsFileManager implementation must satisfy. Written + * against the OsFileManager surface only, so this suite can run against + * the fake now and a real spawn-based adapter later. It cannot assert on + * whether the OS actually revealed the file, since that is inherently + * unobservable from here (and best-effort even in the real adapter); it + * only pins that reveal() resolves rather than rejecting. + */ +export function describeOsFileManagerContract(label: string, makeSubject: () => OsFileManager | Promise) { + describe(`OsFileManager contract (${label})`, () => { + it('reveal resolves for a path', async () => { + const subject = await makeSubject() + await expect(subject.reveal('/books/some-book/audiobook.m4b')).resolves.toBeUndefined() + }) + + it('reveal resolves for multiple paths called in sequence', async () => { + const subject = await makeSubject() + await subject.reveal('/books/book-a/audiobook.m4b') + await expect(subject.reveal('/books/book-b/audiobook.m4b')).resolves.toBeUndefined() + }) + }) +} diff --git a/server/ports/os-file-manager.fake.test.ts b/server/ports/os-file-manager.fake.test.ts new file mode 100644 index 0000000..c89194d --- /dev/null +++ b/server/ports/os-file-manager.fake.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { createFakeOsFileManager } from './os-file-manager.fake.js' +import { describeOsFileManagerContract } from './os-file-manager.contract.js' + +describeOsFileManagerContract('fake', () => createFakeOsFileManager()) + +describe('createFakeOsFileManager (whitebox)', () => { + it('records what it was asked to reveal, in order', async () => { + const fake = createFakeOsFileManager() + await fake.reveal('/books/book-a/audiobook.m4b') + await fake.reveal('/books/book-b/audiobook.m4b') + + expect(fake.revealed).toEqual([ + '/books/book-a/audiobook.m4b', + '/books/book-b/audiobook.m4b', + ]) + }) +}) diff --git a/server/ports/os-file-manager.fake.ts b/server/ports/os-file-manager.fake.ts new file mode 100644 index 0000000..189f6b6 --- /dev/null +++ b/server/ports/os-file-manager.fake.ts @@ -0,0 +1,18 @@ +import type { OsFileManager } from './os-file-manager.js' + +/** An OsFileManager that records what it was asked to reveal. */ +export interface FakeOsFileManager extends OsFileManager { + readonly revealed: string[] +} + +/** Deterministic in-memory OsFileManager: never touches a real OS, just records. */ +export function createFakeOsFileManager(): FakeOsFileManager { + const revealed: string[] = [] + + return { + revealed, + async reveal(path) { + revealed.push(path) + }, + } +} diff --git a/server/ports/os-file-manager.ts b/server/ports/os-file-manager.ts new file mode 100644 index 0000000..56d8943 --- /dev/null +++ b/server/ports/os-file-manager.ts @@ -0,0 +1,20 @@ +/** + * Reveals a file on disk in the OS's native file manager. + * + * Abstracts the platform switch in the POST /api/books/:id/audiobook/reveal + * route handler in server/routes/books.ts, which spawns `open -R` on + * macOS, `explorer.exe /select,` on Windows, or `xdg-open` on the parent + * directory on Linux, depending on process.platform. That handler runs the + * reveal on the server, which for this single-user app is the user's own + * machine, so it does not depend on Electron IPC reaching the renderer. + * + * Today's implementation is best-effort: it wraps the spawn call in a + * try/catch, and on failure it swallows the error rather than throwing, + * leaving the caller to fall back (clipboard, IPC, or just showing the + * path). This port keeps that contract. reveal() resolves once the OS has + * been asked, regardless of whether the OS-side command actually + * succeeded. + */ +export interface OsFileManager { + reveal(path: string): Promise +} From 1b6f55120c8b1de78ff5b5618bde65f0c46c9889 Mon Sep 17 00:00:00 2001 From: Ross Miller Date: Mon, 20 Jul 2026 20:19:53 -0500 Subject: [PATCH 010/126] feat(ports): add BookRepository port, fake, and contract test This defines the BookRepository interface derived from the thirty four structured data functions in server/services/book-store.ts, covering books, tables of contents, chapters, per chapter and final quizzes, feedback, progress, the learning profile, briefs, chapter summaries, and references. It adds NotFoundError, a port owned error whose code is ENOENT, so the existing error handler can map a missing entity to a 404 the same way it already does for the real store's filesystem errors. It adds an in-memory fake and a shared contract test that pins round trips, listing, deletion, existence checks, missing entity rejections, and the derivation rules behind getChaptersRead and getSkillProgress, each worked through on a small example. The fake test runs that contract against the fake and adds a few fake specific checks for state isolation between instances and for copy-on-read and copy-on-write behavior. This adds fifty six passing tests and touches no existing file. Claude-Session: https://claude.ai/code/session_01FSkESkJTRpt3Ye2cM7c5n5 --- server/ports/book-repository.contract.ts | 537 ++++++++++++++++++++++ server/ports/book-repository.fake.test.ts | 76 +++ server/ports/book-repository.fake.ts | 409 ++++++++++++++++ server/ports/book-repository.ts | 160 +++++++ 4 files changed, 1182 insertions(+) create mode 100644 server/ports/book-repository.contract.ts create mode 100644 server/ports/book-repository.fake.test.ts create mode 100644 server/ports/book-repository.fake.ts create mode 100644 server/ports/book-repository.ts diff --git a/server/ports/book-repository.contract.ts b/server/ports/book-repository.contract.ts new file mode 100644 index 0000000..5b62579 --- /dev/null +++ b/server/ports/book-repository.contract.ts @@ -0,0 +1,537 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import type { + BookMeta, + Toc, + Quiz, + Feedback, + ChapterProgress, + LearningProfile, + ChapterSummary, +} from '@shared/domain.js' +import type { BookRepository } from './book-repository.js' + +/** + * The behavioural specification every BookRepository must satisfy, whether + * it is the in-memory fake or a future real adapter. Run this once against + * each with describeBookRepositoryContract(label, makeSubject), rather than + * writing the same assertions twice and letting them drift apart. + * + * A missing entity is asserted with rejects.toMatchObject({ code: 'ENOENT' }) + * rather than an instanceof check, on purpose, so both the fake's + * NotFoundError and a real adapter's plain Node fs error satisfy the same + * expectation without this file importing either one. + */ + +function makeBookMeta(overrides: Partial = {}): BookMeta { + return { + id: 'book-1', + title: 'Test Book', + prompt: 'Teach me testing', + status: 'reading', + totalChapters: 3, + generatedUpTo: 1, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + tags: [], + audioGeneratedChapters: [], + ...overrides, + } +} + +function makeToc(overrides: Partial = {}): Toc { + return { + chapters: [ + { title: 'Chapter 1', description: 'Introduction' }, + { title: 'Chapter 2', description: 'Deep dive' }, + ], + ...overrides, + } +} + +function makeQuiz(overrides: Partial = {}): Quiz { + return { + questions: [ + { question: 'What is X?', options: ['A', 'B', 'C', 'D'], correctIndex: 1 }, + ], + ...overrides, + } +} + +function makeFeedback(overrides: Partial = {}): Feedback { + return { + chapter: 1, + feedback: { liked: 'The analogies', disliked: 'Too much jargon' }, + quiz: { questions: makeQuiz().questions, score: 1 }, + ...overrides, + } +} + +function makeProfile(overrides: Partial = {}): LearningProfile { + return { + style: 'mental models', + identity: 'developer', + preferences: { + explainComplexTermsSimply: true, + codeExamples: true, + realWorldAnalogies: true, + includeRecaps: true, + includeSummaries: true, + visualDescriptions: false, + depthLevel: 3, + pacePreference: 3, + metaphorDensity: 3, + narrativeStyle: 3, + humorLevel: 2, + formalityLevel: 3, + }, + skills: [], + ...overrides, + } +} + +function makeChapterProgress(overrides: Partial = {}): ChapterProgress { + return { scroll: 1, completed: true, completedAt: '2026-01-01T00:00:00.000Z', ...overrides } +} + +function makeSummary(overrides: Partial = {}): ChapterSummary { + return { summary: 'A short summary', keyPoints: ['point one', 'point two'], ...overrides } +} + +export function describeBookRepositoryContract( + label: string, + makeSubject: () => BookRepository | Promise, +): void { + describe(`BookRepository contract (${label})`, () => { + let repo: BookRepository + + beforeEach(async () => { + repo = await makeSubject() + }) + + describe('learning profile', () => { + it('rejects with a not found code when nothing has been saved', async () => { + await expect(repo.getProfile()).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('round trips a saved profile', async () => { + await repo.saveProfile(makeProfile({ style: 'story-driven' })) + const result = await repo.getProfile() + expect(result.style).toBe('story-driven') + }) + + it('reports no updatedAt until the first save', async () => { + expect(await repo.getProfileUpdatedAt()).toBeNull() + }) + + it('reports an updatedAt after saving, that advances on a second save', async () => { + await repo.saveProfile(makeProfile()) + const first = await repo.getProfileUpdatedAt() + expect(first).not.toBeNull() + + await repo.saveProfile(makeProfile({ style: 'changed' })) + const second = await repo.getProfileUpdatedAt() + expect(second).not.toBeNull() + expect(second! > first!).toBe(true) + }) + }) + + describe('book CRUD', () => { + it('lists no books when nothing has been saved', async () => { + expect(await repo.listBooks()).toEqual([]) + }) + + it('rejects with a not found code for a book that was never saved', async () => { + await expect(repo.getBook('missing')).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('round trips a saved book', async () => { + await repo.saveBook(makeBookMeta()) + const book = await repo.getBook('book-1') + expect(book).toEqual(makeBookMeta()) + }) + + it('reflects a saved book in the listing', async () => { + await repo.saveBook(makeBookMeta()) + const books = await repo.listBooks() + expect(books).toHaveLength(1) + expect(books[0].id).toBe('book-1') + }) + + it('lists newest createdAt first', async () => { + await repo.saveBook(makeBookMeta({ id: 'older', createdAt: '2026-01-01T00:00:00.000Z' })) + await repo.saveBook(makeBookMeta({ id: 'newer', createdAt: '2026-03-01T00:00:00.000Z' })) + const books = await repo.listBooks() + expect(books.map((b) => b.id)).toEqual(['newer', 'older']) + }) + + it('makes a book unreadable after deleting it', async () => { + await repo.saveBook(makeBookMeta()) + await repo.deleteBook('book-1') + + await expect(repo.getBook('book-1')).rejects.toMatchObject({ code: 'ENOENT' }) + expect(await repo.listBooks()).toEqual([]) + }) + + it('does not throw when deleting a book that was never saved', async () => { + await expect(repo.deleteBook('never-existed')).resolves.not.toThrow() + }) + + it('deleting a book also clears its table of contents, chapters, quizzes, feedback, and progress', async () => { + await repo.saveBook(makeBookMeta()) + await repo.saveToc('book-1', makeToc()) + await repo.saveChapter('book-1', 1, '# Chapter 1') + await repo.saveQuiz('book-1', 1, makeQuiz()) + await repo.saveFinalQuiz('book-1', makeQuiz()) + await repo.saveFeedback('book-1', 1, makeFeedback()) + await repo.saveChapterProgress('book-1', 1, makeChapterProgress()) + await repo.saveBrief('book-1', 'Write a book about testing.') + await repo.saveSummary('book-1', 1, makeSummary()) + await repo.saveReference('book-1', 'source-a', 'Reference body') + + await repo.deleteBook('book-1') + + await expect(repo.getToc('book-1')).rejects.toMatchObject({ code: 'ENOENT' }) + await expect(repo.getChapter('book-1', 1)).rejects.toMatchObject({ code: 'ENOENT' }) + expect(await repo.chapterExists('book-1', 1)).toBe(false) + await expect(repo.getQuiz('book-1', 1)).rejects.toMatchObject({ code: 'ENOENT' }) + expect(repo.finalQuizExists('book-1')).toBe(false) + expect(await repo.getAllFeedback('book-1')).toEqual([]) + expect(await repo.getProgress('book-1')).toEqual({ chapters: {} }) + await expect(repo.getBrief('book-1')).rejects.toMatchObject({ code: 'ENOENT' }) + expect(await repo.getAllSummaries('book-1')).toEqual([]) + expect(await repo.listReferences('book-1')).toEqual([]) + }) + }) + + describe('resetBook', () => { + async function seedReadBook(): Promise { + await repo.saveBook(makeBookMeta({ + status: 'complete', + rating: 4.5, + finalQuizScore: 8, + finalQuizTotal: 10, + })) + await repo.saveToc('book-1', makeToc()) + await repo.saveChapter('book-1', 1, '# Chapter 1') + await repo.saveChapterProgress('book-1', 1, makeChapterProgress()) + await repo.saveFeedback('book-1', 1, makeFeedback()) + await repo.saveQuiz('book-1', 1, { + questions: [{ question: 'Q1', options: ['A', 'B', 'C', 'D'], correctIndex: 0, userAnswer: 3, correct: false }], + }) + await repo.saveFinalQuiz('book-1', { + questions: [{ question: 'Q1', options: ['A', 'B', 'C', 'D'], correctIndex: 1, userAnswer: 1, correct: true }], + }) + } + + it('rejects with a not found code for a book that was never saved', async () => { + await expect(repo.resetBook('missing')).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('rejects while the book is generating its table of contents or a chapter', async () => { + await repo.saveBook(makeBookMeta({ status: 'generating_toc' })) + await expect(repo.resetBook('book-1')).rejects.toThrow(/generating/) + + await repo.saveBook(makeBookMeta({ status: 'generating' })) + await expect(repo.resetBook('book-1')).rejects.toThrow(/generating/) + }) + + it('clears progress and feedback, and strips answers from quizzes while keeping the questions', async () => { + await seedReadBook() + await repo.resetBook('book-1') + + expect(await repo.getProgress('book-1')).toEqual({ chapters: {} }) + expect(await repo.getAllFeedback('book-1')).toEqual([]) + + const quiz = await repo.getQuiz('book-1', 1) + expect(quiz.questions).toHaveLength(1) + expect(quiz.questions[0]).not.toHaveProperty('userAnswer') + expect(quiz.questions[0]).not.toHaveProperty('correct') + expect(quiz.questions[0].question).toBe('Q1') + + const finalQuiz = await repo.getFinalQuiz('book-1') + expect(finalQuiz.questions[0]).not.toHaveProperty('userAnswer') + expect(finalQuiz.questions[0]).not.toHaveProperty('correct') + }) + + it('preserves generated content such as chapters and the table of contents', async () => { + await seedReadBook() + await repo.resetBook('book-1') + + expect(await repo.getChapter('book-1', 1)).toBe('# Chapter 1') + expect((await repo.getToc('book-1')).chapters).toHaveLength(2) + }) + + it('resets status and drops rating and final quiz score, and advances updatedAt', async () => { + await seedReadBook() + const before = await repo.getBook('book-1') + + await repo.resetBook('book-1') + const after = await repo.getBook('book-1') + + expect(after.status).toBe('reading') + expect(after.rating).toBeUndefined() + expect(after.finalQuizScore).toBeUndefined() + expect(after.finalQuizTotal).toBeUndefined() + expect(after.updatedAt > before.updatedAt).toBe(true) + expect(after.id).toBe(before.id) + expect(after.title).toBe(before.title) + }) + + it('is idempotent across repeated calls', async () => { + await seedReadBook() + await repo.resetBook('book-1') + await expect(repo.resetBook('book-1')).resolves.not.toThrow() + const after = await repo.getBook('book-1') + expect(after.status).toBe('reading') + }) + }) + + describe('table of contents', () => { + it('rejects with a not found code when no table of contents has been saved', async () => { + await expect(repo.getToc('book-1')).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('round trips a saved table of contents', async () => { + await repo.saveToc('book-1', makeToc()) + const toc = await repo.getToc('book-1') + expect(toc.chapters).toHaveLength(2) + expect(toc.chapters[0].title).toBe('Chapter 1') + }) + }) + + describe('chapters', () => { + it('rejects with a not found code for a chapter that was never saved', async () => { + await expect(repo.getChapter('book-1', 1)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('round trips saved chapter content', async () => { + await repo.saveChapter('book-1', 1, '# Chapter 1\n\nHello world') + expect(await repo.getChapter('book-1', 1)).toBe('# Chapter 1\n\nHello world') + }) + + it('reports existence in agreement with what was saved', async () => { + expect(await repo.chapterExists('book-1', 1)).toBe(false) + await repo.saveChapter('book-1', 1, '# Chapter 1') + expect(await repo.chapterExists('book-1', 1)).toBe(true) + }) + }) + + describe('per chapter quiz', () => { + it('rejects with a not found code for a quiz that was never saved', async () => { + await expect(repo.getQuiz('book-1', 1)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('round trips a saved quiz', async () => { + await repo.saveQuiz('book-1', 1, makeQuiz()) + const quiz = await repo.getQuiz('book-1', 1) + expect(quiz.questions).toHaveLength(1) + expect(quiz.questions[0].question).toBe('What is X?') + }) + + it('reports existence in agreement with what was saved', async () => { + expect(await repo.quizExists('book-1', 1)).toBe(false) + await repo.saveQuiz('book-1', 1, makeQuiz()) + expect(await repo.quizExists('book-1', 1)).toBe(true) + }) + }) + + describe('final quiz', () => { + it('rejects with a not found code when no final quiz has been saved', async () => { + await expect(repo.getFinalQuiz('book-1')).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('round trips a saved final quiz', async () => { + await repo.saveFinalQuiz('book-1', makeQuiz()) + const quiz = await repo.getFinalQuiz('book-1') + expect(quiz.questions).toHaveLength(1) + }) + + it('reports existence synchronously, in agreement with what was saved', () => { + expect(repo.finalQuizExists('book-1')).toBe(false) + }) + + it('reports existence synchronously after a save', async () => { + await repo.saveFinalQuiz('book-1', makeQuiz()) + expect(repo.finalQuizExists('book-1')).toBe(true) + }) + }) + + describe('feedback', () => { + it('rejects with a not found code for feedback that was never saved', async () => { + await expect(repo.getFeedback('book-1', 1)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('round trips saved feedback', async () => { + await repo.saveFeedback('book-1', 1, makeFeedback()) + const fb = await repo.getFeedback('book-1', 1) + expect(fb.feedback.liked).toBe('The analogies') + }) + + it('lists no feedback when none has been saved', async () => { + expect(await repo.getAllFeedback('book-1')).toEqual([]) + }) + + it('lists saved feedback ordered by chapter number', async () => { + await repo.saveFeedback('book-1', 2, makeFeedback({ chapter: 2 })) + await repo.saveFeedback('book-1', 1, makeFeedback({ chapter: 1 })) + const all = await repo.getAllFeedback('book-1') + expect(all.map((f) => f.chapter)).toEqual([1, 2]) + }) + }) + + describe('progress', () => { + it('resolves to empty progress rather than rejecting when nothing has been saved', async () => { + expect(await repo.getProgress('book-1')).toEqual({ chapters: {} }) + }) + + it('round trips saved chapter progress', async () => { + await repo.saveChapterProgress('book-1', 1, makeChapterProgress({ scroll: 0.75, completed: false, completedAt: undefined })) + const progress = await repo.getProgress('book-1') + expect(progress.chapters['1'].scroll).toBe(0.75) + expect(progress.chapters['1'].completed).toBe(false) + }) + + it('accumulates progress across chapters', async () => { + await repo.saveChapterProgress('book-1', 1, makeChapterProgress({ completed: true })) + await repo.saveChapterProgress('book-1', 2, makeChapterProgress({ completed: false, completedAt: undefined })) + const progress = await repo.getProgress('book-1') + expect(progress.chapters['1'].completed).toBe(true) + expect(progress.chapters['2'].completed).toBe(false) + }) + + it('derives chaptersRead as the count of completed chapters, worked through on three chapters', async () => { + expect(await repo.getChaptersRead('book-1')).toBe(0) + + await repo.saveChapterProgress('book-1', 1, makeChapterProgress({ completed: true })) + await repo.saveChapterProgress('book-1', 2, makeChapterProgress({ completed: false, completedAt: undefined })) + await repo.saveChapterProgress('book-1', 3, makeChapterProgress({ completed: true })) + + expect(await repo.getChaptersRead('book-1')).toBe(2) + }) + }) + + describe('skill progress', () => { + it('derives stats and per-skill weight from a table of contents and progress, worked through on one book', async () => { + await repo.saveBook(makeBookMeta({ id: 'b1', title: 'Cooking 101', createdAt: '2026-01-01T00:00:00.000Z' })) + await repo.saveToc('b1', { + skills: [{ name: 'Cooking', weight: 3 }], + chapters: [ + { title: 'Knife Skills', description: 'd1', skills: [{ skill: 'Cooking', subskill: 'Knife Skills', weight: 2 }] }, + { title: 'Seasoning', description: 'd2', skills: [{ skill: 'Cooking', subskill: 'Seasoning', weight: 1 }] }, + ], + }) + // Chapter 1 completed, chapter 2 not, so the book overall is incomplete. + await repo.saveChapterProgress('b1', 1, { scroll: 1, completed: true, completedAt: '2026-02-01T00:00:00.000Z' }) + await repo.saveChapterProgress('b1', 2, { scroll: 0.2, completed: false }) + + const result = await repo.getSkillProgress() + + expect(result.stats).toEqual({ totalBooks: 1, completedBooks: 0, totalChapters: 2, completedChapters: 1 }) + expect(result.skills).toEqual([ + { + name: 'Cooking', + // The book-level skill weight is credited only when the whole + // book is complete, so an incomplete book contributes zero even + // though one of its two chapters is done. + totalWeight: 3, + completedWeight: 0, + lastActivityAt: '2026-02-01T00:00:00.000Z', + books: [{ bookId: 'b1', title: 'Cooking 101', weight: 3, completed: false, lastActivityAt: '2026-02-01T00:00:00.000Z' }], + // Subskill weight, in contrast, is credited per chapter, so the + // completed chapter's subskill is fully credited on its own. + subskills: [ + { name: 'Knife Skills', totalWeight: 2, completedWeight: 2 }, + { name: 'Seasoning', totalWeight: 1, completedWeight: 0 }, + ], + }, + ]) + }) + + it('excludes a book whose table of contents declares no skills', async () => { + await repo.saveBook(makeBookMeta({ id: 'b1' })) + await repo.saveToc('b1', makeToc()) + await repo.saveChapterProgress('b1', 1, makeChapterProgress({ completed: true })) + await repo.saveChapterProgress('b1', 2, makeChapterProgress({ completed: true })) + + const result = await repo.getSkillProgress() + expect(result.stats).toEqual({ totalBooks: 0, completedBooks: 0, totalChapters: 0, completedChapters: 0 }) + expect(result.skills).toEqual([]) + }) + + it('skips a book with no table of contents saved yet, rather than rejecting', async () => { + await repo.saveBook(makeBookMeta({ id: 'b1', status: 'toc_review' })) + await expect(repo.getSkillProgress()).resolves.toEqual({ + stats: { totalBooks: 0, completedBooks: 0, totalChapters: 0, completedChapters: 0 }, + skills: [], + }) + }) + + it('resolves to zeroed stats and no skills when no books have been saved', async () => { + expect(await repo.getSkillProgress()).toEqual({ + stats: { totalBooks: 0, completedBooks: 0, totalChapters: 0, completedChapters: 0 }, + skills: [], + }) + }) + }) + + describe('brief', () => { + it('rejects with a not found code when no brief has been saved', async () => { + await expect(repo.getBrief('book-1')).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('round trips a saved brief', async () => { + await repo.saveBrief('book-1', 'Write a book about testing.') + expect(await repo.getBrief('book-1')).toBe('Write a book about testing.') + }) + }) + + describe('chapter summaries', () => { + it('rejects with a not found code for a summary that was never saved', async () => { + await expect(repo.getSummary('book-1', 1)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('round trips a saved summary', async () => { + await repo.saveSummary('book-1', 1, makeSummary()) + const summary = await repo.getSummary('book-1', 1) + expect(summary.keyPoints).toEqual(['point one', 'point two']) + }) + + it('lists no summaries when none has been saved', async () => { + expect(await repo.getAllSummaries('book-1')).toEqual([]) + }) + + it('lists saved summaries ordered by chapter number', async () => { + await repo.saveSummary('book-1', 2, makeSummary({ summary: 'second' })) + await repo.saveSummary('book-1', 1, makeSummary({ summary: 'first' })) + const all = await repo.getAllSummaries('book-1') + expect(all.map((s) => s.summary)).toEqual(['first', 'second']) + }) + }) + + describe('references', () => { + it('rejects a name containing anything other than letters, digits, and hyphens', async () => { + await expect(repo.saveReference('book-1', 'bad name!', 'content')).rejects.toThrow(/Invalid reference name/) + await expect(repo.getReference('book-1', 'bad name!')).rejects.toThrow(/Invalid reference name/) + }) + + it('rejects with a not found code for a reference that was never saved', async () => { + await expect(repo.getReference('book-1', 'source-a')).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('round trips a saved reference', async () => { + await repo.saveReference('book-1', 'source-a', 'Reference body') + expect(await repo.getReference('book-1', 'source-a')).toBe('Reference body') + }) + + it('lists no references when none has been saved', async () => { + expect(await repo.listReferences('book-1')).toEqual([]) + }) + + it('lists a saved reference by name', async () => { + await repo.saveReference('book-1', 'source-a', 'Reference body') + const manifest = await repo.listReferences('book-1') + expect(manifest).toEqual([{ name: 'source-a' }]) + }) + }) + }) +} diff --git a/server/ports/book-repository.fake.test.ts b/server/ports/book-repository.fake.test.ts new file mode 100644 index 0000000..68c5d70 --- /dev/null +++ b/server/ports/book-repository.fake.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from 'vitest' +import { createFakeBookRepository } from './book-repository.fake.js' +import { describeBookRepositoryContract } from './book-repository.contract.js' + +/** + * Proves the fake satisfies the port's behavioural contract today. A future + * real adapter gets the same describeBookRepositoryContract call, pointed + * at a temp directory, so the two never drift apart silently. + */ + +describeBookRepositoryContract('fake', () => createFakeBookRepository()) + +describe('createFakeBookRepository, fake-specific behaviour', () => { + it('is isolated per call, so two fakes never share state', async () => { + const a = createFakeBookRepository() + const b = createFakeBookRepository() + + await a.saveBook({ + id: 'only-in-a', + title: 'A', + prompt: 'p', + status: 'reading', + totalChapters: 1, + generatedUpTo: 0, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + tags: [], + audioGeneratedChapters: [], + }) + + expect(await a.listBooks()).toHaveLength(1) + expect(await b.listBooks()).toEqual([]) + }) + + it('protects its internal state from a caller mutating a returned book', async () => { + const repo = createFakeBookRepository() + const meta = { + id: 'book-1', + title: 'Original title', + prompt: 'p', + status: 'reading' as const, + totalChapters: 1, + generatedUpTo: 0, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + tags: [], + audioGeneratedChapters: [], + } + await repo.saveBook(meta) + + const fetched = await repo.getBook('book-1') + fetched.title = 'Mutated by caller' + + expect((await repo.getBook('book-1')).title).toBe('Original title') + }) + + it('protects its internal state from a caller mutating the object passed to save', async () => { + const repo = createFakeBookRepository() + const meta = { + id: 'book-1', + title: 'Original title', + prompt: 'p', + status: 'reading' as const, + totalChapters: 1, + generatedUpTo: 0, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + tags: [], + audioGeneratedChapters: [], + } + await repo.saveBook(meta) + meta.title = 'Mutated after save' + + expect((await repo.getBook('book-1')).title).toBe('Original title') + }) +}) diff --git a/server/ports/book-repository.fake.ts b/server/ports/book-repository.fake.ts new file mode 100644 index 0000000..de7f834 --- /dev/null +++ b/server/ports/book-repository.fake.ts @@ -0,0 +1,409 @@ +import { isGenerating } from '@shared/book-status.js' +import type { + BookMeta, + Toc, + Progress, + ChapterProgress, + Feedback, + Quiz, + LearningProfile, + ChapterSummary, + ReferenceManifest, +} from '@shared/domain.js' +import type { SkillProgress } from '@shared/responses.js' +import { type BookRepository, NotFoundError } from './book-repository.js' + +/** + * An in-memory BookRepository for unit tests and for the contract test + * itself. Every entity lives in a Map keyed by book id, mirroring how the + * real store keys a directory per book, so deleteBook can drop every map + * entry for that id in one pass rather than reimplementing a recursive + * directory removal. + * + * Reads and writes pass every plain object and array through + * structuredClone, so a caller mutating a returned BookMeta, or reusing + * the object it passed to saveBook, can never reach back into the fake's + * storage. A real filesystem adapter gets this for free because every read + * parses a fresh object from disk, and this fake earns it the same way a + * unit test would want it, on purpose rather than by accident. + * + * Timestamps this fake generates itself, meaning resetBook's updatedAt and + * the learning profile's updatedAt, come from an internal counter rather + * than the real clock, so two calls in the same test never produce the + * same instant and a run is reproducible no matter how fast it executes. + * The counter starts from a fixed date past any date a test is likely to + * write into a fixture by hand, so a generated timestamp reliably compares + * later than one a test supplied, without this fake ever reading the real + * clock. + */ + +const FAKE_EPOCH_MS = Date.UTC(2100, 0, 1) + +function validateReferenceName(name: string): void { + if (!/^[a-zA-Z0-9-]+$/.test(name)) { + throw new Error(`Invalid reference name: "${name}". Only alphanumeric characters and hyphens are allowed.`) + } +} + +function stripUserAnswers(quiz: Quiz): Quiz { + return { + ...quiz, + questions: quiz.questions.map(({ userAnswer: _u, correct: _c, ...rest }) => rest), + } +} + +export function createFakeBookRepository(): BookRepository { + const books = new Map() + const tocs = new Map() + const chapters = new Map>() + const quizzes = new Map>() + const finalQuizzes = new Map() + const feedback = new Map>() + const progress = new Map() + const briefs = new Map() + const summaries = new Map>() + const references = new Map>() + const referenceManifests = new Map() + let profile: LearningProfile | undefined + let profileUpdatedAt: string | null = null + + let tick = 0 + const nextTimestamp = (): string => new Date(FAKE_EPOCH_MS + ++tick * 1000).toISOString() + + function subMapFor(store: Map>, bookId: string): Map { + let byKey = store.get(bookId) + if (!byKey) { + byKey = new Map() + store.set(bookId, byKey) + } + return byKey + } + + return { + // --- Learning profile --- + + async getProfile(): Promise { + if (!profile) throw new NotFoundError('Learning profile has not been saved yet') + return structuredClone(profile) + }, + + async saveProfile(newProfile: LearningProfile): Promise { + profile = structuredClone(newProfile) + profileUpdatedAt = nextTimestamp() + }, + + async getProfileUpdatedAt(): Promise { + return profileUpdatedAt + }, + + // --- Book CRUD --- + + async listBooks(): Promise { + return [...books.values()] + .sort((a, b) => b.createdAt.localeCompare(a.createdAt)) + .map((meta) => structuredClone(meta)) + }, + + async getBook(bookId: string): Promise { + const meta = books.get(bookId) + if (!meta) throw new NotFoundError(`Book "${bookId}" not found`) + return structuredClone(meta) + }, + + async saveBook(meta: BookMeta): Promise { + books.set(meta.id, structuredClone(meta)) + }, + + async deleteBook(bookId: string): Promise { + books.delete(bookId) + tocs.delete(bookId) + chapters.delete(bookId) + quizzes.delete(bookId) + finalQuizzes.delete(bookId) + feedback.delete(bookId) + progress.delete(bookId) + briefs.delete(bookId) + summaries.delete(bookId) + references.delete(bookId) + referenceManifests.delete(bookId) + }, + + async resetBook(bookId: string): Promise { + const meta = books.get(bookId) + if (!meta) throw new NotFoundError(`Book "${bookId}" not found`) + if (isGenerating(meta.status)) { + throw new Error(`Cannot reset book "${bookId}" while it is generating`) + } + + progress.delete(bookId) + feedback.delete(bookId) + + const quizzesForBook = quizzes.get(bookId) + if (quizzesForBook) { + for (const [num, quiz] of quizzesForBook) { + quizzesForBook.set(num, stripUserAnswers(quiz)) + } + } + + const finalQuiz = finalQuizzes.get(bookId) + if (finalQuiz) finalQuizzes.set(bookId, stripUserAnswers(finalQuiz)) + + const { rating: _r, finalQuizScore: _s, finalQuizTotal: _t, ...rest } = meta + books.set(bookId, { ...rest, status: 'reading', updatedAt: nextTimestamp() }) + }, + + // --- Table of contents --- + + async getToc(bookId: string): Promise { + const toc = tocs.get(bookId) + if (!toc) throw new NotFoundError(`Table of contents for book "${bookId}" not found`) + return structuredClone(toc) + }, + + async saveToc(bookId: string, toc: Toc): Promise { + tocs.set(bookId, structuredClone(toc)) + }, + + // --- Chapters --- + + async getChapter(bookId: string, chapterNum: number): Promise { + const content = chapters.get(bookId)?.get(chapterNum) + if (content === undefined) throw new NotFoundError(`Chapter ${chapterNum} of book "${bookId}" not found`) + return content + }, + + async saveChapter(bookId: string, chapterNum: number, content: string): Promise { + subMapFor(chapters, bookId).set(chapterNum, content) + }, + + async chapterExists(bookId: string, chapterNum: number): Promise { + return chapters.get(bookId)?.has(chapterNum) ?? false + }, + + // --- Per chapter quiz --- + + async getQuiz(bookId: string, chapterNum: number): Promise { + const quiz = quizzes.get(bookId)?.get(chapterNum) + if (!quiz) throw new NotFoundError(`Quiz for chapter ${chapterNum} of book "${bookId}" not found`) + return structuredClone(quiz) + }, + + async saveQuiz(bookId: string, chapterNum: number, quiz: Quiz): Promise { + subMapFor(quizzes, bookId).set(chapterNum, structuredClone(quiz)) + }, + + async quizExists(bookId: string, chapterNum: number): Promise { + return quizzes.get(bookId)?.has(chapterNum) ?? false + }, + + // --- Final quiz --- + + async getFinalQuiz(bookId: string): Promise { + const quiz = finalQuizzes.get(bookId) + if (!quiz) throw new NotFoundError(`Final quiz for book "${bookId}" not found`) + return structuredClone(quiz) + }, + + async saveFinalQuiz(bookId: string, quiz: Quiz): Promise { + finalQuizzes.set(bookId, structuredClone(quiz)) + }, + + finalQuizExists(bookId: string): boolean { + return finalQuizzes.has(bookId) + }, + + // --- Feedback --- + + async getFeedback(bookId: string, chapterNum: number): Promise { + const fb = feedback.get(bookId)?.get(chapterNum) + if (!fb) throw new NotFoundError(`Feedback for chapter ${chapterNum} of book "${bookId}" not found`) + return structuredClone(fb) + }, + + async saveFeedback(bookId: string, chapterNum: number, fb: Feedback): Promise { + subMapFor(feedback, bookId).set(chapterNum, structuredClone(fb)) + }, + + async getAllFeedback(bookId: string): Promise { + const byNum = feedback.get(bookId) + if (!byNum) return [] + return [...byNum.entries()] + .sort((a, b) => a[0] - b[0]) + .map(([, fb]) => structuredClone(fb)) + }, + + // --- Progress --- + + async getProgress(bookId: string): Promise { + const current = progress.get(bookId) + return current ? structuredClone(current) : { chapters: {} } + }, + + async saveChapterProgress(bookId: string, chapterNum: number, chapterProgress: ChapterProgress): Promise { + const current = progress.get(bookId) ?? { chapters: {} } + current.chapters[String(chapterNum)] = structuredClone(chapterProgress) + progress.set(bookId, current) + }, + + async getChaptersRead(bookId: string): Promise { + const current = progress.get(bookId) ?? { chapters: {} } + return Object.values(current.chapters).filter((ch) => ch.completed).length + }, + + async getSkillProgress(): Promise { + const allBooks = [...books.values()].sort((a, b) => b.createdAt.localeCompare(a.createdAt)) + + let totalBooks = 0 + let completedBooks = 0 + let totalChapters = 0 + let completedChapters = 0 + + const skillMap = new Map< + string, + { + name: string + totalWeight: number + completedWeight: number + books: Array<{ bookId: string; title: string; weight: number; completed: boolean; lastActivityAt?: string }> + subskills: Map + } + >() + + for (const book of allBooks) { + const toc = tocs.get(book.id) + if (!toc) continue + if (!toc.skills || toc.skills.length === 0) continue + const bookProgress = progress.get(book.id) ?? { chapters: {} } + + totalBooks++ + const chapCount = toc.chapters.length + totalChapters += chapCount + + let bookCompletedChapters = 0 + let bookLastActivity: string | undefined + for (let i = 1; i <= chapCount; i++) { + const ch = bookProgress.chapters[String(i)] + if (ch?.completed) { + bookCompletedChapters++ + if (ch.completedAt && (!bookLastActivity || ch.completedAt > bookLastActivity)) { + bookLastActivity = ch.completedAt + } + } + } + if (!bookLastActivity) bookLastActivity = book.updatedAt + completedChapters += bookCompletedChapters + const bookComplete = bookCompletedChapters === chapCount + + if (bookComplete) completedBooks++ + + for (const skill of toc.skills) { + let entry = skillMap.get(skill.name) + if (!entry) { + entry = { name: skill.name, totalWeight: 0, completedWeight: 0, books: [], subskills: new Map() } + skillMap.set(skill.name, entry) + } + entry.totalWeight += skill.weight + if (bookComplete) entry.completedWeight += skill.weight + entry.books.push({ + bookId: book.id, + title: book.title, + weight: skill.weight, + completed: bookComplete, + lastActivityAt: bookLastActivity, + }) + } + + for (let i = 0; i < toc.chapters.length; i++) { + const ch = toc.chapters[i] + if (!ch.skills) continue + const chapterCompleted = !!bookProgress.chapters[String(i + 1)]?.completed + + for (const cs of ch.skills) { + const skillEntry = skillMap.get(cs.skill) + if (!skillEntry) continue + + let sub = skillEntry.subskills.get(cs.subskill) + if (!sub) { + sub = { name: cs.subskill, totalWeight: 0, completedWeight: 0 } + skillEntry.subskills.set(cs.subskill, sub) + } + sub.totalWeight += cs.weight + if (chapterCompleted) sub.completedWeight += cs.weight + } + } + } + + const skills = Array.from(skillMap.values()).map((s) => { + const bookDates = s.books.map((b) => b.lastActivityAt).filter((d): d is string => Boolean(d)) + const lastActivityAt = bookDates.length > 0 ? bookDates.sort().pop() : undefined + return { ...s, lastActivityAt, subskills: Array.from(s.subskills.values()) } + }) + + return { + stats: { totalBooks, completedBooks, totalChapters, completedChapters }, + skills, + } + }, + + // --- Brief --- + + async saveBrief(bookId: string, content: string): Promise { + briefs.set(bookId, content) + }, + + async getBrief(bookId: string): Promise { + const brief = briefs.get(bookId) + if (brief === undefined) throw new NotFoundError(`Brief for book "${bookId}" not found`) + return brief + }, + + // --- Chapter summaries --- + + async saveSummary(bookId: string, chapterNum: number, summary: ChapterSummary): Promise { + subMapFor(summaries, bookId).set(chapterNum, structuredClone(summary)) + }, + + async getSummary(bookId: string, chapterNum: number): Promise { + const summary = summaries.get(bookId)?.get(chapterNum) + if (!summary) throw new NotFoundError(`Summary for chapter ${chapterNum} of book "${bookId}" not found`) + return structuredClone(summary) + }, + + async getAllSummaries(bookId: string): Promise { + const byNum = summaries.get(bookId) + if (!byNum) return [] + return [...byNum.entries()] + .sort((a, b) => a[0] - b[0]) + .map(([, summary]) => structuredClone(summary)) + }, + + // --- References --- + + async saveReference(bookId: string, name: string, content: string): Promise { + validateReferenceName(name) + subMapFor(references, bookId).set(name, content) + + const manifest = referenceManifests.get(bookId) ?? [] + const idx = manifest.findIndex((e) => e.name === name) + const nextManifest = [...manifest] + if (idx >= 0) { + nextManifest[idx] = { ...nextManifest[idx], name } + } else { + nextManifest.push({ name }) + } + referenceManifests.set(bookId, nextManifest) + }, + + async getReference(bookId: string, name: string): Promise { + validateReferenceName(name) + const content = references.get(bookId)?.get(name) + if (content === undefined) throw new NotFoundError(`Reference "${name}" of book "${bookId}" not found`) + return content + }, + + async listReferences(bookId: string): Promise { + const manifest = referenceManifests.get(bookId) + return manifest ? structuredClone(manifest) : [] + }, + } +} diff --git a/server/ports/book-repository.ts b/server/ports/book-repository.ts new file mode 100644 index 0000000..c38d689 --- /dev/null +++ b/server/ports/book-repository.ts @@ -0,0 +1,160 @@ +import type { + BookMeta, + Toc, + Progress, + ChapterProgress, + Feedback, + Quiz, + LearningProfile, + ChapterSummary, + ReferenceManifest, +} from '@shared/domain.js' +import type { SkillProgress } from '@shared/responses.js' + +/** + * The port that server/services/book-store.ts stands in front of today for + * every structured piece of a book, meaning YAML metadata and Markdown text + * rather than binary files. Books, tables of contents, chapters, per + * chapter and final quizzes, feedback, reading progress, the global + * learning profile, generation briefs, chapter summaries, and reference + * manifests all live behind this one interface. + * + * A service depends on this shape instead of importing Node's filesystem + * module, the yaml package, or a path helper directly, so it can be unit + * tested against createFakeBookRepository() and later pointed at a real + * filesystem, or a database, without changing a single call site. + * + * Binary artifacts such as covers, EPUB files, and audiobook audio are + * deliberately out of scope here. See artifact-store.ts for those, and for + * why they get a separate, filesystem shaped port instead of living here. + */ + +/** + * Thrown by a get method when the entity it asked for was never saved. The + * code property is set to ENOENT on purpose, matching the property Node + * puts on the filesystem errors the real store throws today, so the + * server's error handler can turn either one into the same 404 response + * without knowing whether it is talking to the fake or the real adapter. A + * future real adapter satisfies this contract by rejecting with anything + * that carries that same code, its own Node fs error included, rather than + * by throwing this exact class. + */ +export class NotFoundError extends Error { + readonly code = 'ENOENT' + + constructor(message: string) { + super(message) + this.name = 'NotFoundError' + } +} + +export interface BookRepository { + // --- Learning profile, global rather than per book --- + + /** Rejects with NotFoundError when no profile has been saved yet. */ + getProfile(): Promise + saveProfile(profile: LearningProfile): Promise + /** Resolves to null when no profile has been saved yet, rather than rejecting. */ + getProfileUpdatedAt(): Promise + + // --- Book CRUD --- + + /** Resolves to an empty array when no book has been saved. Sorted by createdAt, newest first. */ + listBooks(): Promise + /** Rejects with NotFoundError when no book has been saved under this id. */ + getBook(bookId: string): Promise + saveBook(meta: BookMeta): Promise + /** Resolves without error when the book was never saved, the same as a plain no-op. */ + deleteBook(bookId: string): Promise + /** + * Clears reading interaction, meaning progress, feedback, and quiz + * answers, while keeping generated content such as chapters and the + * table of contents. Rejects while the book's status is generating or + * generating_toc, and rejects with NotFoundError when the book itself + * has not been saved. + */ + resetBook(bookId: string): Promise + + // --- Table of contents --- + + /** Rejects with NotFoundError when the table of contents has not been saved yet. */ + getToc(bookId: string): Promise + saveToc(bookId: string, toc: Toc): Promise + + // --- Chapters --- + + /** Rejects with NotFoundError when this chapter has not been saved yet. */ + getChapter(bookId: string, chapterNum: number): Promise + saveChapter(bookId: string, chapterNum: number, content: string): Promise + chapterExists(bookId: string, chapterNum: number): Promise + + // --- Per chapter quiz --- + + /** Rejects with NotFoundError when this chapter has no quiz saved yet. */ + getQuiz(bookId: string, chapterNum: number): Promise + saveQuiz(bookId: string, chapterNum: number, quiz: Quiz): Promise + quizExists(bookId: string, chapterNum: number): Promise + + // --- Final quiz --- + + /** Rejects with NotFoundError when the final quiz has not been saved yet. */ + getFinalQuiz(bookId: string): Promise + saveFinalQuiz(bookId: string, quiz: Quiz): Promise + /** + * The one existence check the real store answers synchronously rather + * than through a promise. This port preserves that instead of smoothing + * every method into a uniform async shape. + */ + finalQuizExists(bookId: string): boolean + + // --- Feedback --- + + /** Rejects with NotFoundError when this chapter has no feedback saved yet. */ + getFeedback(bookId: string, chapterNum: number): Promise + saveFeedback(bookId: string, chapterNum: number, feedback: Feedback): Promise + /** Resolves to an empty array when the book has no feedback at all, rather than rejecting. */ + getAllFeedback(bookId: string): Promise + + // --- Progress --- + + /** + * Resolves to an empty progress record when nothing has been saved yet, + * rather than rejecting. This is the one get method on the whole port + * that always succeeds. + */ + getProgress(bookId: string): Promise + saveChapterProgress(bookId: string, chapterNum: number, progress: ChapterProgress): Promise + /** Derived from progress rather than stored on its own, counting the chapters marked completed. */ + getChaptersRead(bookId: string): Promise + /** + * Skill mastery rolled up across every saved book, derived from each + * book's table of contents and progress rather than stored on its own. + * A book contributes nothing unless its table of contents declares + * skills. See the contract test for the exact aggregation rules, worked + * through on a small example. + */ + getSkillProgress(): Promise + + // --- Brief, the generation input a book was built from --- + + saveBrief(bookId: string, content: string): Promise + /** Rejects with NotFoundError when no brief has been saved for this book. */ + getBrief(bookId: string): Promise + + // --- Chapter summaries --- + + saveSummary(bookId: string, chapterNum: number, summary: ChapterSummary): Promise + /** Rejects with NotFoundError when this chapter has no summary saved yet. */ + getSummary(bookId: string, chapterNum: number): Promise + /** Resolves to an empty array when the book has no summaries at all. */ + getAllSummaries(bookId: string): Promise + + // --- References, source material a book was grounded in --- + + /** Rejects when name contains anything other than letters, digits, and hyphens. */ + saveReference(bookId: string, name: string, content: string): Promise + /** Rejects when name is malformed, or with NotFoundError when no reference of this name has been saved. */ + getReference(bookId: string, name: string): Promise + /** Resolves to an empty array when the book has no references at all. */ + listReferences(bookId: string): Promise +} From aebda92f09bb0eddc51f5936edfc7f1861b385d4 Mon Sep 17 00:00:00 2001 From: Ross Miller Date: Mon, 20 Jul 2026 20:20:15 -0500 Subject: [PATCH 011/126] feat(ports): add ArtifactStore port, fake, and contract test This defines the ArtifactStore interface for every binary artifact a book can have, covering the cover image, the exported EPUB file, audiobook audio and its manifest, and startup crash recovery. The port is deliberately filesystem shaped rather than a byte stream abstraction, because ffmpeg needs a real path to assemble an audiobook and the streaming route needs a real path to answer an HTTP Range request. The header comment explains why hiding that behind a stream would be a false abstraction rather than a real one. It also adds writeEpub, a method the real store does not export today. Its callers currently read epubPath and write the file themselves with the same mkdir, temp file, and rename sequence saveCover already uses, so this formalizes that sequence as one method rather than leaving it duplicated at the call site. recoverFromCrash surfaces a real seam between the two ports. Book status recovery is BookRepository data that ArtifactStore cannot see on its own, so this port's contract only pins the half it can own, that a saved audiobook manifest with no matching audiobook file is a stray and gets removed on recovery. The header comment on CrashRecoveryReport records this for whoever wires the real adapters together next. It adds an in-memory fake that roots its plausible paths under a caller supplied root, and a shared contract test that asserts path consistency rather than exact strings, so a real adapter rooted elsewhere still satisfies it. This adds twenty four passing tests and touches no existing file. Claude-Session: https://claude.ai/code/session_01FSkESkJTRpt3Ye2cM7c5n5 --- server/ports/artifact-store.contract.ts | 195 +++++++++++++++++++++++ server/ports/artifact-store.fake.test.ts | 35 ++++ server/ports/artifact-store.fake.ts | 140 ++++++++++++++++ server/ports/artifact-store.ts | 96 +++++++++++ 4 files changed, 466 insertions(+) create mode 100644 server/ports/artifact-store.contract.ts create mode 100644 server/ports/artifact-store.fake.test.ts create mode 100644 server/ports/artifact-store.fake.ts create mode 100644 server/ports/artifact-store.ts diff --git a/server/ports/artifact-store.contract.ts b/server/ports/artifact-store.contract.ts new file mode 100644 index 0000000..331749f --- /dev/null +++ b/server/ports/artifact-store.contract.ts @@ -0,0 +1,195 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import type { AudiobookManifest } from '@shared/domain.js' +import type { ArtifactStore } from './artifact-store.js' + +/** + * The behavioural specification every ArtifactStore must satisfy, whether + * it is the in-memory fake or a future real adapter. Run this once against + * each with describeArtifactStoreContract(label, makeSubject), rather than + * writing the same assertions twice and letting them drift apart. + * + * Path-returning methods are asserted for consistency, meaning the same + * inputs produce the same path and different inputs produce different + * paths, rather than for an exact string. A real adapter rooted at a + * different directory than the fake still satisfies every assertion here. + */ + +function makeManifest(overrides: Partial = {}): AudiobookManifest { + return { + version: 1, + voice: 'am_michael', + speed: 1, + generatedAt: '2026-01-01T00:00:00.000Z', + m4bPath: 'book.m4b', + chapters: [ + { num: 1, title: 'Chapter 1', mp3Path: '01.mp3', durationSec: 120, startSec: 0 }, + ], + ...overrides, + } +} + +export function describeArtifactStoreContract( + label: string, + makeSubject: () => ArtifactStore | Promise, +): void { + describe(`ArtifactStore contract (${label})`, () => { + let store: ArtifactStore + + beforeEach(async () => { + store = await makeSubject() + }) + + describe('cover image', () => { + it('agrees that no cover exists before one is saved', async () => { + expect(await store.getCoverPath('book-1')).toBeNull() + expect(await store.hasCover('book-1')).toBe(false) + expect(await store.getCoverMtime('book-1')).toBeNull() + }) + + it('agrees that a cover exists once one is saved', async () => { + await store.saveCover('book-1', Buffer.from('fake image bytes'), 'image/png') + expect(await store.hasCover('book-1')).toBe(true) + expect(await store.getCoverPath('book-1')).not.toBeNull() + expect(await store.getCoverMtime('book-1')).toBeInstanceOf(Date) + }) + + it('gives the same path back on repeated reads, and different paths for different books', async () => { + await store.saveCover('book-1', Buffer.from('a'), 'image/png') + await store.saveCover('book-2', Buffer.from('b'), 'image/png') + + const first = await store.getCoverPath('book-1') + const second = await store.getCoverPath('book-1') + expect(first).toBe(second) + expect(await store.getCoverPath('book-2')).not.toBe(first) + }) + + it('advances the mtime on a second save', async () => { + await store.saveCover('book-1', Buffer.from('a'), 'image/png') + const first = await store.getCoverMtime('book-1') + await store.saveCover('book-1', Buffer.from('b'), 'image/jpeg') + const second = await store.getCoverMtime('book-1') + expect(second!.getTime()).toBeGreaterThan(first!.getTime()) + }) + + it('makes a cover unreadable after deleting it', async () => { + await store.saveCover('book-1', Buffer.from('a'), 'image/png') + await store.deleteCover('book-1') + expect(await store.hasCover('book-1')).toBe(false) + expect(await store.getCoverPath('book-1')).toBeNull() + expect(await store.getCoverMtime('book-1')).toBeNull() + }) + + it('does not throw when deleting a cover that was never saved', async () => { + await expect(store.deleteCover('never-had-one')).resolves.not.toThrow() + }) + }) + + describe('epub export', () => { + it('reports no epub before one is written', () => { + expect(store.epubExists('book-1')).toBe(false) + }) + + it('agrees an epub exists once one is written', async () => { + await store.writeEpub('book-1', Buffer.from('fake epub bytes')) + expect(store.epubExists('book-1')).toBe(true) + }) + + it('gives a stable path per book, and a different path for a different book', () => { + expect(store.epubPath('book-1')).toBe(store.epubPath('book-1')) + expect(store.epubPath('book-1')).not.toBe(store.epubPath('book-2')) + }) + }) + + describe('audiobook paths', () => { + it('gives stable, book-scoped paths for the audiobook file and its directory', () => { + expect(store.audiobookPath('book-1')).toBe(store.audiobookPath('book-1')) + expect(store.audiobookPath('book-1')).not.toBe(store.audiobookPath('book-2')) + expect(store.audioDir('book-1')).toBe(store.audioDir('book-1')) + expect(store.audioDir('book-1')).not.toBe(store.audioDir('book-2')) + }) + + it('gives stable, chapter-scoped paths that live under the book’s audio directory', () => { + const mp3 = store.chapterAudioPath('book-1', 1) + const wav = store.chapterWavPath('book-1', 1) + + expect(store.chapterAudioPath('book-1', 1)).toBe(mp3) + expect(store.chapterAudioPath('book-1', 2)).not.toBe(mp3) + expect(store.chapterAudioPath('book-2', 1)).not.toBe(mp3) + expect(mp3).not.toBe(wav) + + expect(mp3.startsWith(store.audioDir('book-1'))).toBe(true) + expect(wav.startsWith(store.audioDir('book-1'))).toBe(true) + }) + + it('reports no audiobook before one has ever been produced', async () => { + expect(store.audiobookExists('book-1')).toBe(false) + }) + }) + + describe('chapter audio existence', () => { + it('reports false for a chapter with no manifest entry', async () => { + expect(await store.chapterAudioExists('book-1', 1)).toBe(false) + }) + + it('agrees with the manifest once one is saved', async () => { + await store.saveAudiobookManifest('book-1', makeManifest({ + chapters: [ + { num: 1, title: 'Chapter 1', mp3Path: '01.mp3', durationSec: 60, startSec: 0 }, + { num: 2, title: 'Chapter 2', mp3Path: '02.mp3', durationSec: 60, startSec: 60 }, + ], + })) + + expect(await store.chapterAudioExists('book-1', 1)).toBe(true) + expect(await store.chapterAudioExists('book-1', 2)).toBe(true) + expect(await store.chapterAudioExists('book-1', 3)).toBe(false) + }) + }) + + describe('audiobook manifest', () => { + it('resolves to null before one is saved', async () => { + expect(await store.getAudiobookManifest('book-1')).toBeNull() + }) + + it('round trips a saved manifest', async () => { + await store.saveAudiobookManifest('book-1', makeManifest({ voice: 'onyx' })) + const manifest = await store.getAudiobookManifest('book-1') + expect(manifest?.voice).toBe('onyx') + expect(manifest?.chapters).toHaveLength(1) + }) + + it('removes the manifest and chapter audio once artifacts are deleted', async () => { + await store.saveAudiobookManifest('book-1', makeManifest()) + await store.deleteAudiobookArtifacts('book-1') + + expect(await store.getAudiobookManifest('book-1')).toBeNull() + expect(await store.chapterAudioExists('book-1', 1)).toBe(false) + }) + + it('does not throw when deleting audiobook artifacts that were never saved', async () => { + await expect(store.deleteAudiobookArtifacts('never-had-one')).resolves.not.toThrow() + }) + }) + + describe('crash recovery', () => { + it('is a no-op when nothing needs recovering', async () => { + const report = await store.recoverFromCrash() + expect(report).toEqual({ booksReset: [], artifactsRemoved: [] }) + }) + + it('treats a saved manifest with no matching audiobook file as a stray and removes it', async () => { + await store.saveAudiobookManifest('book-1', makeManifest()) + + const report = await store.recoverFromCrash() + + expect(report.artifactsRemoved).toContain(store.audioDir('book-1')) + expect(await store.getAudiobookManifest('book-1')).toBeNull() + }) + + it('never reports a book status change, since that is BookRepository data this port cannot see', async () => { + await store.saveAudiobookManifest('book-1', makeManifest()) + const report = await store.recoverFromCrash() + expect(report.booksReset).toEqual([]) + }) + }) + }) +} diff --git a/server/ports/artifact-store.fake.test.ts b/server/ports/artifact-store.fake.test.ts new file mode 100644 index 0000000..5085fc2 --- /dev/null +++ b/server/ports/artifact-store.fake.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest' +import { createFakeArtifactStore } from './artifact-store.fake.js' +import { describeArtifactStoreContract } from './artifact-store.contract.js' + +/** + * Proves the fake satisfies the port's behavioural contract today. A future + * real adapter gets the same describeArtifactStoreContract call, pointed at + * a temp directory, so the two never drift apart silently. + */ + +describeArtifactStoreContract('fake', () => createFakeArtifactStore()) + +describe('createFakeArtifactStore, fake-specific behaviour', () => { + it('is isolated per call, so two fakes never share state', async () => { + const a = createFakeArtifactStore() + const b = createFakeArtifactStore() + + await a.writeEpub('book-1', Buffer.from('epub bytes')) + + expect(a.epubExists('book-1')).toBe(true) + expect(b.epubExists('book-1')).toBe(false) + }) + + it('roots paths under a caller-supplied root, still consistently', () => { + const store = createFakeArtifactStore({ root: '/custom-root' }) + expect(store.epubPath('book-1').startsWith('/custom-root')).toBe(true) + expect(store.audioDir('book-1').startsWith('/custom-root')).toBe(true) + }) + + it('roots paths differently across two fakes with different roots, proving the contract is root-agnostic', () => { + const a = createFakeArtifactStore({ root: '/root-a' }) + const b = createFakeArtifactStore({ root: '/root-b' }) + expect(a.epubPath('book-1')).not.toBe(b.epubPath('book-1')) + }) +}) diff --git a/server/ports/artifact-store.fake.ts b/server/ports/artifact-store.fake.ts new file mode 100644 index 0000000..6001bdb --- /dev/null +++ b/server/ports/artifact-store.fake.ts @@ -0,0 +1,140 @@ +import type { AudiobookManifest } from '@shared/domain.js' +import { type ArtifactStore, type CrashRecoveryReport } from './artifact-store.js' + +/** + * An in-memory ArtifactStore for unit tests and for the contract test + * itself. Path-returning methods build plausible strings under a fake + * root rather than touching the filesystem, so the contract can assert + * that paths are stable and distinct without ever asserting an exact + * string, which is what lets a real adapter rooted somewhere else satisfy + * the same contract. + * + * This fake cannot make audiobookExists or the legacy branch of + * chapterAudioExists true, because nothing on the ArtifactStore interface + * writes the audiobook file itself. Real audio bytes come from ffmpeg, + * writing straight to the path this port hands out, which is exactly the + * filesystem dependency the header comment on artifact-store.ts explains. + * A contract that only exercises this port's own methods can only ever + * observe that file as absent, and that is a faithful reflection of the + * real adapter's behaviour too, for as long as a test never reaches around + * the port to create the file directly. + */ + +const FAKE_MTIME_EPOCH_MS = Date.UTC(2100, 0, 1) + +function extensionFor(mediaType: string): string { + if (mediaType === 'image/jpeg') return 'jpg' + if (mediaType === 'image/webp') return 'webp' + return 'png' +} + +function pad(chapterNum: number): string { + return String(chapterNum).padStart(2, '0') +} + +export function createFakeArtifactStore(options: { root?: string } = {}): ArtifactStore { + const root = options.root ?? '/fake-artifacts' + + const covers = new Map() + const epubs = new Set() + // Never populated through this port's own interface. See the header + // comment on this file for why, and the crash recovery test in + // artifact-store.contract.ts for the one behaviour that follows from it. + const audiobooksPresent = new Set() + const manifests = new Map() + + let tick = 0 + const nextMtime = (): Date => new Date(FAKE_MTIME_EPOCH_MS + ++tick * 1000) + + const audioDir = (bookId: string): string => `${root}/${bookId}/audio` + const audiobookPath = (bookId: string): string => `${root}/${bookId}/book.m4b` + const epubPath = (bookId: string): string => `${root}/${bookId}/book.epub` + const chapterAudioPath = (bookId: string, chapterNum: number): string => + `${audioDir(bookId)}/${pad(chapterNum)}.mp3` + const chapterWavPath = (bookId: string, chapterNum: number): string => + `${audioDir(bookId)}/${pad(chapterNum)}.wav` + + return { + // --- Cover image --- + + async getCoverPath(bookId: string): Promise { + const cover = covers.get(bookId) + return cover ? `${root}/${bookId}/cover.${cover.ext}` : null + }, + + async hasCover(bookId: string): Promise { + return covers.has(bookId) + }, + + async getCoverMtime(bookId: string): Promise { + return covers.get(bookId)?.mtime ?? null + }, + + async saveCover(bookId: string, _data: Buffer, mediaType: string): Promise { + covers.set(bookId, { ext: extensionFor(mediaType), mtime: nextMtime() }) + }, + + async deleteCover(bookId: string): Promise { + covers.delete(bookId) + }, + + // --- EPUB export --- + + epubPath, + + epubExists(bookId: string): boolean { + return epubs.has(bookId) + }, + + async writeEpub(bookId: string, _data: Buffer): Promise { + epubs.add(bookId) + }, + + // --- Audiobook --- + + audiobookPath, + + audiobookExists(bookId: string): boolean { + return audiobooksPresent.has(bookId) + }, + + audioDir, + chapterAudioPath, + chapterWavPath, + + async chapterAudioExists(bookId: string, chapterNum: number): Promise { + const manifest = manifests.get(bookId) + return !!manifest?.chapters.some((c) => c.num === chapterNum) + }, + + async getAudiobookManifest(bookId: string): Promise { + const manifest = manifests.get(bookId) + return manifest ? structuredClone(manifest) : null + }, + + async saveAudiobookManifest(bookId: string, manifest: AudiobookManifest): Promise { + manifests.set(bookId, structuredClone(manifest)) + }, + + async deleteAudiobookArtifacts(bookId: string): Promise { + audiobooksPresent.delete(bookId) + manifests.delete(bookId) + }, + + // --- Crash recovery --- + + async recoverFromCrash(): Promise { + const report: CrashRecoveryReport = { booksReset: [], artifactsRemoved: [] } + for (const bookId of manifests.keys()) { + // Mirrors book-store.ts: a saved manifest with no matching + // audiobook file is a stray left by an interrupted generation, so + // recovery clears it. audiobooksPresent can never hold this id + // through this port alone, which is the point explained above. + if (audiobooksPresent.has(bookId)) continue + manifests.delete(bookId) + report.artifactsRemoved.push(audioDir(bookId)) + } + return report + }, + } +} diff --git a/server/ports/artifact-store.ts b/server/ports/artifact-store.ts new file mode 100644 index 0000000..12dc344 --- /dev/null +++ b/server/ports/artifact-store.ts @@ -0,0 +1,96 @@ +import type { AudiobookManifest } from '@shared/domain.js' + +/** + * The port that server/services/book-store.ts stands in front of today for + * every binary artifact a book can have. Covers, the exported EPUB file, + * audiobook audio and its manifest, plus startup crash recovery for all of + * the above. + * + * Unlike BookRepository, this port is deliberately filesystem shaped. The + * epub, audiobook, and chapter audio methods hand back real paths rather + * than an opaque handle, because ffmpeg needs a real path to read and write + * as it assembles an audiobook, and the audiobook streaming route needs a + * real path to answer an HTTP Range request efficiently. Wrapping those in + * a byte-stream abstraction would just push the same filesystem dependency + * one level down into every caller instead of removing it, which is a false + * abstraction rather than a real one. What this port still buys a service + * is independence from exactly where those paths live and from the + * mkdir and tmp file and rename mechanics of writing them safely. + * + * A path returned by this port is a promise about shape, not about exact + * value. Two different adapters are free to root their books under + * different directories, so a caller may rely on a path being stable for a + * given book and chapter and different across books and chapters, but must + * not depend on its exact string value. + */ + +/** + * What a startup crash recovery pass changed. Mirrors the shape + * server/services/book-store.ts returns today. + * + * artifactsRemoved is fully owned by this port, and lists every stray + * artifact this pass deleted. booksReset is part of the shape for + * compatibility with that existing report, but a book's status is + * BookRepository data, which this port cannot see or change on its own. + * An adapter that only implements ArtifactStore therefore always returns + * booksReset as an empty array. Reconciling a book's status after a crash, + * the way book-store.ts does today in one combined pass, is a composition + * level concern that reads both ports, not something either port should + * own alone. See the contract test for the one crash recovery rule this + * port can pin by itself, that a saved audiobook manifest without a + * matching audiobook file is treated as a stray and removed. + */ +export interface CrashRecoveryReport { + booksReset: Array<{ id: string; title: string; from: string; to: string; reason: string }> + artifactsRemoved: string[] +} + +export interface ArtifactStore { + // --- Cover image --- + + /** Resolves to null when no cover has been saved. */ + getCoverPath(bookId: string): Promise + hasCover(bookId: string): Promise + /** Resolves to null when no cover has been saved. */ + getCoverMtime(bookId: string): Promise + /** Replaces any existing cover for this book, whatever image type it was saved as. */ + saveCover(bookId: string, data: Buffer, mediaType: string): Promise + /** Resolves without error when the book had no cover to delete. */ + deleteCover(bookId: string): Promise + + // --- EPUB export --- + + /** A path this port hands out, not a claim that the file exists yet. Pair with epubExists. */ + epubPath(bookId: string): string + epubExists(bookId: string): boolean + /** + * Not a method book-store.ts exports today. Its callers currently read + * epubPath() and write the file themselves with the same mkdir, tmp + * file, and rename sequence saveCover and saveChapter already use. This + * method exists so ArtifactStore owns that sequence once, the same way + * it already does for every other artifact. + */ + writeEpub(bookId: string, data: Buffer): Promise + + // --- Audiobook --- + + /** A path this port hands out, not a claim that the file exists yet. Pair with audiobookExists. */ + audiobookPath(bookId: string): string + audiobookExists(bookId: string): boolean + /** The directory ffmpeg and the speech synthesis port write chapter audio into. */ + audioDir(bookId: string): string + chapterAudioPath(bookId: string, chapterNum: number): string + chapterWavPath(bookId: string, chapterNum: number): string + /** True once the manifest lists this chapter, regardless of whether a legacy per-chapter file also exists. */ + chapterAudioExists(bookId: string, chapterNum: number): Promise + /** Resolves to null when no audiobook has been generated. */ + getAudiobookManifest(bookId: string): Promise + saveAudiobookManifest(bookId: string, manifest: AudiobookManifest): Promise + /** Removes the audiobook file, every chapter audio file, and the manifest, so a regeneration starts clean. */ + deleteAudiobookArtifacts(bookId: string): Promise + + // --- Crash recovery --- + + /** Run once at startup. See CrashRecoveryReport for what this port can and cannot report. */ + recoverFromCrash(): Promise +} From 05c97dc581ceac62657975140de20b25125c6f16 Mon Sep 17 00:00:00 2001 From: Ross Miller Date: Mon, 20 Jul 2026 20:24:17 -0500 Subject: [PATCH 012/126] refactor(shared): keep the book status predicates free of zod The client imports these six predicates, so the zod value import that backed BookStatusSchema was dragging the whole validator into the renderer bundle for the sake of six string comparisons. BOOK_STATUSES is now a plain literal tuple and BookStatus is derived from it, so shared/book-status.ts has no runtime dependency at all. BookStatusSchema moves to shared/domain.ts, which already builds on zod, and is built with z.enum(BOOK_STATUSES) so the schema and the predicates cannot drift apart. Every client import path is untouched because the client only ever imported the predicates. Claude-Session: https://claude.ai/code/session_01FSkESkJTRpt3Ye2cM7c5n5 --- server/mcp-server.ts | 2 +- shared/book-status.ts | 22 ++++++++++++++-------- shared/domain.ts | 12 ++++++++++-- 3 files changed, 25 insertions(+), 11 deletions(-) diff --git a/server/mcp-server.ts b/server/mcp-server.ts index 2dfab52..2d17966 100644 --- a/server/mcp-server.ts +++ b/server/mcp-server.ts @@ -1,7 +1,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { z } from 'zod' -import { BookStatusSchema } from '@shared/book-status.js' +import { BookStatusSchema } from '@shared/domain.js' const API_URL = process.env.TUTOR_API_URL ?? 'http://127.0.0.1:3147' diff --git a/shared/book-status.ts b/shared/book-status.ts index 6eb43a6..2299012 100644 --- a/shared/book-status.ts +++ b/shared/book-status.ts @@ -1,5 +1,3 @@ -import { z } from 'zod' - /** * The lifecycle a book moves through, and the predicates both sides of the app * use to ask about it. @@ -14,21 +12,29 @@ import { z } from 'zod' * interfaces, so a narrower parameter would force casts at the call sites this * module is meant to simplify. Tightening those interfaces is a later phase's * job, and `shared/book-status.test.ts` covers the loose inputs meanwhile. + * + * DELIBERATELY FREE OF ZOD, and of every other runtime dependency. The client + * imports these predicates, so a value import of zod here would drag the whole + * validator into the renderer bundle for the sake of six string comparisons. + * The matching `BookStatusSchema` therefore lives in `shared/domain.ts`, which + * is server-side and already builds on zod, and it is derived from the + * `BOOK_STATUSES` tuple below so the two cannot drift apart. */ -export const BookStatusSchema = z.enum([ +/** + * Every status a book can hold, in lifecycle order. This tuple is the single + * source of truth. `shared/domain.ts` builds the Zod enum from it. + */ +export const BOOK_STATUSES = [ 'generating_toc', 'toc_review', 'generating', 'reading', 'complete', 'failed', -]) - -export type BookStatus = z.infer +] as const -/** Every status, in schema order. Useful for exhaustive iteration. */ -export const BOOK_STATUSES = BookStatusSchema.options +export type BookStatus = (typeof BOOK_STATUSES)[number] /** * The book is producing content right now, either its table of contents or a diff --git a/shared/domain.ts b/shared/domain.ts index dfd299a..1615ac6 100644 --- a/shared/domain.ts +++ b/shared/domain.ts @@ -1,5 +1,13 @@ import { z } from 'zod' -import { BookStatusSchema, type BookStatus } from './book-status.js' +import { BOOK_STATUSES, type BookStatus } from './book-status.js' + +/** + * Built here rather than in `shared/book-status.ts` so that module can stay + * free of zod. The client imports its predicates, and a value import of zod + * there would ship the validator in the renderer bundle. Derived from the + * BOOK_STATUSES tuple, so the schema and the predicates cannot drift. + */ +export const BookStatusSchema = z.enum(BOOK_STATUSES) /** * The entities the app persists and renders: learning profile, table of @@ -84,7 +92,7 @@ export type Toc = z.infer // --- Book Meta --- -export { BookStatusSchema, type BookStatus } +export { type BookStatus } export const BookMetaSchema = z.object({ id: z.string(), From 801ac1b87b9d0a66148ebe0d83d710728b1348c0 Mon Sep 17 00:00:00 2001 From: Ross Miller Date: Mon, 20 Jul 2026 20:25:38 -0500 Subject: [PATCH 013/126] feat: add the profile response contract type GET /api/profile does not return a LearningProfile. The stored profile keeps identity and style as separate fields, and the handler joins them into one aboutMe string before answering, so the wire shape and the stored shape are genuinely different types. The client was left to infer that difference. Derived from the handler like the other response types, with skills always present because the handler defaults it to an empty array. Claude-Session: https://claude.ai/code/session_01FSkESkJTRpt3Ye2cM7c5n5 --- shared/responses.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/shared/responses.ts b/shared/responses.ts index f4f276e..80373f9 100644 --- a/shared/responses.ts +++ b/shared/responses.ts @@ -1,5 +1,5 @@ import type { z } from 'zod' -import type { BookMeta } from './domain.js' +import type { BookMeta, LearningProfile } from './domain.js' import type { ImportEpubPreviewResponseSchema } from './contracts.js' /** @@ -117,6 +117,21 @@ export type VoiceInfo = { grade: string } +/** + * GET /api/profile — the learning profile as the client sees it. + * + * Deliberately NOT `LearningProfile`. The stored profile keeps `identity` and + * `style` as separate fields, and the handler joins them into a single + * `aboutMe` string before answering, so the wire shape and the stored shape + * are genuinely different types rather than one being an alias of the other. + * `skills` is always present on the wire, defaulted to an empty array. + */ +export type ProfileResponse = { + aboutMe: string + preferences: LearningProfile['preferences'] + skills: LearningProfile['skills'] +} + /** GET /api/audiobook/status — whether the narration engine is installed. */ export type AudiobookStatus = { installed: boolean From 55ee37be241c28e3c0b3af8b324acdfa0f1a1493 Mon Sep 17 00:00:00 2001 From: Ross Miller Date: Mon, 20 Jul 2026 20:26:21 -0500 Subject: [PATCH 014/126] refactor(ports): standardize the two port contracts the real adapters exposed Two port decisions the architect settled before adapter work begins. DiagramRenderer now owes readable fallback markup for a chart it cannot render, never an empty string. The Electron renderer has always emitted an escaped mermaid code block on failure so the reader sees the diagram's source instead of a hole in the page, which is plainly better than the empty string the kroki path emits, so it becomes the contract and the kroki adapter will adopt it. diagramSourceFallback owns that markup so no implementation can drift from what the others emit, and it escapes the source, which also keeps chart text from injecting markup into an EPUB. AudioAssembly.concatToM4b takes an optional coverPath. Cover embedding is real behaviour in today's stitch, and the half of it the caller owns, knowing whether a cover exists and where, belongs in the request. Retrying without the cover when embedding fails stays adapter internal resilience, so an adapter that cannot embed still produces a coverless M4B rather than rejecting, and callers never see the difference. Claude-Session: https://claude.ai/code/session_01FSkESkJTRpt3Ye2cM7c5n5 --- server/ports/audio-assembly.contract.ts | 15 +++++++++ server/ports/audio-assembly.ts | 18 +++++----- server/ports/diagram-renderer.contract.ts | 17 +++++++--- server/ports/diagram-renderer.fake.ts | 15 +++++---- server/ports/diagram-renderer.ts | 41 ++++++++++++++--------- 5 files changed, 72 insertions(+), 34 deletions(-) diff --git a/server/ports/audio-assembly.contract.ts b/server/ports/audio-assembly.contract.ts index 3e43b35..daca22b 100644 --- a/server/ports/audio-assembly.contract.ts +++ b/server/ports/audio-assembly.contract.ts @@ -84,6 +84,21 @@ export function describeAudioAssemblyContract( }), ).rejects.toThrow() }) + + it('still produces an M4B when a cover is requested, whether or not it embeds', async () => { + const controller = new AbortController() + const out = '/fake/audio/book-with-cover.m4b' + await subject.concatToM4b({ + inputs: ['/fake/audio/01.wav', '/fake/audio/02.wav'], + chapters, + out, + bitrate: '64k', + coverPath: '/fake/covers/book.png', + signal: controller.signal, + }) + const sec = await subject.probeDurationSec(out, controller.signal) + expect(sec).toBeGreaterThan(0) + }) }) }) } diff --git a/server/ports/audio-assembly.ts b/server/ports/audio-assembly.ts index 25a50f1..39dc285 100644 --- a/server/ports/audio-assembly.ts +++ b/server/ports/audio-assembly.ts @@ -25,14 +25,14 @@ import type { AudiobookChapterEntry } from '@shared/domain.js' * concat list and FFMETADATA1 files ffmpeg reads, and locating the ffmpeg * binary itself. * - * One real behaviour this first cut does not carry through. The current - * M4B stitch in audiobook-generator.ts also embeds a cover image when one - * is available, and retries the stitch without a cover if embedding fails. - * ConcatToM4bRequest has no coverPath field, matching the interface this - * port was specified against, so that behaviour is a known gap rather than - * a silent omission. Adding an optional coverPath, or deciding cover - * embedding belongs somewhere else entirely, is a design call for whoever - * builds the real adapter in a later task. + * Cover art is part of the request. The current M4B stitch in + * audiobook-generator.ts embeds a cover image when one is available and + * retries the stitch without a cover if embedding fails. The caller's half + * of that, deciding whether a cover exists and where it lives, belongs in + * the request as the optional coverPath below. The retry is adapter + * internal resilience, so an adapter that cannot embed the cover must + * still produce a coverless M4B rather than reject, and callers never see + * the difference. */ export interface ConcatToM4bRequest { @@ -44,6 +44,8 @@ export interface ConcatToM4bRequest { out: string /** ffmpeg audio bitrate, for example '64k'. */ bitrate: string + /** Cover art to embed, when the book has one. An adapter that cannot embed it still produces a coverless M4B rather than rejecting. */ + coverPath?: string signal: AbortSignal } diff --git a/server/ports/diagram-renderer.contract.ts b/server/ports/diagram-renderer.contract.ts index 47a50e1..32e1945 100644 --- a/server/ports/diagram-renderer.contract.ts +++ b/server/ports/diagram-renderer.contract.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach } from 'vitest' -import type { DiagramRenderer } from './diagram-renderer.js' +import { diagramSourceFallback, type DiagramRenderer } from './diagram-renderer.js' /** * The behavioural contract every DiagramRenderer implementation must @@ -28,15 +28,22 @@ export function describeDiagramRendererContract( expect(results).toHaveLength(charts.length) }) - it('yields an empty string rather than throwing for a chart that fails', async () => { + it('yields the escaped source fallback rather than throwing for a chart that fails', async () => { const results = await subject.render(['graph TD; A-->B', '']) - expect(results[1]).toBe('') + expect(results[1]).toBe(diagramSourceFallback('')) + }) + + it('never yields an empty string, because a reader deserves the source over a hole', async () => { + const results = await subject.render(['', ' ', 'graph TD; A-->B']) + for (const result of results) { + expect(result).not.toBe('') + } }) it('does not fail the whole batch when one chart fails', async () => { const results = await subject.render(['', 'graph TD; A-->B']) - expect(results[0]).toBe('') - expect(results[1]).not.toBe('') + expect(results[0]).toBe(diagramSourceFallback('')) + expect(results[1]).not.toBe(diagramSourceFallback('')) }) it('returns an empty array for an empty input array', async () => { diff --git a/server/ports/diagram-renderer.fake.ts b/server/ports/diagram-renderer.fake.ts index b50f7f7..585fbb0 100644 --- a/server/ports/diagram-renderer.fake.ts +++ b/server/ports/diagram-renderer.fake.ts @@ -1,12 +1,13 @@ -import type { DiagramRenderer } from './diagram-renderer.js' +import { diagramSourceFallback, type DiagramRenderer } from './diagram-renderer.js' /** * Deterministic in-memory DiagramRenderer. A chart whose source is empty * or whitespace only fails to render, standing in for the malformed - * diagram source a real renderer would reject. Every other chart succeeds - * with a synthetic, clearly fake markup string that echoes the chart's - * index, so ordering is easy to assert on without depending on real - * kroki.io or Electron markup shapes. + * diagram source a real renderer would reject, and yields the same + * escaped source fallback every real implementation owes. Every other + * chart succeeds with a synthetic, clearly fake markup string that echoes + * the chart's index, so ordering is easy to assert on without depending on + * real kroki.io or Electron markup shapes. */ export interface FakeDiagramRenderer extends DiagramRenderer { @@ -24,7 +25,9 @@ export function createFakeDiagramRenderer(): FakeDiagramRenderer { calls.push(charts) if (charts.length === 0) return [] return charts.map((chart, i) => - chart.trim().length === 0 ? '' : ``, + chart.trim().length === 0 + ? diagramSourceFallback(chart) + : ``, ) }, } diff --git a/server/ports/diagram-renderer.ts b/server/ports/diagram-renderer.ts index a6a5672..aa29114 100644 --- a/server/ports/diagram-renderer.ts +++ b/server/ports/diagram-renderer.ts @@ -9,17 +9,15 @@ * touched by this port, it is written from reading both so that a future * kroki adapter and a future Electron adapter can both satisfy it as is. * - * An empty string result for a chart means that chart failed to render. - * Both real implementations already treat a single chart's failure as keep - * going rather than fail the whole batch. The kroki.io implementation - * already pushes an empty string on failure, matching this contract - * exactly. The Electron implementation currently pushes an escaped - * pre code fallback block showing the raw chart source instead of an - * empty string. That is real behaviour today, but it does not match this - * port's contract, so reconciling it, most likely by having the future - * Electron adapter return an empty string the same way the kroki adapter - * does, is a decision for whoever builds that adapter, not something this - * task changes in electron/main.ts. + * A chart that fails to render yields readable fallback markup rather than + * an empty string. Both real implementations already treat a single + * chart's failure as keep going rather than fail the whole batch, and the + * Electron renderer already emits an escaped code block holding the chart + * source so the reader sees the diagram's text instead of a hole in the + * page. That is the better of the two behaviours, so it is the contract, + * and the kroki adapter adopts it. Use diagramSourceFallback below to + * produce it, so no implementation can drift from the markup the others + * emit. * * render takes no AbortSignal. Neither real implementation accepts a * caller supplied one today. The kroki.io call uses a fixed internal @@ -27,13 +25,26 @@ * chart timeout, so there is nothing for the port to expose yet. */ +/** + * The markup every implementation must emit for a chart it could not + * render. An escaped mermaid code block, so the reader still gets the + * diagram's source rather than a blank space, and so nothing in the chart + * source can inject markup into the EPUB. Lifted verbatim from the + * fallback the Electron renderer has always used. + */ +export function diagramSourceFallback(chartSource: string): string { + const escaped = chartSource.replace(//g, '>') + return `
${escaped}
` +} + export interface DiagramRenderer { /** * Renders each chart and returns one markup string per chart, in the - * same order as charts. A chart that fails to render yields an empty - * string at that index rather than rejecting the whole call. An empty - * charts array resolves to an empty array without doing any rendering - * work. + * same order as charts. A chart that fails to render yields + * diagramSourceFallback of that chart's source at that index rather than + * rejecting the whole call, so the result is never an empty string. An + * empty charts array resolves to an empty array without doing any + * rendering work. */ render(charts: string[]): Promise } From f309f9338444f1fdcfb6fb76017d15d8599fdfb8 Mon Sep 17 00:00:00 2001 From: Ross Miller Date: Mon, 20 Jul 2026 20:33:55 -0500 Subject: [PATCH 015/126] docs(plans): reconcile the phase 2 plan with what the code actually says Seven facts found while executing that the plan was written against different numbers for. The largest is that sanctioned change 1, the eight MCP authoring routes flipping 500 to 400, has no frozen assertion to edit. Those routes have zero test coverage of any kind, verified by grepping every test file under server for their paths, so the change ships with new tests asserting the 400 rather than an edit to an existing one. The rest are the corrected test baseline, the corrected books.ts size and line references, the two port contract decisions the architect settled, the real port count of thirteen, and confirmation that services/mermaid-renderer.ts is dead code with only its own test importing it. Claude-Session: https://claude.ai/code/session_01FSkESkJTRpt3Ye2cM7c5n5 --- docs/plans/refactor/phase-2.md | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/docs/plans/refactor/phase-2.md b/docs/plans/refactor/phase-2.md index b50d030..b2e3902 100644 --- a/docs/plans/refactor/phase-2.md +++ b/docs/plans/refactor/phase-2.md @@ -4,6 +4,18 @@ Scope: `server/` only. Assumes P1 delivered `server/ + shared/ + client/` with ` Consolidation deltas that modify this plan: S1 reduces to `shared/domain/provider.ts` consolidation only (P1 owns the schemas move); the schemas re-export shim does not exist so S12's shim deletion is moot; S1+S2+S3 land as an early `phase-2-foundations` PR merged to master so P3 can rebase; service unit tests are written red before each extraction (TDD delta 9). +## 0. Reconciled during execution + +Facts found in the code that contradict what this plan was written against. The plan below is corrected in place; this list records what moved and why, so the diff between plan and reality is never silent. + +1. **Test baseline.** Section 9 was written against a post-P0 count of 232. Phase 1 landed more tests, so the real baseline on `master` before this branch is 315. Stage S4 took it to 479, and the stage 2 items below took it to 481. The phase gate is measured against 481 plus the new adapter and service tests, not 232. +2. **`books.ts` size.** Read at 2,244 lines when this plan was written. The foundations PR moved constants, HTTP helpers, and the error handler out, so it is 2,176 lines and 47 route registrations at the point S6b splits it. Every line reference in sections 2 and 3 is therefore approximate and must be re-derived from the file rather than trusted. +3. **Sanctioned change 1 has no frozen assertion to edit.** Risk 4 assumed the eight MCP authoring routes had P0 characterization assertions locking their current 500. They do not. Those eight routes have zero test coverage of any kind, verified by grepping every `*.test.ts` under `server/` for their paths. So the 500 to 400 flip edits nothing. Instead this phase must ADD tests asserting the new 400, because an unsanctioned regression there would otherwise be invisible. +4. **DiagramRenderer failure contract.** The port shipped in S4 said a failed chart yields `''`. The two real implementations disagreed: kroki pushed `''`, Electron pushed an escaped mermaid code block holding the chart source. The architect standardized on the Electron behaviour, so a failed chart now yields `diagramSourceFallback(source)` and never `''`. The kroki adapter adopts it, which is sanctioned change 5, visible only in dev web mode. +5. **AudioAssembly cover embedding.** `ConcatToM4bRequest` gained an optional `coverPath`. Retrying the stitch without a cover when embedding fails stays adapter-internal resilience rather than a caller concern. +6. **Port count.** Thirteen ports shipped in S4, not the eleven plus two extras this plan estimated: TextGeneration, KeyVault, ImageGeneration, BookRepository, ArtifactStore, SpeechSynthesis, AudioAssembly, DiagramRenderer, EpubImport, EpubExport, BackgroundTasks, Clock, OsFileManager. +7. **`services/mermaid-renderer.ts` is confirmed dead.** Only its own test imports it. Every other `mermaid-renderer` hit in the tree is a log prefix string or a temp file name, not an import. + ## 1. Port catalog Location: `server/ports/.ts` — interface + fake + contract test per port. All signatures derived from actual call sites (line refs are current `server/`). @@ -77,7 +89,7 @@ get(p: ProviderId): string|null; set(p, key): void; remove(p): void; has(p): status(): Record // ports/diagram-renderer.ts -render(charts: string[]): Promise // markup per chart; '' = failed +render(charts: string[]): Promise // markup per chart; failed = diagramSourceFallback(source), never '' // ports/epub-import.ts — MUST NOT persist (today epub-importer.ts:10 imports book-store) preview(bytes: Buffer): Promise @@ -206,7 +218,7 @@ Each port ships `ports/.fake.ts` (in-memory, recording) and `ports/. 1. **SSE regressions** (`reply.hijack`, `raw.writeHead`, `request.raw.on('close')`) are invisible to unit tests. Require ≥1 P0 characterization test per streaming route asserting event order; manual boot per slice. 2. **Timeout semantics move** into the adapter. Verify `AbortSignal.any` (Node 24 ✓, Electron 40 ✓); keep manual controller-combining as fallback. 3. **Electron packaging**: adapters change the rollup `external()` list in `vite.config.ts`; `epub-gen-memory`'s double-default and `kokoro-js`/`@huggingface/transformers` must stay external. Gate `electron:preview` after S5 and S12. -4. **Sanctioned behavior change**: 8 MCP CRUD routes (books.ts:2065, 2089, 2106, 2123, 2145, 2171, 2194, 2237) call `.parse()` unguarded and currently return **500** on bad input; `parseBody` makes them **400**. Architect accepted: document in the PR, update those P0 assertions — the only assertion edit allowed this phase. +4. **Sanctioned behavior change**: 8 MCP CRUD routes call `.parse()` unguarded and currently return **500** on bad input; `parseBody` makes them **400**. Architect accepted. At the current file size those unguarded parses sit at books.ts:1997, 2021, 2038, 2055, 2077, 2103, 2126, 2169. They have no test coverage at all, so nothing is edited and new tests assert the 400 instead (section 0 item 3). 5. **Singleton→factory**: `epub-importer`, `audiobook-generator`, `mcp-server` import `* as store` at module scope. All must convert in S5 or keep a shim; a half-converted state silently writes to the production data dir. 6. **Test-mock debt**: `vi.mock('../../lib/data-dir.js')` must be deleted when the factory lands, otherwise tests pass against the wrong directory. 7. `electron/main.ts:385` sets `fastify.mermaidRenderer`; switching to `setDiagramRenderer` requires an `electron/` edit — coordinate with the orchestrator on that file. @@ -214,8 +226,8 @@ Each port ships `ports/.fake.ts` (in-memory, recording) and `ports/. ## 9. Phase gate -- `pnpm test` green, count ≥ 232 + new contract/service tests (Phase 0 corrected the inflated 384 baseline — vitest was collecting nested worktree copies; the true post-P0 count is 232); `tsc --noEmit` clean; ESLint 0 warnings including new boundary rules. +- `pnpm test` green, count ≥ 481 + new adapter/service tests (see section 0 item 1 for how the baseline moved: 315 on master, 479 at S4, 481 after the stage 2 items); `tsc --noEmit` clean; ESLint 0 warnings including new boundary rules. - Grep gates: `rg "from 'ai'" server/{routes,services}` = 0 · `rg "node:fs" server/routes` = 0 · `rg "5 \* 60 \* 1000" server` = 0 · `rg "instanceof ZodError" server` = 0 · `rg "await import\(" server/routes` = 0 · no route file > 200 lines · `server/routes/books.ts` gone. - Contract test exists for all mandated ports; hermetic ones also run against the real adapter. - `electron:dev` + `electron:preview` boot; manual E2E once: create book (SSE) → read → feedback+quiz → generate next → export EPUB → audiobook range request returns 206. -- P0 characterization assertions unchanged except the documented 400/500 item. +- P0 characterization assertions unchanged. The 400/500 item edits none of them, because those eight routes had no coverage to edit, so it ships with new tests instead (section 0 item 3). From fad307510eeaf6c7febd689e6b5855b2707b152a Mon Sep 17 00:00:00 2001 From: Ross Miller Date: Mon, 20 Jul 2026 20:51:59 -0500 Subject: [PATCH 016/126] feat(adapters): add fs-paths helpers and the real BookRepository adapter Server stage S5 replaces the single book-store.ts module with real adapters behind the ports S4 introduced. This commit adds the first one. fs-paths.ts holds the small pieces both adapters need, the shared books directory layout and the atomic YAML read and write helpers, so they live in one place instead of two. fs-book-repository.ts implements every BookRepository method with the YAML and Markdown filesystem logic that used to live directly in book-store.ts, moved behind a createFsBookRepository factory that takes a data directory instead of calling getDataDir() itself. The logic is unchanged, only parameterized. fs-book-repository.test.ts runs the shared BookRepository contract against this real adapter over a fresh temporary directory per test, the same contract the fake already satisfies, so real filesystem behaviour and the fake can never drift apart unnoticed. Claude-Session: https://claude.ai/code/session_01FSkESkJTRpt3Ye2cM7c5n5 --- server/adapters/fs-book-repository.test.ts | 26 ++ server/adapters/fs-book-repository.ts | 520 +++++++++++++++++++++ server/adapters/fs-paths.ts | 49 ++ 3 files changed, 595 insertions(+) create mode 100644 server/adapters/fs-book-repository.test.ts create mode 100644 server/adapters/fs-book-repository.ts create mode 100644 server/adapters/fs-paths.ts diff --git a/server/adapters/fs-book-repository.test.ts b/server/adapters/fs-book-repository.test.ts new file mode 100644 index 0000000..c5a91e0 --- /dev/null +++ b/server/adapters/fs-book-repository.test.ts @@ -0,0 +1,26 @@ +import { afterEach } from 'vitest' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { BookRepository } from '../ports/book-repository.js' +import { describeBookRepositoryContract } from '../ports/book-repository.contract.js' +import { createFsBookRepository } from './fs-book-repository.js' + +// Runs the shared BookRepository contract against the real filesystem +// adapter, over a fresh temp directory per subject so a failing assertion +// never touches, and this suite never even risks touching, the real data +// directory a running app would use. + +const tempDirs: string[] = [] + +async function makeSubject(): Promise { + const dataDir = await mkdtemp(join(tmpdir(), 'tutor-fs-book-repository-test-')) + tempDirs.push(dataDir) + return createFsBookRepository({ dataDir }) +} + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) +}) + +describeBookRepositoryContract('real fs adapter', makeSubject) diff --git a/server/adapters/fs-book-repository.ts b/server/adapters/fs-book-repository.ts new file mode 100644 index 0000000..9e47efe --- /dev/null +++ b/server/adapters/fs-book-repository.ts @@ -0,0 +1,520 @@ +import { readFile, writeFile, mkdir, readdir, rm, lstat, rename, stat } from 'node:fs/promises' +import { join } from 'node:path' +import { existsSync } from 'node:fs' +import { + BookMetaSchema, + TocSchema, + ProgressSchema, + FeedbackSchema, + LearningProfileSchema, + QuizSchema, + ChapterSummarySchema, + ReferenceManifestSchema, + type BookMeta, + type Toc, + type Progress, + type Feedback, + type Quiz, + type LearningProfile, + type ChapterProgress, + type ChapterSummary, + type ReferenceManifest, +} from '@shared/domain.js' +import type { SkillProgress } from '@shared/responses.js' +import type { BookRepository } from '../ports/book-repository.js' +import { booksDir, bookDir, padChapter, readYaml, writeYaml } from './fs-paths.js' + +/** + * The real BookRepository adapter: YAML metadata and Markdown chapters on + * the filesystem, rooted at {dataDir}/books/. Every method here is the same + * logic server/services/book-store.ts used to run at module scope, moved + * behind a factory so the data directory is a constructor argument instead + * of a fresh getDataDir() call baked into every helper. + * + * "Not found" is signalled the same way the filesystem already signals it: + * a rejected promise carrying `code: 'ENOENT'`, straight from Node's own + * readFile/stat, with no wrapping. See the NotFoundError doc comment on the + * port for why that is exactly what the port contract expects here. + */ + +function validateReferenceName(name: string): void { + if (!/^[a-zA-Z0-9-]+$/.test(name)) { + throw new Error(`Invalid reference name: "${name}". Only alphanumeric characters and hyphens are allowed.`) + } +} + +function stripUserAnswers(quiz: Quiz): Quiz { + return { + ...quiz, + questions: quiz.questions.map(({ userAnswer: _u, correct: _c, ...rest }) => rest), + } +} + +/** + * Removes any BookRepository-owned .tmp file left behind by an interrupted + * atomic write for one book: chapters/NN.md.tmp, meta.yml.tmp, toc.yml.tmp, + * progress.yml.tmp, and final-quiz.yml.tmp. Not part of the BookRepository + * port, since no method there is about crash recovery, only ArtifactStore + * declares recoverFromCrash(). This is exported alongside the factory so + * server/services/book-store.ts's crash-recovery composition (see its doc + * comment) can sweep this adapter's own debris through this module instead + * of reaching around it with raw fs calls of its own. Returns the paths it + * removed. + */ +export async function cleanTmpArtifacts(dataDir: string, bookId: string): Promise { + const dir = bookDir(dataDir, bookId) + const removed: string[] = [] + if (!existsSync(dir)) return removed + + const chaptersDir = join(dir, 'chapters') + if (existsSync(chaptersDir)) { + for (const file of await readdir(chaptersDir)) { + if (file.endsWith('.tmp')) { + const p = join(chaptersDir, file) + await rm(p) + removed.push(p) + } + } + } + + for (const yamlName of ['meta.yml', 'toc.yml', 'progress.yml', 'final-quiz.yml']) { + const yamlTmp = join(dir, `${yamlName}.tmp`) + if (existsSync(yamlTmp)) { + await rm(yamlTmp) + removed.push(yamlTmp) + } + } + + return removed +} + +export function createFsBookRepository(opts: { dataDir: string }): BookRepository { + const { dataDir } = opts + + const repo: BookRepository = { + // --- Learning profile --- + + async getProfile(): Promise { + return readYaml(join(booksDir(dataDir), 'learning-profile.yml'), LearningProfileSchema) + }, + + async saveProfile(profile: LearningProfile): Promise { + LearningProfileSchema.parse(profile) + await writeYaml(join(booksDir(dataDir), 'learning-profile.yml'), profile) + }, + + async getProfileUpdatedAt(): Promise { + try { + const stats = await stat(join(booksDir(dataDir), 'learning-profile.yml')) + return stats.mtime.toISOString() + } catch { + return null + } + }, + + // --- Book CRUD --- + + async listBooks(): Promise { + const dir = booksDir(dataDir) + if (!existsSync(dir)) return [] + + const entries = await readdir(dir, { withFileTypes: true }) + const books: BookMeta[] = [] + + for (const entry of entries) { + if (!entry.isDirectory()) continue + const metaPath = join(dir, entry.name, 'meta.yml') + if (!existsSync(metaPath)) continue + try { + const meta = await readYaml(metaPath, BookMetaSchema) + books.push(meta) + } catch (err) { + console.error(`[listBooks] Failed to load book "${entry.name}":`, err) + } + } + + return books.sort((a, b) => b.createdAt.localeCompare(a.createdAt)) + }, + + async getBook(bookId: string): Promise { + return readYaml(join(bookDir(dataDir, bookId), 'meta.yml'), BookMetaSchema) + }, + + async saveBook(meta: BookMeta): Promise { + BookMetaSchema.parse(meta) + const dir = bookDir(dataDir, meta.id) + await mkdir(dir, { recursive: true }) + await mkdir(join(dir, 'chapters'), { recursive: true }) + await mkdir(join(dir, 'feedback'), { recursive: true }) + await writeYaml(join(dir, 'meta.yml'), meta) + }, + + async deleteBook(bookId: string): Promise { + const dir = bookDir(dataDir, bookId) + if (existsSync(dir)) { + const stats = await lstat(dir) + if (!stats.isDirectory()) { + throw new Error('Invalid book directory') + } + await rm(dir, { recursive: true }) + } + }, + + async resetBook(bookId: string): Promise { + const meta = await repo.getBook(bookId) + if (meta.status === 'generating' || meta.status === 'generating_toc') { + throw new Error(`Cannot reset book "${bookId}" while it is generating`) + } + + const dir = bookDir(dataDir, bookId) + + // Delete progress.yml + const progressPath = join(dir, 'progress.yml') + if (existsSync(progressPath)) await rm(progressPath) + + // Delete every feedback/*.yml file (keep the directory itself; saveBook re-creates it) + const feedbackDir = join(dir, 'feedback') + if (existsSync(feedbackDir)) { + for (const file of await readdir(feedbackDir)) { + if (file.endsWith('.yml')) await rm(join(feedbackDir, file)) + } + } + + // Strip userAnswer/correct from per-chapter quiz files (keep the questions) + const quizDir = join(dir, 'quiz') + if (existsSync(quizDir)) { + for (const file of await readdir(quizDir)) { + if (!file.endsWith('.yml')) continue + const path = join(quizDir, file) + const quiz = await readYaml(path, QuizSchema) + await writeYaml(path, stripUserAnswers(quiz)) + } + } + + // Strip userAnswer/correct from final-quiz.yml (keep the questions) + const finalQuizPath = join(dir, 'final-quiz.yml') + if (existsSync(finalQuizPath)) { + const finalQuiz = await readYaml(finalQuizPath, QuizSchema) + await writeYaml(finalQuizPath, stripUserAnswers(finalQuiz)) + } + + // Reset meta: drop rating/finalQuiz* fields, set status to 'reading', refresh updatedAt + const { rating: _r, finalQuizScore: _s, finalQuizTotal: _t, ...rest } = meta + await repo.saveBook({ + ...rest, + status: 'reading', + updatedAt: new Date().toISOString(), + }) + }, + + // --- Table of contents --- + + async getToc(bookId: string): Promise { + return readYaml(join(bookDir(dataDir, bookId), 'toc.yml'), TocSchema) + }, + + async saveToc(bookId: string, toc: Toc): Promise { + TocSchema.parse(toc) + await writeYaml(join(bookDir(dataDir, bookId), 'toc.yml'), toc) + }, + + // --- Chapters --- + + async getChapter(bookId: string, chapterNum: number): Promise { + const padded = padChapter(chapterNum) + return readFile(join(bookDir(dataDir, bookId), 'chapters', `${padded}.md`), 'utf-8') + }, + + async saveChapter(bookId: string, chapterNum: number, content: string): Promise { + const dir = join(bookDir(dataDir, bookId), 'chapters') + await mkdir(dir, { recursive: true }) + const padded = padChapter(chapterNum) + const tmp = join(dir, `${padded}.md.tmp`) + await writeFile(tmp, content, 'utf-8') + await rename(tmp, join(dir, `${padded}.md`)) + }, + + async chapterExists(bookId: string, chapterNum: number): Promise { + const padded = padChapter(chapterNum) + return existsSync(join(bookDir(dataDir, bookId), 'chapters', `${padded}.md`)) + }, + + // --- Per chapter quiz --- + + async getQuiz(bookId: string, chapterNum: number): Promise { + const padded = padChapter(chapterNum) + return readYaml(join(bookDir(dataDir, bookId), 'quiz', `${padded}.yml`), QuizSchema) + }, + + async saveQuiz(bookId: string, chapterNum: number, quiz: Quiz): Promise { + QuizSchema.parse(quiz) + const dir = join(bookDir(dataDir, bookId), 'quiz') + await mkdir(dir, { recursive: true }) + const padded = padChapter(chapterNum) + await writeYaml(join(dir, `${padded}.yml`), quiz) + }, + + async quizExists(bookId: string, chapterNum: number): Promise { + const padded = padChapter(chapterNum) + return existsSync(join(bookDir(dataDir, bookId), 'quiz', `${padded}.yml`)) + }, + + // --- Final quiz --- + + async getFinalQuiz(bookId: string): Promise { + return readYaml(join(bookDir(dataDir, bookId), 'final-quiz.yml'), QuizSchema) + }, + + async saveFinalQuiz(bookId: string, quiz: Quiz): Promise { + QuizSchema.parse(quiz) + await writeYaml(join(bookDir(dataDir, bookId), 'final-quiz.yml'), quiz) + }, + + finalQuizExists(bookId: string): boolean { + return existsSync(join(bookDir(dataDir, bookId), 'final-quiz.yml')) + }, + + // --- Feedback --- + + async getFeedback(bookId: string, chapterNum: number): Promise { + const padded = padChapter(chapterNum) + return readYaml(join(bookDir(dataDir, bookId), 'feedback', `${padded}.yml`), FeedbackSchema) + }, + + async saveFeedback(bookId: string, chapterNum: number, feedback: Feedback): Promise { + FeedbackSchema.parse(feedback) + const padded = padChapter(chapterNum) + await writeYaml(join(bookDir(dataDir, bookId), 'feedback', `${padded}.yml`), feedback) + }, + + async getAllFeedback(bookId: string): Promise { + const feedbackDir = join(bookDir(dataDir, bookId), 'feedback') + if (!existsSync(feedbackDir)) return [] + + const entries = await readdir(feedbackDir) + const feedbacks: Feedback[] = [] + + for (const entry of entries.filter((e) => e.endsWith('.yml')).sort()) { + try { + const fb = await readYaml(join(feedbackDir, entry), FeedbackSchema) + feedbacks.push(fb) + } catch { + // Skip invalid feedback files + } + } + + return feedbacks + }, + + // --- Progress --- + + async getProgress(bookId: string): Promise { + const path = join(bookDir(dataDir, bookId), 'progress.yml') + if (!existsSync(path)) return { chapters: {} } + return readYaml(path, ProgressSchema) + }, + + async saveChapterProgress(bookId: string, chapterNum: number, progress: ChapterProgress): Promise { + const current = await repo.getProgress(bookId) + current.chapters[String(chapterNum)] = progress + await writeYaml(join(bookDir(dataDir, bookId), 'progress.yml'), current) + }, + + async getChaptersRead(bookId: string): Promise { + const progress = await repo.getProgress(bookId) + return Object.values(progress.chapters).filter((ch) => ch.completed).length + }, + + async getSkillProgress(): Promise { + const allBooks = await repo.listBooks() + + let totalBooks = 0 + let completedBooks = 0 + let totalChapters = 0 + let completedChapters = 0 + + const skillMap = new Map< + string, + { + name: string + totalWeight: number + completedWeight: number + books: Array<{ bookId: string; title: string; weight: number; completed: boolean; lastActivityAt?: string }> + subskills: Map + } + >() + + for (const book of allBooks) { + let toc: Toc + let progress: Progress + try { + toc = await repo.getToc(book.id) + progress = await repo.getProgress(book.id) + } catch { + continue + } + + if (!toc.skills || toc.skills.length === 0) continue + + totalBooks++ + const chapCount = toc.chapters.length + totalChapters += chapCount + + let bookCompletedChapters = 0 + let bookLastActivity: string | undefined + for (let i = 1; i <= chapCount; i++) { + const ch = progress.chapters[String(i)] + if (ch?.completed) { + bookCompletedChapters++ + if (ch.completedAt && (!bookLastActivity || ch.completedAt > bookLastActivity)) { + bookLastActivity = ch.completedAt + } + } + } + if (!bookLastActivity) bookLastActivity = book.updatedAt + completedChapters += bookCompletedChapters + const bookComplete = bookCompletedChapters === chapCount + + if (bookComplete) completedBooks++ + + for (const skill of toc.skills) { + let entry = skillMap.get(skill.name) + if (!entry) { + entry = { name: skill.name, totalWeight: 0, completedWeight: 0, books: [], subskills: new Map() } + skillMap.set(skill.name, entry) + } + entry.totalWeight += skill.weight + if (bookComplete) entry.completedWeight += skill.weight + entry.books.push({ + bookId: book.id, + title: book.title, + weight: skill.weight, + completed: bookComplete, + lastActivityAt: bookLastActivity, + }) + } + + for (let i = 0; i < toc.chapters.length; i++) { + const ch = toc.chapters[i] + if (!ch.skills) continue + const chapterCompleted = !!progress.chapters[String(i + 1)]?.completed + + for (const cs of ch.skills) { + const skillEntry = skillMap.get(cs.skill) + if (!skillEntry) continue + + let sub = skillEntry.subskills.get(cs.subskill) + if (!sub) { + sub = { name: cs.subskill, totalWeight: 0, completedWeight: 0 } + skillEntry.subskills.set(cs.subskill, sub) + } + sub.totalWeight += cs.weight + if (chapterCompleted) sub.completedWeight += cs.weight + } + } + } + + const skills = Array.from(skillMap.values()).map((s) => { + const bookDates = s.books.map((b) => b.lastActivityAt).filter((d): d is string => Boolean(d)) + const lastActivityAt = bookDates.length > 0 ? bookDates.sort().pop() : undefined + return { ...s, lastActivityAt, subskills: Array.from(s.subskills.values()) } + }) + + return { + stats: { totalBooks, completedBooks, totalChapters, completedChapters }, + skills, + } + }, + + // --- Brief --- + + async saveBrief(bookId: string, content: string): Promise { + const dir = bookDir(dataDir, bookId) + await mkdir(dir, { recursive: true }) + const dest = join(dir, 'brief.md') + const tmp = dest + '.tmp' + await writeFile(tmp, content, 'utf-8') + await rename(tmp, dest) + }, + + async getBrief(bookId: string): Promise { + return readFile(join(bookDir(dataDir, bookId), 'brief.md'), 'utf-8') + }, + + // --- Chapter summaries --- + + async saveSummary(bookId: string, chapterNum: number, summary: ChapterSummary): Promise { + ChapterSummarySchema.parse(summary) + const dir = join(bookDir(dataDir, bookId), 'summaries') + await mkdir(dir, { recursive: true }) + const padded = padChapter(chapterNum) + await writeYaml(join(dir, `${padded}.yml`), summary) + }, + + async getSummary(bookId: string, chapterNum: number): Promise { + const padded = padChapter(chapterNum) + return readYaml(join(bookDir(dataDir, bookId), 'summaries', `${padded}.yml`), ChapterSummarySchema) + }, + + async getAllSummaries(bookId: string): Promise { + const summariesDir = join(bookDir(dataDir, bookId), 'summaries') + if (!existsSync(summariesDir)) return [] + + const entries = await readdir(summariesDir) + const summaries: ChapterSummary[] = [] + + for (const entry of entries.filter((e) => e.endsWith('.yml')).sort()) { + try { + const s = await readYaml(join(summariesDir, entry), ChapterSummarySchema) + summaries.push(s) + } catch { + // Skip invalid summary files + } + } + + return summaries + }, + + // --- References --- + + async saveReference(bookId: string, name: string, content: string): Promise { + validateReferenceName(name) + const dir = join(bookDir(dataDir, bookId), 'references') + await mkdir(dir, { recursive: true }) + + // Write content file atomically + const dest = join(dir, `${name}.md`) + const tmp = dest + '.tmp' + await writeFile(tmp, content, 'utf-8') + await rename(tmp, dest) + + // Update manifest: read existing, upsert entry, write back + const manifestPath = join(dir, 'manifest.yml') + const manifest: ReferenceManifest = existsSync(manifestPath) + ? await readYaml(manifestPath, ReferenceManifestSchema) + : [] + + const idx = manifest.findIndex((e) => e.name === name) + if (idx >= 0) { + manifest[idx] = { ...manifest[idx], name } + } else { + manifest.push({ name }) + } + + await writeYaml(manifestPath, manifest) + }, + + async getReference(bookId: string, name: string): Promise { + validateReferenceName(name) + return readFile(join(bookDir(dataDir, bookId), 'references', `${name}.md`), 'utf-8') + }, + + async listReferences(bookId: string): Promise { + const manifestPath = join(bookDir(dataDir, bookId), 'references', 'manifest.yml') + if (!existsSync(manifestPath)) return [] + return readYaml(manifestPath, ReferenceManifestSchema) + }, + } + + return repo +} diff --git a/server/adapters/fs-paths.ts b/server/adapters/fs-paths.ts new file mode 100644 index 0000000..f817a2b --- /dev/null +++ b/server/adapters/fs-paths.ts @@ -0,0 +1,49 @@ +import { readFile, writeFile, mkdir, rename } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { parse as parseYaml, stringify as stringifyYaml } from 'yaml' + +/** + * Path and YAML helpers shared by fs-book-repository.ts and + * fs-artifact-store.ts. Both adapters root every book under the same + * {dataDir}/books/{bookId}/ directory and only differ in which files they + * touch inside it, YAML metadata and Markdown for BookRepository, binary + * artifacts for ArtifactStore, so the directory layout and the atomic YAML + * read/write mechanics live here once instead of being duplicated in both + * files. + */ + +export function booksDir(dataDir: string): string { + return join(dataDir, 'books') +} + +/** Guards against a bookId that would resolve outside the books directory. */ +export function bookDir(dataDir: string, bookId: string): string { + const base = booksDir(dataDir) + const resolved = join(base, bookId) + if (!resolved.startsWith(base + '/') && resolved !== base) { + throw new Error('Invalid book path') + } + return resolved +} + +export function padChapter(chapterNum: number): string { + return String(chapterNum).padStart(2, '0') +} + +export async function readYaml(path: string, schema: { parse: (data: unknown) => T }): Promise { + const content = await readFile(path, 'utf-8') + const data = parseYaml(content) + return schema.parse(data) +} + +/** Writes YAML atomically: create the parent directory if needed, write a .tmp file, then rename it into place. */ +export async function writeYaml(path: string, data: unknown): Promise { + const dir = dirname(path) + if (!existsSync(dir)) { + await mkdir(dir, { recursive: true }) + } + const tmp = path + '.tmp' + await writeFile(tmp, stringifyYaml(data), 'utf-8') + await rename(tmp, path) +} From a209736bea5234e4ae2ee676decba21726f71bbe Mon Sep 17 00:00:00 2001 From: Ross Miller Date: Mon, 20 Jul 2026 20:52:16 -0500 Subject: [PATCH 017/126] feat(adapters): add the real ArtifactStore adapter fs-artifact-store.ts implements every ArtifactStore method, covers, EPUB export, and audiobook audio, with the same filesystem logic that used to live in book-store.ts, moved behind a createFsArtifactStore factory parameterized by data directory. Its recoverFromCrash() only ever touches artifacts, meaning stray temporary files and an audio directory left behind with no finished m4b, and always reports an empty booksReset. Reconciling a book's status after a crash needs BookRepository data this adapter cannot see, so that half of recovery is a composition concern handled where both adapters are available. See the doc comment on this method and on server/services/book-store.ts for the full explanation. fs-artifact-store.test.ts runs the shared ArtifactStore contract against this real adapter over a fresh temporary directory per test. Claude-Session: https://claude.ai/code/session_01FSkESkJTRpt3Ye2cM7c5n5 --- server/adapters/fs-artifact-store.test.ts | 26 +++ server/adapters/fs-artifact-store.ts | 222 ++++++++++++++++++++++ 2 files changed, 248 insertions(+) create mode 100644 server/adapters/fs-artifact-store.test.ts create mode 100644 server/adapters/fs-artifact-store.ts diff --git a/server/adapters/fs-artifact-store.test.ts b/server/adapters/fs-artifact-store.test.ts new file mode 100644 index 0000000..d2a2c27 --- /dev/null +++ b/server/adapters/fs-artifact-store.test.ts @@ -0,0 +1,26 @@ +import { afterEach } from 'vitest' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { ArtifactStore } from '../ports/artifact-store.js' +import { describeArtifactStoreContract } from '../ports/artifact-store.contract.js' +import { createFsArtifactStore } from './fs-artifact-store.js' + +// Runs the shared ArtifactStore contract against the real filesystem +// adapter, over a fresh temp directory per subject so a failing assertion +// never touches, and this suite never even risks touching, the real data +// directory a running app would use. + +const tempDirs: string[] = [] + +async function makeSubject(): Promise { + const dataDir = await mkdtemp(join(tmpdir(), 'tutor-fs-artifact-store-test-')) + tempDirs.push(dataDir) + return createFsArtifactStore({ dataDir }) +} + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) +}) + +describeArtifactStoreContract('real fs adapter', makeSubject) diff --git a/server/adapters/fs-artifact-store.ts b/server/adapters/fs-artifact-store.ts new file mode 100644 index 0000000..d49b988 --- /dev/null +++ b/server/adapters/fs-artifact-store.ts @@ -0,0 +1,222 @@ +import { writeFile, mkdir, readdir, rm, rename, stat } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { AudiobookManifestSchema, type AudiobookManifest } from '@shared/domain.js' +import type { ArtifactStore, CrashRecoveryReport } from '../ports/artifact-store.js' +import { booksDir, bookDir, padChapter, readYaml, writeYaml } from './fs-paths.js' + +/** + * The real ArtifactStore adapter: covers, EPUB exports, and audiobook audio + * on the filesystem, rooted at {dataDir}/books/. Every method here is the + * same logic server/services/book-store.ts used to run at module scope, + * moved behind a factory so the data directory is a constructor argument + * instead of a fresh getDataDir() call baked into every helper. + * + * recoverFromCrash() only ever touches artifacts: stray .tmp files left by + * an interrupted write, and an audio directory left behind with no m4b to + * show for it. It walks the books directory itself rather than going + * through BookRepository, so it can find and clean these up even for a + * book whose meta.yml is missing or unparseable, exactly like the fixture + * artifact-store.contract.ts uses (a saved audiobook manifest with no + * BookMeta behind it at all). It never changes a book's status, and always + * reports booksReset as empty, because that is BookRepository data this + * adapter cannot see. See server/services/book-store.ts for the + * composition that reconciles status on top of this report. + */ + +const COVER_EXTENSIONS = ['png', 'jpg', 'webp'] + +function extensionFor(mediaType: string): string { + if (mediaType === 'image/jpeg') return 'jpg' + if (mediaType === 'image/webp') return 'webp' + return 'png' +} + +export function createFsArtifactStore(opts: { dataDir: string }): ArtifactStore { + const { dataDir } = opts + + const audioDir = (bookId: string): string => join(bookDir(dataDir, bookId), 'audio') + const audiobookPath = (bookId: string): string => join(bookDir(dataDir, bookId), 'book.m4b') + const audiobookManifestPath = (bookId: string): string => join(audioDir(bookId), 'manifest.yml') + const epubPath = (bookId: string): string => join(bookDir(dataDir, bookId), 'book.epub') + const chapterAudioPath = (bookId: string, chapterNum: number): string => + join(audioDir(bookId), `${padChapter(chapterNum)}.mp3`) + const chapterWavPath = (bookId: string, chapterNum: number): string => + join(audioDir(bookId), `${padChapter(chapterNum)}.wav`) + + async function cleanPartialBookArtifacts(bookId: string, report: CrashRecoveryReport): Promise { + const dir = bookDir(dataDir, bookId) + if (!existsSync(dir)) return + + // book.epub.tmp + const epubTmp = join(dir, 'book.epub.tmp') + if (existsSync(epubTmp)) { + await rm(epubTmp) + report.artifactsRemoved.push(epubTmp) + } + + // cover.*.tmp + for (const ext of COVER_EXTENSIONS) { + const coverTmp = join(dir, `cover.${ext}.tmp`) + if (existsSync(coverTmp)) { + await rm(coverTmp) + report.artifactsRemoved.push(coverTmp) + } + } + } + + async function cleanPartialInstallArtifacts(report: CrashRecoveryReport): Promise { + const binDir = join(dataDir, 'bin') + if (!existsSync(binDir)) return + + // ffmpeg downloader leaves these on crash mid-download or mid-unzip. + for (const name of ['ffmpeg.partial', 'ffmpeg.zip.tmp']) { + const p = join(binDir, name) + if (existsSync(p)) { + await rm(p) + report.artifactsRemoved.push(p) + } + } + const extractDir = join(binDir, '.ffmpeg-extract.tmp') + if (existsSync(extractDir)) { + await rm(extractDir, { recursive: true }) + report.artifactsRemoved.push(extractDir) + } + } + + const store: ArtifactStore = { + // --- Cover image --- + + async getCoverPath(bookId: string): Promise { + const dir = bookDir(dataDir, bookId) + for (const ext of COVER_EXTENSIONS) { + const p = join(dir, `cover.${ext}`) + if (existsSync(p)) return p + } + return null + }, + + async hasCover(bookId: string): Promise { + return (await store.getCoverPath(bookId)) !== null + }, + + async getCoverMtime(bookId: string): Promise { + const coverPath = await store.getCoverPath(bookId) + if (!coverPath) return null + const s = await stat(coverPath) + return s.mtime + }, + + async saveCover(bookId: string, data: Buffer, mediaType: string): Promise { + const dir = bookDir(dataDir, bookId) + await mkdir(dir, { recursive: true }) + + // Delete any existing cover first + await store.deleteCover(bookId) + + const ext = extensionFor(mediaType) + const dest = join(dir, `cover.${ext}`) + const tmp = dest + '.tmp' + await writeFile(tmp, data) + await rename(tmp, dest) + }, + + async deleteCover(bookId: string): Promise { + const dir = bookDir(dataDir, bookId) + for (const ext of COVER_EXTENSIONS) { + const p = join(dir, `cover.${ext}`) + if (existsSync(p)) { + await rm(p) + } + } + }, + + // --- EPUB export --- + + epubPath, + + epubExists(bookId: string): boolean { + return existsSync(epubPath(bookId)) + }, + + async writeEpub(bookId: string, data: Buffer): Promise { + const dir = bookDir(dataDir, bookId) + await mkdir(dir, { recursive: true }) + const dest = epubPath(bookId) + const tmp = dest + '.tmp' + await writeFile(tmp, data) + await rename(tmp, dest) + }, + + // --- Audiobook --- + + audiobookPath, + + audiobookExists(bookId: string): boolean { + return existsSync(audiobookPath(bookId)) + }, + + audioDir, + chapterAudioPath, + chapterWavPath, + + async chapterAudioExists(bookId: string, chapterNum: number): Promise { + if (existsSync(chapterAudioPath(bookId, chapterNum))) return true + const manifest = await store.getAudiobookManifest(bookId) + return !!manifest?.chapters.some((c) => c.num === chapterNum) + }, + + async getAudiobookManifest(bookId: string): Promise { + const path = audiobookManifestPath(bookId) + if (!existsSync(path)) return null + return readYaml(path, AudiobookManifestSchema) + }, + + async saveAudiobookManifest(bookId: string, manifest: AudiobookManifest): Promise { + AudiobookManifestSchema.parse(manifest) + await writeYaml(audiobookManifestPath(bookId), manifest) + }, + + async deleteAudiobookArtifacts(bookId: string): Promise { + const m4b = audiobookPath(bookId) + if (existsSync(m4b)) { + await rm(m4b) + } + const dir = audioDir(bookId) + if (existsSync(dir)) { + await rm(dir, { recursive: true }) + } + }, + + // --- Crash recovery --- + + async recoverFromCrash(): Promise { + const report: CrashRecoveryReport = { booksReset: [], artifactsRemoved: [] } + const dir = booksDir(dataDir) + + if (existsSync(dir)) { + const entries = await readdir(dir, { withFileTypes: true }) + + for (const entry of entries) { + if (!entry.isDirectory()) continue + const bookId = entry.name + + await cleanPartialBookArtifacts(bookId, report) + + const m4bExists = existsSync(audiobookPath(bookId)) + const audioDirExists = existsSync(audioDir(bookId)) + if (!m4bExists && audioDirExists) { + await rm(audioDir(bookId), { recursive: true }) + report.artifactsRemoved.push(audioDir(bookId)) + } + } + } + + await cleanPartialInstallArtifacts(report) + + return report + }, + } + + return store +} From f7e53ce5b83b51d1455151c3f8a48776a9f8248c Mon Sep 17 00:00:00 2001 From: Ross Miller Date: Mon, 20 Jul 2026 20:52:32 -0500 Subject: [PATCH 018/126] refactor(services): turn book-store into a thin shim over the two adapters book-store.ts is imported at module scope by every route file, by mcp-server.ts, epub-importer.ts, and audiobook-generator.ts, so this change had to land as one atomic step rather than converting callers one at a time. Every function this module exported keeps its exact name and signature. Each one now builds a fresh BookRepository or ArtifactStore over the current data directory and delegates to it, rather than running filesystem logic directly. Both adapters are rebuilt on every call rather than once at module load, preserving the original module's lazy timing. Existing tests, audiobook-generator.test.ts among them, change the effective data directory between tests within a single file and expect the very next call into this module to see the new value. recoverFromCrash() is the one export that could not be a single delegating line, since reconciling a book's status after a crash needs both adapters together. It now runs ArtifactStore's own artifact-only recovery, sweeps this repository's own leftover temporary files book by book, then walks every book through BookRepository to reconcile status and audioGeneratedChapters, the same combined pass this module has always run. The full test suite for every existing caller of this module still passes unchanged against this rewrite, confirming the behaviour is identical. Claude-Session: https://claude.ai/code/session_01FSkESkJTRpt3Ye2cM7c5n5 --- server/services/book-store.ts | 814 ++++++++-------------------------- 1 file changed, 195 insertions(+), 619 deletions(-) diff --git a/server/services/book-store.ts b/server/services/book-store.ts index 07f3f3b..3c85a0d 100644 --- a/server/services/book-store.ts +++ b/server/services/book-store.ts @@ -1,422 +1,156 @@ -import { readFile, writeFile, mkdir, readdir, rm, lstat, rename, stat } from 'node:fs/promises' -import { join, dirname } from 'node:path' -import { existsSync } from 'node:fs' -import { parse as parseYaml, stringify as stringifyYaml } from 'yaml' -import { - BookMetaSchema, - TocSchema, - ProgressSchema, - FeedbackSchema, - LearningProfileSchema, - QuizSchema, - ChapterSummarySchema, - ReferenceManifestSchema, - AudiobookManifestSchema, - type BookMeta, - type Toc, - type Progress, - type Feedback, - type Quiz, - type LearningProfile, - type ChapterProgress, - type ChapterSummary, - type ReferenceManifest, - type AudiobookManifest, -} from '@shared/domain.js' +import { join } from 'node:path' import { getDataDir } from '@shared/node/data-dir.js' +import { createFsBookRepository, cleanTmpArtifacts } from '../adapters/fs-book-repository.js' +import { createFsArtifactStore } from '../adapters/fs-artifact-store.js' +import type { + BookMeta, + Toc, + Progress, + ChapterProgress, + Feedback, + Quiz, + LearningProfile, + ChapterSummary, + ReferenceManifest, + AudiobookManifest, +} from '@shared/domain.js' +import type { SkillProgress } from '@shared/responses.js' +import type { BookRepository } from '../ports/book-repository.js' +import type { ArtifactStore, CrashRecoveryReport } from '../ports/artifact-store.js' -function booksDir(): string { - return join(getDataDir(), 'books') -} +/** + * Temporary compatibility shim over the two real adapters that replaced + * this file's original, monolithic filesystem implementation: + * fs-book-repository.ts (YAML metadata and Markdown chapters, behind the + * BookRepository port) and fs-artifact-store.ts (covers, EPUB exports, and + * audiobook audio, behind the ArtifactStore port). + * + * It exists so the move from a singleton module to two factory-built + * adapters could land in one atomic step, rather than leaving every + * caller, meaning every route file, epub-importer.ts, audiobook- + * generator.ts, generation-manager.ts, and mcp-server.ts, half converted + * while each migrated on its own schedule. Every function below re-exports + * the exact name and signature this module has always exported, and + * simply delegates to whichever adapter owns that piece of behaviour. + * + * Every caller will move onto BookRepository and ArtifactStore directly in + * a later stage. Until then, treat this file as a bridge, not a place to + * add new behaviour. + * + * Both adapters are rebuilt on every call, each resolving getDataDir() + * fresh rather than once at module load. This preserves the original + * module's lazy timing, which existing tests depend on: audiobook- + * generator.test.ts and book-store.test.ts both change the effective data + * directory between tests within a single file (through a mock or a real + * env var) and expect the very next call into this module to see the new + * value, not whatever was current the first time the module was imported. + */ -function bookDir(bookId: string): string { - const base = booksDir() - const resolved = join(base, bookId) - if (!resolved.startsWith(base + '/') && resolved !== base) { - throw new Error('Invalid book path') - } - return resolved +function bookRepository(): BookRepository { + return createFsBookRepository({ dataDir: getDataDir() }) } -// --- YAML helpers --- - -async function readYaml(path: string, schema: { parse: (data: unknown) => T }): Promise { - const content = await readFile(path, 'utf-8') - const data = parseYaml(content) - return schema.parse(data) +function artifactStore(): ArtifactStore { + return createFsArtifactStore({ dataDir: getDataDir() }) } -async function writeYaml(path: string, data: unknown): Promise { - const dir = dirname(path) - if (!existsSync(dir)) { - await mkdir(dir, { recursive: true }) - } - const tmp = path + '.tmp' - await writeFile(tmp, stringifyYaml(data), 'utf-8') - await rename(tmp, path) -} +export type { CrashRecoveryReport } from '../ports/artifact-store.js' +export type { SkillProgress as SkillProgressResult } from '@shared/responses.js' // --- Learning Profile --- export async function getProfile(): Promise { - return readYaml(join(booksDir(), 'learning-profile.yml'), LearningProfileSchema) + return bookRepository().getProfile() } export async function saveProfile(profile: LearningProfile): Promise { - LearningProfileSchema.parse(profile) - await writeYaml(join(booksDir(), 'learning-profile.yml'), profile) + return bookRepository().saveProfile(profile) } export async function getProfileUpdatedAt(): Promise { - try { - const stats = await stat(join(booksDir(), 'learning-profile.yml')) - return stats.mtime.toISOString() - } catch { - return null - } + return bookRepository().getProfileUpdatedAt() } // --- Book CRUD --- export async function listBooks(): Promise { - const dir = booksDir() - if (!existsSync(dir)) return [] - - const entries = await readdir(dir, { withFileTypes: true }) - const books: BookMeta[] = [] - - for (const entry of entries) { - if (!entry.isDirectory()) continue - const metaPath = join(dir, entry.name, 'meta.yml') - if (!existsSync(metaPath)) continue - try { - const meta = await readYaml(metaPath, BookMetaSchema) - books.push(meta) - } catch (err) { - console.error(`[listBooks] Failed to load book "${entry.name}":`, err) - } - } - - return books.sort((a, b) => b.createdAt.localeCompare(a.createdAt)) + return bookRepository().listBooks() } export async function getBook(bookId: string): Promise { - return readYaml(join(bookDir(bookId), 'meta.yml'), BookMetaSchema) -} - -/** - * Comprehensive crash recovery — runs once at server startup. - * - * Long-running operations (TOC streaming, chapter streaming, audiobook - * generation, EPUB export, cover generation, audiobook install) hold their - * progress in memory and have no resume path. If the app crashes or quits - * mid-flight, partial artifacts and stuck statuses are left behind. This - * function detects each kind of partial state and reverts it to the cleanest - * recoverable point so the user can retry from a known-good baseline. - * - * Recovery policy per book status: - * generating_toc + toc.yml exists -> 'toc_review' (user can approve/edit/regen) - * generating_toc + no toc.yml -> 'failed' (empty shell; user can Delete) - * generating + generatedUpTo>0 -> 'reading' (user has chapters to read; can retry next) - * generating + generatedUpTo=0 -> 'toc_review' (TOC exists, no chapters) - * - * Artifact cleanup per book (always, regardless of status): - * - audio/ wiped + audioGeneratedChapters reset, UNLESS book.m4b exists - * (a successful audiobook is the only state where partial audio means - * "complete" — m4b presence is the source of truth) - * - chapters/NN.md.tmp removed - * - book.epub.tmp removed - * - cover.*.tmp removed - * - * Engine cleanup (system-wide): - * - userData/bin/ffmpeg.partial / .zip.tmp / .ffmpeg-extract.tmp removed - * - * Returns a report of what was changed for logging / debugging. - */ -export interface CrashRecoveryReport { - booksReset: Array<{ id: string; title: string; from: string; to: string; reason: string }> - artifactsRemoved: string[] -} - -export async function recoverFromCrash(): Promise { - const report: CrashRecoveryReport = { booksReset: [], artifactsRemoved: [] } - const books = await listBooks() - - for (const book of books) { - // 1. Wipe per-book partial artifacts BEFORE any status decision. - await cleanPartialBookArtifacts(book, report) - - // 2. Audiobook artifact cleanup: m4b is the source of truth. - const m4bExists = existsSync(audiobookPath(book.id)) - const audioDirExists = existsSync(audioDir(book.id)) - if (!m4bExists && audioDirExists) { - await rm(audioDir(book.id), { recursive: true }) - report.artifactsRemoved.push(audioDir(book.id)) - if (book.audioGeneratedChapters.length > 0) { - book.audioGeneratedChapters = [] - } - } - - // 3. Status recovery. Both transient states get re-mapped to the cleanest - // user-actionable resting point. - const originalStatus = book.status - let newStatus = originalStatus - let reason = '' - - if (originalStatus === 'generating_toc') { - const tocPath = join(bookDir(book.id), 'toc.yml') - if (existsSync(tocPath)) { - newStatus = 'toc_review' - reason = 'TOC was written before crash; user can approve/edit/regen' - } else { - newStatus = 'failed' - reason = 'TOC stream was interrupted before toc.yml landed; nothing to recover' - } - } else if (originalStatus === 'generating') { - newStatus = book.generatedUpTo > 0 ? 'reading' : 'toc_review' - reason = book.generatedUpTo > 0 - ? `${book.generatedUpTo} chapter(s) saved; user can read and retry next` - : 'No chapters saved yet; back to TOC review' - } - - const audioWiped = !m4bExists && audioDirExists - if (newStatus !== originalStatus || audioWiped) { - if (newStatus !== originalStatus) { - book.status = newStatus - } - book.updatedAt = new Date().toISOString() - await saveBook(book) - if (newStatus !== originalStatus) { - report.booksReset.push({ id: book.id, title: book.title, from: originalStatus, to: newStatus, reason }) - console.warn(`[startup] Recovered "${book.title}" (${book.id}): ${originalStatus} -> ${newStatus} (${reason})`) - } - } - } - - // 4. Engine-wide cleanup: half-downloaded ffmpeg artifacts. - await cleanPartialInstallArtifacts(report) - - return report -} - -/** - * Backward-compatible alias kept for any external callers (e.g., tests - * referencing the old name). New code should call recoverFromCrash(). - */ -export async function recoverStuckBooks(): Promise { - await recoverFromCrash() -} - -async function cleanPartialBookArtifacts(book: BookMeta, report: CrashRecoveryReport): Promise { - const dir = bookDir(book.id) - if (!existsSync(dir)) return - - // chapters/NN.md.tmp — mid-write chapter markdown - const chaptersDir = join(dir, 'chapters') - if (existsSync(chaptersDir)) { - for (const file of await readdir(chaptersDir)) { - if (file.endsWith('.tmp')) { - const p = join(chaptersDir, file) - await rm(p) - report.artifactsRemoved.push(p) - } - } - } - - // book.epub.tmp - const epubTmp = join(dir, 'book.epub.tmp') - if (existsSync(epubTmp)) { - await rm(epubTmp) - report.artifactsRemoved.push(epubTmp) - } - - // cover.*.tmp - for (const ext of COVER_EXTENSIONS) { - const coverTmp = join(dir, `cover.${ext}.tmp`) - if (existsSync(coverTmp)) { - await rm(coverTmp) - report.artifactsRemoved.push(coverTmp) - } - } - - // meta.yml.tmp / toc.yml.tmp / progress.yml.tmp — mid-write yaml - for (const yamlName of ['meta.yml', 'toc.yml', 'progress.yml', 'final-quiz.yml']) { - const yamlTmp = join(dir, `${yamlName}.tmp`) - if (existsSync(yamlTmp)) { - await rm(yamlTmp) - report.artifactsRemoved.push(yamlTmp) - } - } -} - -async function cleanPartialInstallArtifacts(report: CrashRecoveryReport): Promise { - const binDir = join(getDataDir(), 'bin') - if (!existsSync(binDir)) return - - // ffmpeg downloader leaves these on crash mid-download or mid-unzip. - for (const name of ['ffmpeg.partial', 'ffmpeg.zip.tmp']) { - const p = join(binDir, name) - if (existsSync(p)) { - await rm(p) - report.artifactsRemoved.push(p) - } - } - const extractDir = join(binDir, '.ffmpeg-extract.tmp') - if (existsSync(extractDir)) { - await rm(extractDir, { recursive: true }) - report.artifactsRemoved.push(extractDir) - } + return bookRepository().getBook(bookId) } export async function saveBook(meta: BookMeta): Promise { - BookMetaSchema.parse(meta) - const dir = bookDir(meta.id) - await mkdir(dir, { recursive: true }) - await mkdir(join(dir, 'chapters'), { recursive: true }) - await mkdir(join(dir, 'feedback'), { recursive: true }) - await writeYaml(join(dir, 'meta.yml'), meta) + return bookRepository().saveBook(meta) } export async function deleteBook(bookId: string): Promise { - const dir = bookDir(bookId) - if (existsSync(dir)) { - const stat = await lstat(dir) - if (!stat.isDirectory()) { - throw new Error('Invalid book directory') - } - await rm(dir, { recursive: true }) - } + return bookRepository().deleteBook(bookId) } // --- Reset --- -function stripUserAnswers(quiz: Quiz): Quiz { - return { - ...quiz, - questions: quiz.questions.map(({ userAnswer: _u, correct: _c, ...rest }) => rest), - } -} - export async function resetBook(bookId: string): Promise { - const meta = await getBook(bookId) - if (meta.status === 'generating' || meta.status === 'generating_toc') { - throw new Error(`Cannot reset book "${bookId}" while it is generating`) - } - - const dir = bookDir(bookId) - - // Delete progress.yml - const progressPath = join(dir, 'progress.yml') - if (existsSync(progressPath)) await rm(progressPath) - - // Delete every feedback/*.yml file (keep the directory itself; saveBook re-creates it) - const feedbackDir = join(dir, 'feedback') - if (existsSync(feedbackDir)) { - for (const file of await readdir(feedbackDir)) { - if (file.endsWith('.yml')) await rm(join(feedbackDir, file)) - } - } - - // Strip userAnswer/correct from per-chapter quiz files (keep the questions) - const quizDir = join(dir, 'quiz') - if (existsSync(quizDir)) { - for (const file of await readdir(quizDir)) { - if (!file.endsWith('.yml')) continue - const path = join(quizDir, file) - const quiz = await readYaml(path, QuizSchema) - await writeYaml(path, stripUserAnswers(quiz)) - } - } - - // Strip userAnswer/correct from final-quiz.yml (keep the questions) - const finalQuizPath = join(dir, 'final-quiz.yml') - if (existsSync(finalQuizPath)) { - const finalQuiz = await readYaml(finalQuizPath, QuizSchema) - await writeYaml(finalQuizPath, stripUserAnswers(finalQuiz)) - } - - // Reset meta: drop rating/finalQuiz* fields, set status to 'reading', refresh updatedAt - const { rating: _r, finalQuizScore: _s, finalQuizTotal: _t, ...rest } = meta - await saveBook({ - ...rest, - status: 'reading', - updatedAt: new Date().toISOString(), - }) + return bookRepository().resetBook(bookId) } // --- Table of Contents --- export async function getToc(bookId: string): Promise { - return readYaml(join(bookDir(bookId), 'toc.yml'), TocSchema) + return bookRepository().getToc(bookId) } export async function saveToc(bookId: string, toc: Toc): Promise { - TocSchema.parse(toc) - await writeYaml(join(bookDir(bookId), 'toc.yml'), toc) + return bookRepository().saveToc(bookId, toc) } // --- Chapters --- export async function getChapter(bookId: string, chapterNum: number): Promise { - const padded = String(chapterNum).padStart(2, '0') - return readFile(join(bookDir(bookId), 'chapters', `${padded}.md`), 'utf-8') + return bookRepository().getChapter(bookId, chapterNum) } export async function saveChapter(bookId: string, chapterNum: number, content: string): Promise { - const dir = join(bookDir(bookId), 'chapters') - await mkdir(dir, { recursive: true }) - const padded = String(chapterNum).padStart(2, '0') - const tmp = join(dir, `${padded}.md.tmp`) - await writeFile(tmp, content, 'utf-8') - await rename(tmp, join(dir, `${padded}.md`)) + return bookRepository().saveChapter(bookId, chapterNum, content) } export async function chapterExists(bookId: string, chapterNum: number): Promise { - const padded = String(chapterNum).padStart(2, '0') - return existsSync(join(bookDir(bookId), 'chapters', `${padded}.md`)) + return bookRepository().chapterExists(bookId, chapterNum) } // --- Quiz --- export async function getQuiz(bookId: string, chapterNum: number): Promise { - const padded = String(chapterNum).padStart(2, '0') - return readYaml(join(bookDir(bookId), 'quiz', `${padded}.yml`), QuizSchema) + return bookRepository().getQuiz(bookId, chapterNum) } export async function saveQuiz(bookId: string, chapterNum: number, quiz: Quiz): Promise { - QuizSchema.parse(quiz) - const dir = join(bookDir(bookId), 'quiz') - await mkdir(dir, { recursive: true }) - const padded = String(chapterNum).padStart(2, '0') - await writeYaml(join(dir, `${padded}.yml`), quiz) + return bookRepository().saveQuiz(bookId, chapterNum, quiz) } export async function quizExists(bookId: string, chapterNum: number): Promise { - const padded = String(chapterNum).padStart(2, '0') - return existsSync(join(bookDir(bookId), 'quiz', `${padded}.yml`)) + return bookRepository().quizExists(bookId, chapterNum) } // --- Final Quiz --- export async function getFinalQuiz(bookId: string): Promise { - return readYaml(join(bookDir(bookId), 'final-quiz.yml'), QuizSchema) + return bookRepository().getFinalQuiz(bookId) } export async function saveFinalQuiz(bookId: string, quiz: Quiz): Promise { - QuizSchema.parse(quiz) - await writeYaml(join(bookDir(bookId), 'final-quiz.yml'), quiz) + return bookRepository().saveFinalQuiz(bookId, quiz) } export function finalQuizExists(bookId: string): boolean { - return existsSync(join(bookDir(bookId), 'final-quiz.yml')) + return bookRepository().finalQuizExists(bookId) } // --- Progress --- export async function getProgress(bookId: string): Promise { - const path = join(bookDir(bookId), 'progress.yml') - if (!existsSync(path)) return { chapters: {} } - return readYaml(path, ProgressSchema) + return bookRepository().getProgress(bookId) } export async function saveChapterProgress( @@ -424,387 +158,229 @@ export async function saveChapterProgress( chapterNum: number, progress: ChapterProgress, ): Promise { - const current = await getProgress(bookId) - current.chapters[String(chapterNum)] = progress - await writeYaml(join(bookDir(bookId), 'progress.yml'), current) + return bookRepository().saveChapterProgress(bookId, chapterNum, progress) } export async function getChaptersRead(bookId: string): Promise { - const progress = await getProgress(bookId) - return Object.values(progress.chapters).filter(ch => ch.completed).length + return bookRepository().getChaptersRead(bookId) } // --- Feedback --- export async function getFeedback(bookId: string, chapterNum: number): Promise { - const padded = String(chapterNum).padStart(2, '0') - return readYaml(join(bookDir(bookId), 'feedback', `${padded}.yml`), FeedbackSchema) + return bookRepository().getFeedback(bookId, chapterNum) } export async function saveFeedback(bookId: string, chapterNum: number, feedback: Feedback): Promise { - FeedbackSchema.parse(feedback) - const padded = String(chapterNum).padStart(2, '0') - await writeYaml(join(bookDir(bookId), 'feedback', `${padded}.yml`), feedback) -} - -export interface SkillProgressResult { - stats: { totalBooks: number; completedBooks: number; totalChapters: number; completedChapters: number } - skills: Array<{ - name: string - totalWeight: number - completedWeight: number - lastActivityAt?: string - books: Array<{ bookId: string; title: string; weight: number; completed: boolean; lastActivityAt?: string }> - subskills: Array<{ name: string; totalWeight: number; completedWeight: number }> - }> -} - -export async function getSkillProgress(): Promise { - const allBooks = await listBooks() - - let totalBooks = 0 - let completedBooks = 0 - let totalChapters = 0 - let completedChapters = 0 - - const skillMap = new Map - subskills: Map - }>() - - for (const book of allBooks) { - let toc: Toc - let progress: Progress - try { - toc = await getToc(book.id) - progress = await getProgress(book.id) - } catch { - continue - } - - if (!toc.skills || toc.skills.length === 0) continue - - totalBooks++ - const chapCount = toc.chapters.length - totalChapters += chapCount - - let bookCompletedChapters = 0 - let bookLastActivity: string | undefined - for (let i = 1; i <= chapCount; i++) { - const ch = progress.chapters[String(i)] - if (ch?.completed) { - bookCompletedChapters++ - if (ch.completedAt && (!bookLastActivity || ch.completedAt > bookLastActivity)) { - bookLastActivity = ch.completedAt - } - } - } - if (!bookLastActivity) bookLastActivity = book.updatedAt - completedChapters += bookCompletedChapters - const bookComplete = bookCompletedChapters === chapCount - - if (bookComplete) completedBooks++ - - for (const skill of toc.skills) { - let entry = skillMap.get(skill.name) - if (!entry) { - entry = { - name: skill.name, - totalWeight: 0, - completedWeight: 0, - books: [], - subskills: new Map(), - } - skillMap.set(skill.name, entry) - } - entry.totalWeight += skill.weight - if (bookComplete) entry.completedWeight += skill.weight - entry.books.push({ - bookId: book.id, - title: book.title, - weight: skill.weight, - completed: bookComplete, - lastActivityAt: bookLastActivity, - }) - } + return bookRepository().saveFeedback(bookId, chapterNum, feedback) +} - for (let i = 0; i < toc.chapters.length; i++) { - const ch = toc.chapters[i] - if (!ch.skills) continue - const chapterCompleted = !!progress.chapters[String(i + 1)]?.completed - - for (const cs of ch.skills) { - const skillEntry = skillMap.get(cs.skill) - if (!skillEntry) continue - - let sub = skillEntry.subskills.get(cs.subskill) - if (!sub) { - sub = { name: cs.subskill, totalWeight: 0, completedWeight: 0 } - skillEntry.subskills.set(cs.subskill, sub) - } - sub.totalWeight += cs.weight - if (chapterCompleted) sub.completedWeight += cs.weight - } - } - } +export async function getAllFeedback(bookId: string): Promise { + return bookRepository().getAllFeedback(bookId) +} - const skills = Array.from(skillMap.values()).map(s => { - const bookDates = s.books.map(b => b.lastActivityAt).filter(Boolean) as string[] - const lastActivityAt = bookDates.length > 0 ? bookDates.sort().pop() : undefined - return { - ...s, - lastActivityAt, - subskills: Array.from(s.subskills.values()), - } - }) +// --- Skill progress --- - return { - stats: { totalBooks, completedBooks, totalChapters, completedChapters }, - skills, - } +export async function getSkillProgress(): Promise { + return bookRepository().getSkillProgress() } // --- Cover --- -const COVER_EXTENSIONS = ['png', 'jpg', 'webp'] - export async function getCoverPath(bookId: string): Promise { - const dir = bookDir(bookId) - for (const ext of COVER_EXTENSIONS) { - const p = join(dir, `cover.${ext}`) - if (existsSync(p)) return p - } - return null + return artifactStore().getCoverPath(bookId) } export async function hasCover(bookId: string): Promise { - return (await getCoverPath(bookId)) !== null + return artifactStore().hasCover(bookId) } export async function getCoverMtime(bookId: string): Promise { - const coverPath = await getCoverPath(bookId) - if (!coverPath) return null - const s = await stat(coverPath) - return s.mtime + return artifactStore().getCoverMtime(bookId) } export async function saveCover(bookId: string, data: Buffer, mediaType: string): Promise { - const dir = bookDir(bookId) - await mkdir(dir, { recursive: true }) - - // Delete any existing cover first - await deleteCover(bookId) - - const ext = mediaType === 'image/jpeg' ? 'jpg' - : mediaType === 'image/webp' ? 'webp' - : 'png' - const dest = join(dir, `cover.${ext}`) - const tmp = dest + '.tmp' - await writeFile(tmp, data) - await rename(tmp, dest) + return artifactStore().saveCover(bookId, data, mediaType) } export async function deleteCover(bookId: string): Promise { - const dir = bookDir(bookId) - for (const ext of COVER_EXTENSIONS) { - const p = join(dir, `cover.${ext}`) - if (existsSync(p)) { - await rm(p) - } - } + return artifactStore().deleteCover(bookId) } // --- EPUB cache --- export function epubPath(bookId: string): string { - return join(bookDir(bookId), 'book.epub') + return artifactStore().epubPath(bookId) } export function epubExists(bookId: string): boolean { - return existsSync(epubPath(bookId)) + return artifactStore().epubExists(bookId) } // --- Audiobook --- export function audioDir(bookId: string): string { - return join(bookDir(bookId), 'audio') + return artifactStore().audioDir(bookId) } export function audiobookPath(bookId: string): string { - return join(bookDir(bookId), 'book.m4b') + return artifactStore().audiobookPath(bookId) } +/** + * Not part of the ArtifactStore port, only audioDir() and the manifest + * read/write methods are. Derived here from audioDir() so the one external + * caller that reaches for the manifest's path directly today, + * audiobook-generator.test.ts, keeps working unchanged. + */ export function audiobookManifestPath(bookId: string): string { - return join(audioDir(bookId), 'manifest.yml') + return join(artifactStore().audioDir(bookId), 'manifest.yml') } export function chapterAudioPath(bookId: string, chapterNum: number): string { - const padded = String(chapterNum).padStart(2, '0') - return join(audioDir(bookId), `${padded}.mp3`) + return artifactStore().chapterAudioPath(bookId, chapterNum) } export function chapterWavPath(bookId: string, chapterNum: number): string { - const padded = String(chapterNum).padStart(2, '0') - return join(audioDir(bookId), `${padded}.wav`) + return artifactStore().chapterWavPath(bookId, chapterNum) } export function audiobookExists(bookId: string): boolean { - return existsSync(audiobookPath(bookId)) + return artifactStore().audiobookExists(bookId) } -/** - * True iff this chapter's audio is available for playback. Reads the - * manifest (the source of truth post the architecture change away from - * per-chapter MP3 files); falls back to the legacy on-disk MP3 file for - * audiobooks generated before that change. - */ export async function chapterAudioExists(bookId: string, chapterNum: number): Promise { - if (existsSync(chapterAudioPath(bookId, chapterNum))) return true - const manifest = await getAudiobookManifest(bookId) - return !!manifest?.chapters.some(c => c.num === chapterNum) + return artifactStore().chapterAudioExists(bookId, chapterNum) } export async function getAudiobookManifest(bookId: string): Promise { - const path = audiobookManifestPath(bookId) - if (!existsSync(path)) return null - return readYaml(path, AudiobookManifestSchema) + return artifactStore().getAudiobookManifest(bookId) } export async function saveAudiobookManifest(bookId: string, manifest: AudiobookManifest): Promise { - AudiobookManifestSchema.parse(manifest) - await writeYaml(audiobookManifestPath(bookId), manifest) + return artifactStore().saveAudiobookManifest(bookId, manifest) } -/** - * Remove all audiobook artifacts for a book: the M4B file, every per-chapter - * MP3 / leftover WAV, and the manifest. Used before a fresh regeneration so - * the next run starts from a clean slate (no stale chapter contamination). - */ export async function deleteAudiobookArtifacts(bookId: string): Promise { - const m4b = audiobookPath(bookId) - if (existsSync(m4b)) { - await rm(m4b) - } - const dir = audioDir(bookId) - if (existsSync(dir)) { - await rm(dir, { recursive: true }) - } -} - -export async function getAllFeedback(bookId: string): Promise { - const feedbackDir = join(bookDir(bookId), 'feedback') - if (!existsSync(feedbackDir)) return [] - - const entries = await readdir(feedbackDir) - const feedbacks: Feedback[] = [] - - for (const entry of entries.filter(e => e.endsWith('.yml')).sort()) { - try { - const fb = await readYaml(join(feedbackDir, entry), FeedbackSchema) - feedbacks.push(fb) - } catch { - // Skip invalid feedback files - } - } - - return feedbacks + return artifactStore().deleteAudiobookArtifacts(bookId) } // --- Brief --- export async function saveBrief(bookId: string, content: string): Promise { - const dir = bookDir(bookId) - await mkdir(dir, { recursive: true }) - const dest = join(dir, 'brief.md') - const tmp = dest + '.tmp' - await writeFile(tmp, content, 'utf-8') - await rename(tmp, dest) + return bookRepository().saveBrief(bookId, content) } export async function getBrief(bookId: string): Promise { - return readFile(join(bookDir(bookId), 'brief.md'), 'utf-8') + return bookRepository().getBrief(bookId) } // --- Summaries --- export async function saveSummary(bookId: string, chapterNum: number, summary: ChapterSummary): Promise { - ChapterSummarySchema.parse(summary) - const dir = join(bookDir(bookId), 'summaries') - await mkdir(dir, { recursive: true }) - const padded = String(chapterNum).padStart(2, '0') - await writeYaml(join(dir, `${padded}.yml`), summary) + return bookRepository().saveSummary(bookId, chapterNum, summary) } export async function getSummary(bookId: string, chapterNum: number): Promise { - const padded = String(chapterNum).padStart(2, '0') - return readYaml(join(bookDir(bookId), 'summaries', `${padded}.yml`), ChapterSummarySchema) + return bookRepository().getSummary(bookId, chapterNum) } export async function getAllSummaries(bookId: string): Promise { - const summariesDir = join(bookDir(bookId), 'summaries') - if (!existsSync(summariesDir)) return [] - - const entries = await readdir(summariesDir) - const summaries: ChapterSummary[] = [] - - for (const entry of entries.filter(e => e.endsWith('.yml')).sort()) { - try { - const s = await readYaml(join(summariesDir, entry), ChapterSummarySchema) - summaries.push(s) - } catch { - // Skip invalid summary files - } - } - - return summaries + return bookRepository().getAllSummaries(bookId) } // --- References --- -/** Validates that a reference name contains only alphanumeric characters and hyphens (no path traversal). */ -function validateReferenceName(name: string): void { - if (!/^[a-zA-Z0-9-]+$/.test(name)) { - throw new Error(`Invalid reference name: "${name}". Only alphanumeric characters and hyphens are allowed.`) - } -} - export async function saveReference(bookId: string, name: string, content: string): Promise { - validateReferenceName(name) - const dir = join(bookDir(bookId), 'references') - await mkdir(dir, { recursive: true }) - - // Write content file atomically - const dest = join(dir, `${name}.md`) - const tmp = dest + '.tmp' - await writeFile(tmp, content, 'utf-8') - await rename(tmp, dest) - - // Update manifest: read existing, upsert entry, write back - const manifestPath = join(dir, 'manifest.yml') - const manifest: ReferenceManifest = existsSync(manifestPath) - ? await readYaml(manifestPath, ReferenceManifestSchema) - : [] - - const idx = manifest.findIndex(e => e.name === name) - if (idx >= 0) { - manifest[idx] = { ...manifest[idx], name } - } else { - manifest.push({ name }) - } - - await writeYaml(manifestPath, manifest) + return bookRepository().saveReference(bookId, name, content) } export async function getReference(bookId: string, name: string): Promise { - validateReferenceName(name) - return readFile(join(bookDir(bookId), 'references', `${name}.md`), 'utf-8') + return bookRepository().getReference(bookId, name) } export async function listReferences(bookId: string): Promise { - const manifestPath = join(bookDir(bookId), 'references', 'manifest.yml') - if (!existsSync(manifestPath)) return [] - return readYaml(manifestPath, ReferenceManifestSchema) + return bookRepository().listReferences(bookId) +} + +// --- Crash recovery --- + +/** + * The one export that cannot be a single delegating line. ArtifactStore's + * own recoverFromCrash() (see its port doc comment) only ever reports + * artifactsRemoved, because reconciling a book's status after a crash + * needs to read and write BookRepository data that ArtifactStore cannot + * see. This function is that composition: it runs the artifact-only + * recovery, sweeps this repository's own leftover .tmp files book by + * book, then walks every book through BookRepository to reconcile status + * and audioGeneratedChapters, exactly as this module has always done in + * one combined pass. + */ +export async function recoverFromCrash(): Promise { + const dataDir = getDataDir() + const repo = createFsBookRepository({ dataDir }) + const artifacts = createFsArtifactStore({ dataDir }) + + const report = await artifacts.recoverFromCrash() + const books = await repo.listBooks() + + for (const book of books) { + const tmpRemoved = await cleanTmpArtifacts(dataDir, book.id) + report.artifactsRemoved.push(...tmpRemoved) + } + + for (const book of books) { + const audioWiped = report.artifactsRemoved.includes(artifacts.audioDir(book.id)) + + const originalStatus = book.status + let newStatus = originalStatus + let reason = '' + + if (originalStatus === 'generating_toc') { + // A file that exists but fails to parse or validate still counts as + // "the TOC was written", matching the original's plain existsSync + // check on toc.yml; only a genuinely missing file (ENOENT) means the + // stream never got that far. + let tocExists = true + try { + await repo.getToc(book.id) + } catch (err) { + tocExists = (err as { code?: string })?.code !== 'ENOENT' + } + if (tocExists) { + newStatus = 'toc_review' + reason = 'TOC was written before crash; user can approve/edit/regen' + } else { + newStatus = 'failed' + reason = 'TOC stream was interrupted before toc.yml landed; nothing to recover' + } + } else if (originalStatus === 'generating') { + newStatus = book.generatedUpTo > 0 ? 'reading' : 'toc_review' + reason = book.generatedUpTo > 0 + ? `${book.generatedUpTo} chapter(s) saved; user can read and retry next` + : 'No chapters saved yet; back to TOC review' + } + + if (newStatus !== originalStatus || audioWiped) { + const updated: BookMeta = { ...book } + if (newStatus !== originalStatus) updated.status = newStatus + if (audioWiped) updated.audioGeneratedChapters = [] + updated.updatedAt = new Date().toISOString() + await repo.saveBook(updated) + if (newStatus !== originalStatus) { + report.booksReset.push({ id: book.id, title: book.title, from: originalStatus, to: newStatus, reason }) + console.warn(`[startup] Recovered "${book.title}" (${book.id}): ${originalStatus} -> ${newStatus} (${reason})`) + } + } + } + + return report +} + +/** + * Backward-compatible alias kept for any external callers (e.g., tests + * referencing the old name). New code should call recoverFromCrash(). + */ +export async function recoverStuckBooks(): Promise { + await recoverFromCrash() } From 564188137ebb0414b3522f017cb5f2a75643bc9f Mon Sep 17 00:00:00 2001 From: Ross Miller Date: Mon, 20 Jul 2026 20:52:47 -0500 Subject: [PATCH 019/126] test(services): rebuild book-store tests over a real temp dir book-store.test.ts mocked shared/node/data-dir.js so the module under test would always resolve to a temporary directory. Now that book-store.ts resolves its data directory through a real, unmocked getDataDir() on every call, the same isolation follows from setting the real TUTOR_DATA_DIR environment variable to a fresh temporary directory in beforeEach and restoring it in afterEach. Every existing test keeps its exact assertions. Only the setup and teardown changed. Claude-Session: https://claude.ai/code/session_01FSkESkJTRpt3Ye2cM7c5n5 --- server/services/book-store.test.ts | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/server/services/book-store.test.ts b/server/services/book-store.test.ts index 9c96c0f..90ce664 100644 --- a/server/services/book-store.test.ts +++ b/server/services/book-store.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { mkdtemp, rm, mkdir } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' @@ -6,17 +6,16 @@ import { writeFile } from 'node:fs/promises' import { existsSync } from 'node:fs' import { stringify as stringifyYaml } from 'yaml' import type { BookMeta, Feedback, LearningProfile, Quiz, Toc } from '@shared/domain.js' +import * as store from './book-store.js' -// Mock getDataDir at module level so book-store ALWAYS uses temp dir. -// This prevents tests from ever writing to the production data directory. +// book-store.js is a thin shim: every call resolves getDataDir() fresh (see +// its own doc comment) and builds the real fs adapters over the result, so +// pointing TUTOR_DATA_DIR at a fresh temp directory per test is enough to +// isolate every test from the production data directory and from each +// other. No module mock is needed for that; the real env var the real +// getDataDir() already reads does the job. let testDir: string - -vi.mock('@shared/node/data-dir.js', () => ({ - getDataDir: () => testDir, -})) - -// Import AFTER mock is set up — vitest hoists vi.mock automatically -import * as store from './book-store.js' +const productionDataDir = process.env.TUTOR_DATA_DIR describe('book-store', () => { const testMeta: BookMeta = { @@ -62,6 +61,7 @@ describe('book-store', () => { beforeEach(async () => { testDir = await mkdtemp(join(tmpdir(), 'tutor-test-')) + process.env.TUTOR_DATA_DIR = testDir await mkdir(join(testDir, 'books'), { recursive: true }) // Write a learning profile so getProfile works @@ -74,6 +74,11 @@ describe('book-store', () => { afterEach(async () => { await rm(testDir, { recursive: true }) + if (productionDataDir === undefined) { + delete process.env.TUTOR_DATA_DIR + } else { + process.env.TUTOR_DATA_DIR = productionDataDir + } }) describe('learning profile', () => { From 593718aa543c17243a1d6f43534127c2e6e9bb7a Mon Sep 17 00:00:00 2001 From: Ross Miller Date: Mon, 20 Jul 2026 20:51:04 -0500 Subject: [PATCH 020/126] feat(adapters): add the real in-memory BackgroundTasks adapter with contract tests This adds server/adapters/in-memory-background-tasks.ts, the real BackgroundTasks implementation the port added in stage four described but did not yet have. Its logic is lifted from server/services/task-manager.ts. The same task, controller, and subscriber bookkeeping carries over, along with the same cleanup delay after a task finishes. All of it is reshaped into a factory, so state now lives in a fresh closure created by each call instead of a module scope Map that persists for the whole process from the moment it is first imported. The controller behind each task stays private to the adapter. A caller only ever gets back a TaskHandle exposing an id and a signal, never the AbortController that can trigger it. This matches the port's own contract test, which pins that a handle carries exactly those two fields and nothing else. createInMemoryBackgroundTasks accepts an optional newId override. It defaults to node:crypto's randomUUID, and a test can supply its own generator instead, without needing to fake the whole module. The test file runs the existing describeBackgroundTasksContract suite against this real adapter, the same suite that already runs against the fake from stage four. Two whitebox tests are added alongside it. One proves two instances never share state. The other proves the injected id generator is actually used. This adds fifteen passing tests and touches no existing file. Claude-Session: https://claude.ai/code/session_01FSkESkJTRpt3Ye2cM7c5n5 --- .../in-memory-background-tasks.test.ts | 29 ++++ server/adapters/in-memory-background-tasks.ts | 142 ++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 server/adapters/in-memory-background-tasks.test.ts create mode 100644 server/adapters/in-memory-background-tasks.ts diff --git a/server/adapters/in-memory-background-tasks.test.ts b/server/adapters/in-memory-background-tasks.test.ts new file mode 100644 index 0000000..4a322fa --- /dev/null +++ b/server/adapters/in-memory-background-tasks.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' +import { createInMemoryBackgroundTasks } from './in-memory-background-tasks.js' +import { describeBackgroundTasksContract } from '../ports/background-tasks.contract.js' + +describeBackgroundTasksContract('real in-memory adapter', () => createInMemoryBackgroundTasks()) + +describe('createInMemoryBackgroundTasks (whitebox)', () => { + it('gives independent tasks to independently created instances, proving no import-time shared state', () => { + const a = createInMemoryBackgroundTasks() + const b = createInMemoryBackgroundTasks() + + const handle = a.start({ type: 'generate-cover', bookId: 'book-1', bookTitle: 'A', total: 1 }) + + expect(a.get(handle.id)).toBeDefined() + expect(b.get(handle.id)).toBeUndefined() + expect(b.list()).toEqual([]) + }) + + it('uses the injected newId instead of generating a random one', () => { + let calls = 0 + const instance = createInMemoryBackgroundTasks({ newId: () => `deterministic-${++calls}` }) + + const first = instance.start({ type: 'generate-cover', bookId: 'book-1', bookTitle: 'A', total: 1 }) + const second = instance.start({ type: 'generate-cover', bookId: 'book-1', bookTitle: 'A', total: 1 }) + + expect(first.id).toBe('deterministic-1') + expect(second.id).toBe('deterministic-2') + }) +}) diff --git a/server/adapters/in-memory-background-tasks.ts b/server/adapters/in-memory-background-tasks.ts new file mode 100644 index 0000000..2d1ba09 --- /dev/null +++ b/server/adapters/in-memory-background-tasks.ts @@ -0,0 +1,142 @@ +import { randomUUID } from 'node:crypto' +import type { TaskEvent } from '@shared/events.js' +import type { BackgroundTasks, StartTaskSpec, Task, TaskHandle } from '../ports/background-tasks.js' + +/** + * Mirrors TASK_CLEANUP_DELAY_MS in server/constants.ts (60 seconds, today). + * Adapters stay free of imports outside node:*, @shared/*, and ../ports/*, + * so the value is pinned here directly rather than imported, the same way + * server/ports/background-tasks.fake.ts already pins it. If the real + * constant ever changes, the eviction-timing contract test is what catches + * the drift. + */ +const EVICTION_DELAY_MS = 60_000 + +export interface InMemoryBackgroundTasksDeps { + /** Generates a fresh task id. Defaults to node:crypto randomUUID, overridable for deterministic tests. */ + newId?: () => string +} + +/** + * The real BackgroundTasks adapter. Its logic is lifted verbatim from the + * pre-port server/services/task-manager.ts, a bare module of exported + * functions closing over one module-level Map, reshaped into a factory so + * every call gets its own state in a fresh closure instead of state that + * lives for the lifetime of the process from the moment the module is + * first imported. + * + * server/services/task-manager.ts now holds a single module-scope instance + * of this factory and re-exports its behaviour under the original function + * names, so every existing call site keeps working unchanged. See that + * file's own header comment for why it still exists as a shim. + * + * The controller behind each TaskHandle is kept in a private, adapter-only + * Map and is never returned to a caller, only its signal is (see + * TaskHandle in server/ports/background-tasks.ts). Only this factory's own + * cancel() may ever call controller.abort(). + */ +export function createInMemoryBackgroundTasks(deps: InMemoryBackgroundTasksDeps = {}): BackgroundTasks { + const newId = deps.newId ?? randomUUID + const tasks = new Map() + const controllers = new Map() + const subscribers = new Set<(event: TaskEvent) => void>() + + const emit = (event: TaskEvent): void => { + for (const cb of subscribers) { + try { + cb(event) + } catch { + // A broken subscriber must not stop the others from being notified. + } + } + } + + const scheduleEviction = (taskId: string): void => { + setTimeout(() => { + tasks.delete(taskId) + controllers.delete(taskId) + }, EVICTION_DELAY_MS) + } + + return { + start(spec: StartTaskSpec): TaskHandle { + const id = newId() + const controller = new AbortController() + const task: Task = { + id, + type: spec.type, + bookId: spec.bookId, + bookTitle: spec.bookTitle, + status: 'running', + progress: { current: 0, total: spec.total, label: 'Starting...' }, + } + tasks.set(id, task) + controllers.set(id, controller) + emit({ type: 'task_created', task: { ...task } }) + return { id, signal: controller.signal } + }, + + report(taskId, current, label) { + const task = tasks.get(taskId) + if (!task || task.status !== 'running') return + task.progress = { ...task.progress, current, label } + emit({ type: 'task_progress', taskId, progress: task.progress }) + }, + + succeed(taskId, result) { + const task = tasks.get(taskId) + if (!task) return + task.status = 'done' + task.result = result + task.progress = { ...task.progress, current: task.progress.total, label: 'Complete' } + emit({ type: 'task_done', taskId, taskType: task.type, result }) + scheduleEviction(taskId) + }, + + fail(taskId, error) { + const task = tasks.get(taskId) + if (!task) return + task.status = 'error' + task.error = error + emit({ type: 'task_error', taskId, taskType: task.type, error }) + scheduleEviction(taskId) + }, + + cancel(taskId) { + const task = tasks.get(taskId) + const controller = controllers.get(taskId) + if (!task || !controller || task.status !== 'running') return false + controller.abort() + task.status = 'cancelled' + emit({ type: 'task_cancelled', taskId }) + scheduleEviction(taskId) + return true + }, + + get(taskId) { + const task = tasks.get(taskId) + return task ? { ...task } : undefined + }, + + list() { + return Array.from(tasks.values()).map(task => ({ ...task })) + }, + + findActive(bookId, type) { + for (const task of tasks.values()) { + if (task.bookId === bookId && task.status === 'running' && (!type || task.type === type)) { + const controller = controllers.get(task.id) + if (controller) return { id: task.id, signal: controller.signal } + } + } + return undefined + }, + + subscribe(cb) { + subscribers.add(cb) + return () => { + subscribers.delete(cb) + } + }, + } +} From 2cdc272f012fa9d03b79acda8e8b08e9d68f2e04 Mon Sep 17 00:00:00 2001 From: Ross Miller Date: Mon, 20 Jul 2026 20:51:28 -0500 Subject: [PATCH 021/126] refactor(services): make task-manager a thin shim over the BackgroundTasks adapter server/services/task-manager.ts was a bare module of exported functions closing over one module scope Map. It now constructs a single instance of createInMemoryBackgroundTasks and re-exports the exact same function names and signatures over that one instance, so every existing call site keeps compiling and behaving the same way without being touched in this change. Two fields callers still read do not fit the port's own Task shape. Both live only in this shim, never inside the adapter. createdAt is a race guard that server/routes/covers.ts checks against a cover image set after generation started. A callable abortController is read directly by several routes and by server/services/audiobook-generator.ts, and its test calls abort on it directly to simulate a mid-generation cancellation. Both fields are kept in a small side table keyed by task id, evicted on the same delay and at the same lifecycle points the adapter already uses for its own state. The file carries a JSDoc block explaining that this shim is temporary. A later stage is expected to move each call site onto the BackgroundTasks port directly, constructed once at the composition root instead of imported as a singleton. This file exists only so the singleton to factory conversion could land as one atomic change. This change is verified by the adapter's own contract tests added in the previous commit, by the existing audiobook generator test that calls abortController.abort() directly, and by the existing route characterization tests. All of them pass unchanged. Claude-Session: https://claude.ai/code/session_01FSkESkJTRpt3Ye2cM7c5n5 --- server/services/task-manager.ts | 135 +++++++++++++++----------------- 1 file changed, 64 insertions(+), 71 deletions(-) diff --git a/server/services/task-manager.ts b/server/services/task-manager.ts index a0f359d..892a37a 100644 --- a/server/services/task-manager.ts +++ b/server/services/task-manager.ts @@ -1,6 +1,35 @@ -import { randomUUID } from 'node:crypto' +import { createInMemoryBackgroundTasks } from '../adapters/in-memory-background-tasks.js' import { TASK_CLEANUP_DELAY_MS } from '../constants.js' +/** + * THIS MODULE IS A TEMPORARY SHIM. + * + * server/services/task-manager.ts used to be a bare module of exported + * functions closing over one module-level Map. The real logic now lives + * behind the BackgroundTasks port, in + * server/adapters/in-memory-background-tasks.ts, built as a factory + * instead of import-time state. This file constructs a single module-scope + * instance of that factory and re-exports its behaviour under the exact + * function names and signatures every route file (server/routes/books.ts, + * covers.ts, audiobook.ts, tasks.ts) and server/services/audiobook-generator.ts + * already import, so the singleton-to-factory conversion could land in one + * atomic change without also rewriting every call site in the same commit. + * + * A later stage moves each of those call sites onto the BackgroundTasks + * port directly, constructed once at the composition root and threaded + * through instead of imported as a singleton, and this file goes away. + * + * Two fields callers still read do not fit the port's own Task shape, and + * live only here, never inside the adapter: createdAt (server/routes/covers.ts + * uses it as a race guard against a cover set after generation started) and + * a callable abortController (routes and server/services/audiobook-generator.ts, + * plus its test, read and in one case directly call task.abortController). + * The port's TaskHandle deliberately exposes only a signal, never the + * controller behind it, so this shim keeps its own small side table of + * { createdAt, controller } keyed by task id, evicted on the same delay and + * lifecycle points the adapter itself uses for its own state. + */ + export type TaskType = | 'generate-all' | 'generate-epub' @@ -48,105 +77,69 @@ export interface ClientTask { type GlobalSubscriber = (event: TaskEvent) => void -const tasks = new Map() -const globalSubscribers = new Set() - -function toClientTask(task: BackgroundTask): ClientTask { - return { - id: task.id, - type: task.type, - bookId: task.bookId, - bookTitle: task.bookTitle, - status: task.status, - progress: task.progress, - error: task.error, - result: task.result, - } -} +const instance = createInMemoryBackgroundTasks() -function emitGlobal(event: TaskEvent): void { - for (const cb of globalSubscribers) { - try { cb(event) } catch { /* subscriber error */ } - } -} +const extras = new Map() -function scheduleCleanup(taskId: string): void { +function scheduleExtrasCleanup(taskId: string): void { setTimeout(() => { - tasks.delete(taskId) + extras.delete(taskId) }, TASK_CLEANUP_DELAY_MS) } +function toBackgroundTask(taskId: string): BackgroundTask | undefined { + const task = instance.get(taskId) + const extra = extras.get(taskId) + if (!task || !extra) return undefined + return { ...task, createdAt: extra.createdAt, abortController: extra.controller } +} + export function createTask(type: TaskType, bookId: string, bookTitle: string, total: number): BackgroundTask { - const task: BackgroundTask = { - id: randomUUID(), - type, - bookId, - bookTitle, - status: 'running', - progress: { current: 0, total, label: 'Starting...' }, - createdAt: new Date().toISOString(), - abortController: new AbortController(), - } - tasks.set(task.id, task) - emitGlobal({ type: 'task_created', task: toClientTask(task) }) - return task + const handle = instance.start({ type, bookId, bookTitle, total }) + extras.set(handle.id, { createdAt: new Date().toISOString(), controller: new AbortController() }) + // The entry set above always exists at this id immediately afterward, so + // this lookup can never miss. + return toBackgroundTask(handle.id)! } export function updateProgress(taskId: string, current: number, label: string): void { - const task = tasks.get(taskId) - if (!task || task.status !== 'running') return - task.progress = { ...task.progress, current, label } - emitGlobal({ type: 'task_progress', taskId, progress: task.progress }) + instance.report(taskId, current, label) } export function completeTask(taskId: string, result?: unknown): void { - const task = tasks.get(taskId) - if (!task) return - task.status = 'done' - task.result = result - task.progress = { ...task.progress, current: task.progress.total, label: 'Complete' } - emitGlobal({ type: 'task_done', taskId, taskType: task.type, result }) - scheduleCleanup(taskId) + const existed = instance.get(taskId) !== undefined + instance.succeed(taskId, result) + if (existed) scheduleExtrasCleanup(taskId) } export function failTask(taskId: string, error: string): void { - const task = tasks.get(taskId) - if (!task) return - task.status = 'error' - task.error = error - emitGlobal({ type: 'task_error', taskId, taskType: task.type, error }) - scheduleCleanup(taskId) + const existed = instance.get(taskId) !== undefined + instance.fail(taskId, error) + if (existed) scheduleExtrasCleanup(taskId) } export function cancelTask(taskId: string): boolean { - const task = tasks.get(taskId) - if (!task || task.status !== 'running') return false - task.abortController.abort() - task.status = 'cancelled' - emitGlobal({ type: 'task_cancelled', taskId }) - scheduleCleanup(taskId) - return true + const cancelled = instance.cancel(taskId) + if (cancelled) { + extras.get(taskId)?.controller.abort() + scheduleExtrasCleanup(taskId) + } + return cancelled } export function getTask(taskId: string): ClientTask | undefined { - const task = tasks.get(taskId) - return task ? toClientTask(task) : undefined + return instance.get(taskId) } export function listTasks(): ClientTask[] { - return Array.from(tasks.values()).map(toClientTask) + return instance.list() } export function getActiveTaskForBook(bookId: string, type?: TaskType): BackgroundTask | undefined { - for (const task of tasks.values()) { - if (task.bookId === bookId && task.status === 'running') { - if (!type || task.type === type) return task - } - } - return undefined + const handle = instance.findActive(bookId, type) + return handle ? toBackgroundTask(handle.id) : undefined } export function subscribeGlobal(callback: GlobalSubscriber): () => void { - globalSubscribers.add(callback) - return () => { globalSubscribers.delete(callback) } + return instance.subscribe(callback) } From f13c7b41b264859b8c197ad42150154245cf284a Mon Sep 17 00:00:00 2001 From: Ross Miller Date: Mon, 20 Jul 2026 20:51:58 -0500 Subject: [PATCH 022/126] feat(adapters): add the real system Clock adapter with contract tests This adds server/adapters/system-clock.ts. It is intentionally trivial. nowIso returns new Date().toISOString(). newId returns node:crypto's randomUUID(). Both are the exact calls the roughly fifteen call sites this port is meant to replace already make directly today. The test file runs the existing describeClockContract suite against it. Two whitebox tests are added alongside it. One confirms nowIso reports the real current time rather than a fixed instant. The other confirms newId returns a well formed v4 UUID. This adds five passing tests and touches no existing file. Claude-Session: https://claude.ai/code/session_01FSkESkJTRpt3Ye2cM7c5n5 --- server/adapters/system-clock.test.ts | 22 ++++++++++++++++++++++ server/adapters/system-clock.ts | 14 ++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 server/adapters/system-clock.test.ts create mode 100644 server/adapters/system-clock.ts diff --git a/server/adapters/system-clock.test.ts b/server/adapters/system-clock.test.ts new file mode 100644 index 0000000..1c0b1b5 --- /dev/null +++ b/server/adapters/system-clock.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { createSystemClock } from './system-clock.js' +import { describeClockContract } from '../ports/clock.contract.js' + +describeClockContract('real system clock', () => createSystemClock()) + +describe('createSystemClock (whitebox)', () => { + it('nowIso reports the actual current time, not a fixed instant', () => { + const clock = createSystemClock() + const before = Date.now() + const reported = new Date(clock.nowIso()).getTime() + const after = Date.now() + + expect(reported).toBeGreaterThanOrEqual(before) + expect(reported).toBeLessThanOrEqual(after) + }) + + it('newId returns a v4 UUID', () => { + const clock = createSystemClock() + expect(clock.newId()).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i) + }) +}) diff --git a/server/adapters/system-clock.ts b/server/adapters/system-clock.ts new file mode 100644 index 0000000..337ea7f --- /dev/null +++ b/server/adapters/system-clock.ts @@ -0,0 +1,14 @@ +import { randomUUID } from 'node:crypto' +import type { Clock } from '../ports/clock.js' + +/** + * The real Clock. Trivial by design: the current instant comes straight + * from `Date`, and a fresh id from node:crypto's `randomUUID`, the same + * function the ~15 call sites this port replaces already called directly. + */ +export function createSystemClock(): Clock { + return { + nowIso: () => new Date().toISOString(), + newId: () => randomUUID(), + } +} From fb7cb11e68cb4750d0077a0a4a81ec395ce725ce Mon Sep 17 00:00:00 2001 From: Ross Miller Date: Mon, 20 Jul 2026 20:52:09 -0500 Subject: [PATCH 023/126] feat(adapters): add the real OsFileManager adapter with contract and unit tests This adds server/adapters/os-file-manager.ts. Its logic is lifted from the platform switch inside the POST /api/books/:id/audiobook/reveal route handler in server/routes/books.ts. That handler spawns open -R on macOS, explorer.exe's select flag on Windows, and xdg-open against the parent directory on Linux. The route is not rewired to this adapter in this change. It keeps its own inline copy for now, so this file has no callers yet. A later stage can wire the route to it and delete the inline copy. Both the process spawn call and the platform check are injected dependencies, defaulting to the real node:child_process spawn and the real process.platform. This keeps the adapter fully testable without ever launching a process. The test file runs the existing describeOsFileManagerContract suite against the real adapter with a fake spawn injected, so the contract runs for real without spawning anything. Targeted whitebox tests assert the exact argv for each platform branch, that the spawned process is always unreffed, and that reveal resolves rather than rejecting even when spawn throws. This adds seven passing tests and touches no existing file. Claude-Session: https://claude.ai/code/session_01FSkESkJTRpt3Ye2cM7c5n5 --- server/adapters/os-file-manager.test.ts | 70 +++++++++++++++++++++++++ server/adapters/os-file-manager.ts | 56 ++++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 server/adapters/os-file-manager.test.ts create mode 100644 server/adapters/os-file-manager.ts diff --git a/server/adapters/os-file-manager.test.ts b/server/adapters/os-file-manager.test.ts new file mode 100644 index 0000000..bcec391 --- /dev/null +++ b/server/adapters/os-file-manager.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it, vi } from 'vitest' +import { createOsFileManager } from './os-file-manager.js' +import { describeOsFileManagerContract } from '../ports/os-file-manager.contract.js' + +/** A spawn double that never launches a real process. */ +function fakeSpawn() { + return vi.fn(() => ({ unref: vi.fn() })) +} + +describeOsFileManagerContract('real adapter', () => createOsFileManager({ spawn: fakeSpawn(), platform: 'darwin' })) + +describe('createOsFileManager (whitebox)', () => { + it('on darwin, reveals with `open -R `', async () => { + const spawn = fakeSpawn() + const manager = createOsFileManager({ spawn, platform: 'darwin' }) + + await manager.reveal('/books/some-book/audiobook.m4b') + + expect(spawn).toHaveBeenCalledWith( + 'open', + ['-R', '/books/some-book/audiobook.m4b'], + { detached: true, stdio: 'ignore' }, + ) + }) + + it('on win32, reveals with `explorer.exe /select,`', async () => { + const spawn = fakeSpawn() + const manager = createOsFileManager({ spawn, platform: 'win32' }) + + await manager.reveal('C:\\books\\some-book\\audiobook.m4b') + + expect(spawn).toHaveBeenCalledWith( + 'explorer.exe', + ['/select,', 'C:\\books\\some-book\\audiobook.m4b'], + { detached: true, stdio: 'ignore' }, + ) + }) + + it('on linux, opens the parent directory with `xdg-open`', async () => { + const spawn = fakeSpawn() + const manager = createOsFileManager({ spawn, platform: 'linux' }) + + await manager.reveal('/books/some-book/audiobook.m4b') + + expect(spawn).toHaveBeenCalledWith( + 'xdg-open', + ['/books/some-book'], + { detached: true, stdio: 'ignore' }, + ) + }) + + it('calls unref on the spawned process so it does not keep the event loop alive', async () => { + const unref = vi.fn() + const spawn = vi.fn(() => ({ unref })) + const manager = createOsFileManager({ spawn, platform: 'darwin' }) + + await manager.reveal('/books/some-book/audiobook.m4b') + + expect(unref).toHaveBeenCalledTimes(1) + }) + + it('resolves rather than rejecting when spawn throws', async () => { + const spawn = vi.fn(() => { + throw new Error('no such command') + }) + const manager = createOsFileManager({ spawn, platform: 'darwin' }) + + await expect(manager.reveal('/books/some-book/audiobook.m4b')).resolves.toBeUndefined() + }) +}) diff --git a/server/adapters/os-file-manager.ts b/server/adapters/os-file-manager.ts new file mode 100644 index 0000000..67e6c5f --- /dev/null +++ b/server/adapters/os-file-manager.ts @@ -0,0 +1,56 @@ +import { spawn as nodeSpawn } from 'node:child_process' +import type { OsFileManager } from '../ports/os-file-manager.js' + +/** + * The narrow slice of node:child_process's spawn this adapter actually + * calls with. Kept intentionally smaller than spawn's own overloaded + * signature so a test double is trivial to write. + */ +type SpawnFn = ( + command: string, + args: string[], + options: { detached: boolean; stdio: 'ignore' }, +) => { unref(): void } + +export interface OsFileManagerDeps { + /** Defaults to node:child_process's real spawn. Inject a fake so no test ever launches a process. */ + spawn?: SpawnFn + /** Defaults to process.platform. Inject to exercise a platform branch a test isn't running on. */ + platform?: NodeJS.Platform +} + +/** + * The real OsFileManager. Its logic is lifted from the platform switch in + * the POST /api/books/:id/audiobook/reveal route handler in + * server/routes/books.ts: `open -R` on macOS, `explorer.exe /select,` on + * Windows, or `xdg-open` on the parent directory on Linux. That route is + * not rewired to this adapter yet, it keeps its own inline copy for now, + * so this file has no callers today. A later stage wires the route to + * this adapter and deletes its inline copy. + * + * Matches the port's contract exactly: the whole platform switch is + * wrapped in try/catch, and reveal() resolves either way, regardless of + * whether the OS-side command actually succeeded. + */ +export function createOsFileManager(deps: OsFileManagerDeps = {}): OsFileManager { + const spawnFn: SpawnFn = deps.spawn ?? ((command, args, options) => nodeSpawn(command, args, options)) + const platform = deps.platform ?? process.platform + + return { + async reveal(path: string): Promise { + try { + if (platform === 'darwin') { + spawnFn('open', ['-R', path], { detached: true, stdio: 'ignore' }).unref() + } else if (platform === 'win32') { + spawnFn('explorer.exe', ['/select,', path], { detached: true, stdio: 'ignore' }).unref() + } else { + // Linux: xdg-open opens the parent folder (no native reveal-and-select). + const dir = path.substring(0, path.lastIndexOf('/')) + spawnFn('xdg-open', [dir], { detached: true, stdio: 'ignore' }).unref() + } + } catch { + // best-effort — caller falls back to clipboard / IPC / displaying the path + } + }, + } +} From 551ac62ec8b319426a94e618357b5eaf28dd6e90 Mon Sep 17 00:00:00 2001 From: Ross Miller Date: Mon, 20 Jul 2026 20:52:18 -0500 Subject: [PATCH 024/126] feat(adapters): add the real HttpImageGeneration adapter with unit tests This adds server/adapters/http-image-generation.ts. Its logic is lifted from server/services/image-generation.ts, the same OpenAI and Google request shaping, the same categorized error handling, and the same fallback chain that tries a known good model when the preferred one fails for a recoverable reason. The one real change from that module is where the API key comes from. This adapter takes a KeyVault as a constructor dependency and never imports server/services/key-store.ts itself, which is the whole point of this stage. fetch is injected too, defaulting to the global, so a test can exercise every path without making a real HTTP request. The test file injects a fake fetch and the existing fake KeyVault from the port layer. It asserts the missing key error path, the success path, the fallback path when a recoverable failure moves to the next model in the chain, the immediate rejection on an auth failure and on a content policy failure, the exhausted chain error when every model fails, and the immediate rejection when the signal is already aborted. The shared describeImageGenerationContract suite is not run against this adapter. That suite is fake only by its own design, since a real subject would spend money against a live provider, and its makeSubject is typed to the fake's own shape for a failNextAttempt method a real adapter should not have. This adds seven passing tests and touches no existing file. Claude-Session: https://claude.ai/code/session_01FSkESkJTRpt3Ye2cM7c5n5 --- server/adapters/http-image-generation.test.ts | 161 +++++++++++++ server/adapters/http-image-generation.ts | 226 ++++++++++++++++++ 2 files changed, 387 insertions(+) create mode 100644 server/adapters/http-image-generation.test.ts create mode 100644 server/adapters/http-image-generation.ts diff --git a/server/adapters/http-image-generation.test.ts b/server/adapters/http-image-generation.test.ts new file mode 100644 index 0000000..c858fa3 --- /dev/null +++ b/server/adapters/http-image-generation.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it, vi } from 'vitest' +import { createHttpImageGeneration } from './http-image-generation.js' +import { createFakeKeyVault } from '../ports/key-vault.fake.js' + +/** Minimal Response double: only the members this adapter actually reads. */ +function fakeResponse(status: number, body: unknown): Response { + const text = JSON.stringify(body) + return { + ok: status >= 200 && status < 300, + status, + text: async () => text, + json: async () => body, + arrayBuffer: async () => new TextEncoder().encode(text).buffer, + headers: { get: () => 'application/json' }, + } as unknown as Response +} + +function b64Image(marker: string): string { + return Buffer.from(`fake-image:${marker}`).toString('base64') +} + +describe('createHttpImageGeneration', () => { + it('throws when no API key is configured for the provider, without making a request', async () => { + const fetchMock = vi.fn() + const imageGen = createHttpImageGeneration({ keyVault: createFakeKeyVault(), fetch: fetchMock }) + + await expect(imageGen.generate({ + provider: 'openai', + preferredModel: 'gpt-image-1', + prompt: 'a cover', + signal: new AbortController().signal, + })).rejects.toThrow('No API key configured for openai') + + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('returns an image for a valid request, produced by the preferred model', async () => { + const fetchMock = vi.fn(async () => + fakeResponse(200, { data: [{ b64_json: b64Image('gpt-image-1') }] }), + ) + const imageGen = createHttpImageGeneration({ + keyVault: createFakeKeyVault({ openai: 'sk-test' }), + fetch: fetchMock, + }) + + const image = await imageGen.generate({ + provider: 'openai', + preferredModel: 'gpt-image-1', + prompt: 'a minimal abstract book cover', + signal: new AbortController().signal, + }) + + expect(image.data.toString()).toBe('fake-image:gpt-image-1') + expect(image.mediaType).toBe('image/png') + expect(image._diag).toEqual({ modelUsed: 'gpt-image-1', fellBack: false }) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock.mock.calls[0][0]).toBe('https://api.openai.com/v1/images/generations') + }) + + it('falls back to the next model in the chain on a recoverable failure, and reports fellBack', async () => { + const fetchMock = vi.fn(async (_input, init) => { + const body = JSON.parse(String(init?.body ?? '{}')) as { model?: string } + if (body.model === 'gpt-image-1') return fakeResponse(500, { error: { message: 'server error' } }) + if (body.model === 'dall-e-3') return fakeResponse(200, { data: [{ b64_json: b64Image('dall-e-3') }] }) + throw new Error(`unexpected model in test: ${body.model}`) + }) + const imageGen = createHttpImageGeneration({ + keyVault: createFakeKeyVault({ openai: 'sk-test' }), + fetch: fetchMock, + }) + + const image = await imageGen.generate({ + provider: 'openai', + preferredModel: 'gpt-image-1', + prompt: 'cover art', + signal: new AbortController().signal, + }) + + expect(image._diag).toEqual({ modelUsed: 'dall-e-3', fellBack: true }) + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + + it('does not retry a fallback model on an auth failure, it rejects immediately', async () => { + const fetchMock = vi.fn(async () => + fakeResponse(401, { error: { message: 'invalid api key' } }), + ) + const imageGen = createHttpImageGeneration({ + keyVault: createFakeKeyVault({ openai: 'sk-bad' }), + fetch: fetchMock, + }) + + await expect(imageGen.generate({ + provider: 'openai', + preferredModel: 'gpt-image-1', + prompt: 'cover art', + signal: new AbortController().signal, + })).rejects.toThrow('Authentication failed for openai') + + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('does not retry a fallback model on a content-policy failure, it rejects immediately', async () => { + const fetchMock = vi.fn(async () => + fakeResponse(400, { + error: { message: 'Your request was rejected as a result of our safety system.', code: 'content_policy_violation' }, + }), + ) + const imageGen = createHttpImageGeneration({ + keyVault: createFakeKeyVault({ openai: 'sk-test' }), + fetch: fetchMock, + }) + + await expect(imageGen.generate({ + provider: 'openai', + preferredModel: 'gpt-image-1', + prompt: 'cover art', + signal: new AbortController().signal, + })).rejects.toThrow('Image rejected by content policy') + + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('rejects when every model in the chain fails', async () => { + // anthropic has no configured fallback chain, so failing the preferred + // (and only unsupported-provider) model exhausts the whole chain in one + // step, without ever calling fetch. + const fetchMock = vi.fn() + const imageGen = createHttpImageGeneration({ + keyVault: createFakeKeyVault({ anthropic: 'sk-test' }), + fetch: fetchMock, + }) + + await expect(imageGen.generate({ + provider: 'anthropic', + preferredModel: 'preferred-model', + prompt: 'cover art', + signal: new AbortController().signal, + })).rejects.toThrow('All image models failed') + + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('rejects when the signal is already aborted, without making a request', async () => { + const fetchMock = vi.fn() + const imageGen = createHttpImageGeneration({ + keyVault: createFakeKeyVault({ openai: 'sk-test' }), + fetch: fetchMock, + }) + const controller = new AbortController() + controller.abort() + + await expect(imageGen.generate({ + provider: 'openai', + preferredModel: 'gpt-image-1', + prompt: 'cover art', + signal: controller.signal, + })).rejects.toThrow() + + expect(fetchMock).not.toHaveBeenCalled() + }) +}) diff --git a/server/adapters/http-image-generation.ts b/server/adapters/http-image-generation.ts new file mode 100644 index 0000000..73ca2e3 --- /dev/null +++ b/server/adapters/http-image-generation.ts @@ -0,0 +1,226 @@ +import type { ProviderId } from '@shared/provider.js' +import type { KeyVault } from '../ports/key-vault.js' +import type { GeneratedImage, ImageGeneration, ImageGenerationRequest } from '../ports/image-generation.js' + +/** + * The real ImageGeneration adapter. Its logic is lifted from the pre-port + * server/services/image-generation.ts, which calls OpenAI's or Google's + * image endpoint directly over fetch and, on a recoverable failure, falls + * back through a provider-owned chain of known-good models before giving + * up entirely. + * + * Design goals, in priority order: always produce some image if any + * configured model can produce one, send the minimum request body that is + * guaranteed to work, recover silently from parameter-shape drift (a new + * model rejected a param), fall back to a known-good model when the + * user's preferred one breaks, and never crash on a recoverable error, + * only surface auth or content-policy failures. + * + * The one real change from that module: it never resolves an API key + * itself. A KeyVault is injected instead, so this adapter never imports + * server/services/key-store.ts, and that module is free to change (or + * become its own shim) independently of this file. fetch is injected too, + * defaulting to the global, so a test can assert every path (success, + * fallback, auth failure, content-policy failure, exhausted chain) + * without ever making a real HTTP request. + */ + +// Known-good models per provider, in preferred order. Used as fallbacks when +// the user's preferred model fails for a recoverable reason. Keep small — +// these are last-resort defaults, not a curation. +const FALLBACK_CHAINS: Partial> = { + openai: ['gpt-image-1', 'dall-e-3'], + google: ['imagen-4.0-generate-001'], +} + +// Per-family optional parameters. Only added to the request when the model +// matches. Unknown families get just {model, prompt} — produces a default-size +// image but never errors on parameter rejection. +interface OpenAIFamily { + match: (model: string) => boolean + portraitSize: `${number}x${number}` +} + +const OPENAI_FAMILIES: OpenAIFamily[] = [ + { match: m => m.startsWith('gpt-image-'), portraitSize: '1024x1536' }, + { match: m => m === 'dall-e-3', portraitSize: '1024x1792' }, +] + +function openaiOptionalParams(model: string): Record { + const family = OPENAI_FAMILIES.find(f => f.match(model)) + return family ? { size: family.portraitSize } : {} +} + +// Error categories drive the recovery decision. Auth errors never fall back +// (a bad key won't get better with another model); model/parameter errors +// trigger the fallback chain. +type ErrorCategory = + | { kind: 'auth' } // bad/missing key — bail to user + | { kind: 'rate-limit'; retryAfterMs?: number } // wait + retry same model + | { kind: 'invalid-param'; param: string } // strip param + retry same model + | { kind: 'model-unavailable' } // try next model in chain + | { kind: 'content-policy' } // bail — won't help to retry + | { kind: 'server'; status: number } // retry same model once + | { kind: 'network' } // retry same model once + | { kind: 'unknown'; message: string } // try next model in chain + +function categorizeOpenAIError(status: number, body: string): ErrorCategory { + if (status === 401 || status === 403) return { kind: 'auth' } + if (status === 429) return { kind: 'rate-limit' } + let parsed: { error?: { message?: string; code?: string; type?: string } } = {} + try { parsed = JSON.parse(body) } catch { /* leave empty */ } + const message = parsed.error?.message ?? '' + const code = parsed.error?.code ?? '' + if (status === 404 || /model_not_found|does not exist/i.test(message)) { + return { kind: 'model-unavailable' } + } + if (status === 400) { + const m = message.match(/Unknown parameter:\s*'([^']+)'/) + if (m) return { kind: 'invalid-param', param: m[1] } + if (/content_policy|safety|policy/i.test(message + code)) return { kind: 'content-policy' } + } + if (status >= 500) return { kind: 'server', status } + return { kind: 'unknown', message: message || body.slice(0, 200) } +} + +function categorizeGoogleError(status: number, body: string): ErrorCategory { + if (status === 401 || status === 403) return { kind: 'auth' } + if (status === 429) return { kind: 'rate-limit' } + if (status === 404) return { kind: 'model-unavailable' } + if (status >= 500) return { kind: 'server', status } + return { kind: 'unknown', message: body.slice(0, 200) } +} + +// Single attempt against one model. Throws a categorized error for the chain +// dispatcher to interpret. +class CategorizedError extends Error { + constructor(public category: ErrorCategory, message: string) { + super(message) + } +} + +export interface HttpImageGenerationDeps { + keyVault: KeyVault + /** Defaults to the global fetch. Inject a fake so a test never makes a real HTTP request. */ + fetch?: typeof fetch +} + +export function createHttpImageGeneration(deps: HttpImageGenerationDeps): ImageGeneration { + const { keyVault } = deps + const doFetch = deps.fetch ?? fetch + + async function attemptOpenAI( + apiKey: string, + model: string, + prompt: string, + signal: AbortSignal, + stripParams: Set = new Set(), + ): Promise { + const body: Record = { model, prompt, ...openaiOptionalParams(model) } + for (const p of stripParams) delete body[p] + + const res = await doFetch('https://api.openai.com/v1/images/generations', { + method: 'POST', + headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal, + }).catch(err => { + if (signal.aborted) throw err + throw new CategorizedError({ kind: 'network' }, err instanceof Error ? err.message : 'network error') + }) + + if (!res.ok) { + const errBody = await res.text().catch(() => '') + const category = categorizeOpenAIError(res.status, errBody) + // Handle strip-and-retry inline so it doesn't consume a fallback step + if (category.kind === 'invalid-param' && !stripParams.has(category.param) && category.param in body) { + console.warn(`[image-gen] OpenAI ${model} rejected param '${category.param}', retrying without it`) + const next = new Set(stripParams); next.add(category.param) + return attemptOpenAI(apiKey, model, prompt, signal, next) + } + throw new CategorizedError(category, `OpenAI ${res.status}: ${errBody.slice(0, 200)}`) + } + + const json = (await res.json()) as { data?: { b64_json?: string; url?: string }[] } + const item = json.data?.[0] + if (item?.b64_json) { + return { data: Buffer.from(item.b64_json, 'base64'), mediaType: 'image/png', _diag: { modelUsed: model, fellBack: false } } + } + if (item?.url) { + const r = await doFetch(item.url, { signal }) + if (!r.ok) throw new CategorizedError({ kind: 'unknown', message: `image download failed: ${r.status}` }, `image download ${r.status}`) + return { data: Buffer.from(await r.arrayBuffer()), mediaType: r.headers.get('content-type') ?? 'image/png', _diag: { modelUsed: model, fellBack: false } } + } + throw new CategorizedError({ kind: 'unknown', message: 'no image in response' }, 'OpenAI returned no image data') + } + + async function attemptGoogle(apiKey: string, model: string, prompt: string, signal: AbortSignal): Promise { + const parameters: Record = { sampleCount: 1 } + if (/imagen/i.test(model)) parameters.aspectRatio = '9:16' + const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:predict?key=${encodeURIComponent(apiKey)}` + const res = await doFetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ instances: [{ prompt }], parameters }), + signal, + }).catch(err => { + if (signal.aborted) throw err + throw new CategorizedError({ kind: 'network' }, err instanceof Error ? err.message : 'network error') + }) + if (!res.ok) { + const errBody = await res.text().catch(() => '') + throw new CategorizedError(categorizeGoogleError(res.status, errBody), `Google ${res.status}: ${errBody.slice(0, 200)}`) + } + const json = (await res.json()) as { predictions?: { bytesBase64Encoded?: string; mimeType?: string }[] } + const pred = json.predictions?.[0] + if (!pred?.bytesBase64Encoded) { + throw new CategorizedError({ kind: 'unknown', message: 'no image in response' }, 'Google returned no image data') + } + return { data: Buffer.from(pred.bytesBase64Encoded, 'base64'), mediaType: pred.mimeType ?? 'image/png', _diag: { modelUsed: model, fellBack: false } } + } + + async function attemptOnce(provider: ProviderId, apiKey: string, model: string, prompt: string, signal: AbortSignal): Promise { + if (provider === 'openai') return attemptOpenAI(apiKey, model, prompt, signal) + if (provider === 'google') return attemptGoogle(apiKey, model, prompt, signal) + throw new CategorizedError({ kind: 'unknown', message: `unsupported provider: ${provider}` }, `unsupported provider: ${provider}`) + } + + return { + // Build the model list to try: user's preferred first, then known-good + // fallbacks (dedup'd, skipping the preferred). On categorized recoverable + // errors, advance to the next model. On hard errors (auth, content + // policy), bail immediately — those won't get better with a different + // model. + async generate(req: ImageGenerationRequest): Promise { + const apiKey = keyVault.get(req.provider) + if (!apiKey) throw new Error(`No API key configured for ${req.provider}`) + + const chain = [req.preferredModel, ...(FALLBACK_CHAINS[req.provider] ?? [])] + .filter((m, i, arr) => arr.indexOf(m) === i) // dedup + + let lastError: CategorizedError | undefined + for (const model of chain) { + if (req.signal.aborted) throw new Error('cancelled') + try { + const result = await attemptOnce(req.provider, apiKey, model, req.prompt, req.signal) + result._diag.fellBack = model !== req.preferredModel + if (result._diag.fellBack) { + // Server-side log only — never surfaces to the user. They get a cover; that's all they need to know. + console.warn(`[image-gen] Preferred ${req.preferredModel} failed; produced image with ${model} instead`) + } + return result + } catch (err) { + if (!(err instanceof CategorizedError)) throw err + lastError = err + const cat = err.category + // Hard stops: these won't be fixed by trying a different model + if (cat.kind === 'auth') throw new Error(`Authentication failed for ${req.provider}. Check your API key in Settings.`, { cause: err }) + if (cat.kind === 'content-policy') throw new Error('Image rejected by content policy. Try a different prompt.', { cause: err }) + // Soft stops: try the next model in chain + console.warn(`[image-gen] ${req.provider}/${model} failed (${cat.kind}): ${err.message}`) + } + } + throw new Error(`All image models failed. Last error: ${lastError?.message ?? 'unknown'}`) + }, + } +} From 4024c99639f74bbf817126344669c025a0da393e Mon Sep 17 00:00:00 2001 From: Ross Miller Date: Mon, 20 Jul 2026 20:52:27 -0500 Subject: [PATCH 025/126] refactor(services): make image-generation delegate to the HttpImageGeneration adapter server/services/image-generation.ts kept its own copy of the OpenAI and Google request logic and called getKey from server/services/key-store.ts directly. It now adapts key-store's existing exported functions, get, set, remove, has, and status, into a KeyVault shaped object, and constructs one module scope instance of createHttpImageGeneration from it. generateImageWithFallback keeps its current exported signature and behaviour. Callers such as server/routes/covers.ts see no change. An invalid provider string still throws the same Invalid provider message it always has, now reached through the adapter's own key lookup rather than a direct call in this file, since keyVault.get is the real getKey and getKey has always validated its own input. This change is verified by the adapter's own tests added in the previous commit and by the full existing test suite, which passes unchanged. Claude-Session: https://claude.ai/code/session_01FSkESkJTRpt3Ye2cM7c5n5 --- server/services/image-generation.ts | 227 +++++----------------------- 1 file changed, 34 insertions(+), 193 deletions(-) diff --git a/server/services/image-generation.ts b/server/services/image-generation.ts index e0fa97d..975fb4f 100644 --- a/server/services/image-generation.ts +++ b/server/services/image-generation.ts @@ -1,13 +1,30 @@ -import { getKey } from './key-store.js' - -// Defensive image generation layer. -// -// Design goals (in priority order): -// 1. Always produce SOME image if any configured model can produce one. -// 2. Send the minimum request body that's guaranteed to work. -// 3. Recover silently from parameter-shape drift (new model rejected a param). -// 4. Fall back to a known-good model when the user's preferred one breaks. -// 5. Never crash on a recoverable error — only surface auth/network failures. +import type { ProviderId } from '@shared/provider.js' +import { getKey, setKey, removeKey, hasKey, keyStatus } from './key-store.js' +import type { KeyVault } from '../ports/key-vault.js' +import { createHttpImageGeneration } from '../adapters/http-image-generation.js' + +/** + * Keeps generateImageWithFallback's current exported signature and + * behaviour. The real logic now lives behind the ImageGeneration port, in + * server/adapters/http-image-generation.ts, which takes a KeyVault rather + * than resolving a key itself. This module adapts server/services/key-store.ts's + * existing exported functions to that KeyVault shape and constructs one + * module-scope adapter instance from it, so the adapter itself never + * imports the key store directly. Callers of generateImageWithFallback see + * no change. + */ +const keyVault: KeyVault = { + get: getKey, + set: setKey, + remove: removeKey, + has: hasKey, + // keyStatus() builds its object from PROVIDERS, so it always has exactly + // the ProviderId keys this cast asserts, just declared as the wider + // Record on the key-store side. + status: () => keyStatus() as Record, +} + +const imageGeneration = createHttpImageGeneration({ keyVault }) export interface GeneratedImage { data: Buffer @@ -28,188 +45,12 @@ export interface ImageGenerationRequest { signal: AbortSignal } -// Known-good models per provider, in preferred order. Used as fallbacks when -// the user's preferred model fails for a recoverable reason. Keep small — -// these are last-resort defaults, not a curation. -const FALLBACK_CHAINS: Record = { - openai: ['gpt-image-1', 'dall-e-3'], - google: ['imagen-4.0-generate-001'], -} - -// Per-family optional parameters. Only added to the request when the model -// matches. Unknown families get just {model, prompt} — produces a default-size -// image but never errors on parameter rejection. -interface OpenAIFamily { - match: (model: string) => boolean - portraitSize: `${number}x${number}` -} - -const OPENAI_FAMILIES: OpenAIFamily[] = [ - { match: m => m.startsWith('gpt-image-'), portraitSize: '1024x1536' }, - { match: m => m === 'dall-e-3', portraitSize: '1024x1792' }, -] - -function openaiOptionalParams(model: string): Record { - const family = OPENAI_FAMILIES.find(f => f.match(model)) - return family ? { size: family.portraitSize } : {} -} - -// Error categories drive the recovery decision. Auth errors never fall back -// (a bad key won't get better with another model); model/parameter errors -// trigger the fallback chain. -type ErrorCategory = - | { kind: 'auth' } // bad/missing key — bail to user - | { kind: 'rate-limit'; retryAfterMs?: number } // wait + retry same model - | { kind: 'invalid-param'; param: string } // strip param + retry same model - | { kind: 'model-unavailable' } // try next model in chain - | { kind: 'content-policy' } // bail — won't help to retry - | { kind: 'server'; status: number } // retry same model once - | { kind: 'network' } // retry same model once - | { kind: 'unknown'; message: string } // try next model in chain - -function categorizeOpenAIError(status: number, body: string): ErrorCategory { - if (status === 401 || status === 403) return { kind: 'auth' } - if (status === 429) return { kind: 'rate-limit' } - let parsed: { error?: { message?: string; code?: string; type?: string } } = {} - try { parsed = JSON.parse(body) } catch { /* leave empty */ } - const message = parsed.error?.message ?? '' - const code = parsed.error?.code ?? '' - if (status === 404 || /model_not_found|does not exist/i.test(message)) { - return { kind: 'model-unavailable' } - } - if (status === 400) { - const m = message.match(/Unknown parameter:\s*'([^']+)'/) - if (m) return { kind: 'invalid-param', param: m[1] } - if (/content_policy|safety|policy/i.test(message + code)) return { kind: 'content-policy' } - } - if (status >= 500) return { kind: 'server', status } - return { kind: 'unknown', message: message || body.slice(0, 200) } -} - -function categorizeGoogleError(status: number, body: string): ErrorCategory { - if (status === 401 || status === 403) return { kind: 'auth' } - if (status === 429) return { kind: 'rate-limit' } - if (status === 404) return { kind: 'model-unavailable' } - if (status >= 500) return { kind: 'server', status } - return { kind: 'unknown', message: body.slice(0, 200) } -} - -// Single attempt against one model. Throws a categorized error for the chain -// dispatcher to interpret. -class CategorizedError extends Error { - constructor(public category: ErrorCategory, message: string) { - super(message) - } -} - -async function attemptOpenAI( - apiKey: string, - model: string, - prompt: string, - signal: AbortSignal, - stripParams: Set = new Set(), -): Promise { - const body: Record = { model, prompt, ...openaiOptionalParams(model) } - for (const p of stripParams) delete body[p] - - const res = await fetch('https://api.openai.com/v1/images/generations', { - method: 'POST', - headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - signal, - }).catch(err => { - if (signal.aborted) throw err - throw new CategorizedError({ kind: 'network' }, err instanceof Error ? err.message : 'network error') - }) - - if (!res.ok) { - const errBody = await res.text().catch(() => '') - const category = categorizeOpenAIError(res.status, errBody) - // Handle strip-and-retry inline so it doesn't consume a fallback step - if (category.kind === 'invalid-param' && !stripParams.has(category.param) && category.param in body) { - console.warn(`[image-gen] OpenAI ${model} rejected param '${category.param}', retrying without it`) - const next = new Set(stripParams); next.add(category.param) - return attemptOpenAI(apiKey, model, prompt, signal, next) - } - throw new CategorizedError(category, `OpenAI ${res.status}: ${errBody.slice(0, 200)}`) - } - - const json = (await res.json()) as { data?: { b64_json?: string; url?: string }[] } - const item = json.data?.[0] - if (item?.b64_json) { - return { data: Buffer.from(item.b64_json, 'base64'), mediaType: 'image/png', _diag: { modelUsed: model, fellBack: false } } - } - if (item?.url) { - const r = await fetch(item.url, { signal }) - if (!r.ok) throw new CategorizedError({ kind: 'unknown', message: `image download failed: ${r.status}` }, `image download ${r.status}`) - return { data: Buffer.from(await r.arrayBuffer()), mediaType: r.headers.get('content-type') ?? 'image/png', _diag: { modelUsed: model, fellBack: false } } - } - throw new CategorizedError({ kind: 'unknown', message: 'no image in response' }, 'OpenAI returned no image data') -} - -async function attemptGoogle(apiKey: string, model: string, prompt: string, signal: AbortSignal): Promise { - const parameters: Record = { sampleCount: 1 } - if (/imagen/i.test(model)) parameters.aspectRatio = '9:16' - const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:predict?key=${encodeURIComponent(apiKey)}` - const res = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ instances: [{ prompt }], parameters }), - signal, - }).catch(err => { - if (signal.aborted) throw err - throw new CategorizedError({ kind: 'network' }, err instanceof Error ? err.message : 'network error') - }) - if (!res.ok) { - const errBody = await res.text().catch(() => '') - throw new CategorizedError(categorizeGoogleError(res.status, errBody), `Google ${res.status}: ${errBody.slice(0, 200)}`) - } - const json = (await res.json()) as { predictions?: { bytesBase64Encoded?: string; mimeType?: string }[] } - const pred = json.predictions?.[0] - if (!pred?.bytesBase64Encoded) { - throw new CategorizedError({ kind: 'unknown', message: 'no image in response' }, 'Google returned no image data') - } - return { data: Buffer.from(pred.bytesBase64Encoded, 'base64'), mediaType: pred.mimeType ?? 'image/png', _diag: { modelUsed: model, fellBack: false } } -} - -async function attemptOnce(provider: string, apiKey: string, model: string, prompt: string, signal: AbortSignal): Promise { - if (provider === 'openai') return attemptOpenAI(apiKey, model, prompt, signal) - if (provider === 'google') return attemptGoogle(apiKey, model, prompt, signal) - throw new CategorizedError({ kind: 'unknown', message: `unsupported provider: ${provider}` }, `unsupported provider: ${provider}`) -} - -// Build the model list to try: user's preferred first, then known-good -// fallbacks (dedup'd, skipping the preferred). On categorized recoverable -// errors, advance to the next model. On hard errors (auth, content policy), -// bail immediately — those won't get better with a different model. export async function generateImageWithFallback(req: ImageGenerationRequest): Promise { - const apiKey = getKey(req.provider) - if (!apiKey) throw new Error(`No API key configured for ${req.provider}`) - - const chain = [req.preferredModel, ...(FALLBACK_CHAINS[req.provider] ?? [])] - .filter((m, i, arr) => arr.indexOf(m) === i) // dedup - - let lastError: CategorizedError | undefined - for (const model of chain) { - if (req.signal.aborted) throw new Error('cancelled') - try { - const result = await attemptOnce(req.provider, apiKey, model, req.prompt, req.signal) - result._diag.fellBack = model !== req.preferredModel - if (result._diag.fellBack) { - // Server-side log only — never surfaces to the user. They get a cover; that's all they need to know. - console.warn(`[image-gen] Preferred ${req.preferredModel} failed; produced image with ${model} instead`) - } - return result - } catch (err) { - if (!(err instanceof CategorizedError)) throw err - lastError = err - const cat = err.category - // Hard stops: these won't be fixed by trying a different model - if (cat.kind === 'auth') throw new Error(`Authentication failed for ${req.provider}. Check your API key in Settings.`, { cause: err }) - if (cat.kind === 'content-policy') throw new Error('Image rejected by content policy. Try a different prompt.', { cause: err }) - // Soft stops: try the next model in chain - console.warn(`[image-gen] ${req.provider}/${model} failed (${cat.kind}): ${err.message}`) - } - } - throw new Error(`All image models failed. Last error: ${lastError?.message ?? 'unknown'}`) + // keyVault.get (== the real getKey) throws "Invalid provider: ..." for + // anything that isn't a known ProviderId, the same validation and the + // same message generateImageWithFallback has always surfaced, just + // reached through the adapter's own key lookup rather than a direct call + // here. This cast only widens the compile-time type; it lets no bad + // value pass silently at runtime. + return imageGeneration.generate({ ...req, provider: req.provider as ProviderId }) } From 0c61d13105acba3ef2bf75c03e2a2cc28ee64a46 Mon Sep 17 00:00:00 2001 From: Ross Miller Date: Mon, 20 Jul 2026 20:52:05 -0500 Subject: [PATCH 026/126] refactor(ports): add bookTitle to ConcatToM4bRequest The current M4B stitch tags the file with the book's title as both the container's title and album metadata and the FFMETADATA1 file's own title line. AudiobookChapterEntry only carries per-chapter titles, so the request had no way to carry the book's own title through to the adapter that embeds it. This field is optional and follows the same shape as the already committed coverPath field, so an adapter given no bookTitle still produces a valid M4B, it just skips those two tags. Found and added while building the real ffmpeg adapter in this stage, flagging it here since the port was otherwise described as already committed. Claude-Session: https://claude.ai/code/session_01FSkESkJTRpt3Ye2cM7c5n5 --- server/ports/audio-assembly.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/server/ports/audio-assembly.ts b/server/ports/audio-assembly.ts index 39dc285..38c6b4d 100644 --- a/server/ports/audio-assembly.ts +++ b/server/ports/audio-assembly.ts @@ -33,6 +33,17 @@ import type { AudiobookChapterEntry } from '@shared/domain.js' * internal resilience, so an adapter that cannot embed the cover must * still produce a coverless M4B rather than reject, and callers never see * the difference. + * + * bookTitle is a second, narrower widening made while building the real + * ffmpeg adapter (S5). The current M4B stitch tags the file with the + * book's title as both the container's title/album metadata and the + * FFMETADATA1 file's own title line, alongside the chapter markers built + * from AudiobookChapterEntry. AudiobookChapterEntry carries only + * per-chapter titles, so without this field the adapter would have no way + * to reproduce that tagging and the M4B would silently lose its + * title/album metadata. Optional, the same shape as coverPath above, so + * an adapter given no bookTitle still produces a valid M4B, just without + * those two tags. */ export interface ConcatToM4bRequest { @@ -46,6 +57,8 @@ export interface ConcatToM4bRequest { bitrate: string /** Cover art to embed, when the book has one. An adapter that cannot embed it still produces a coverless M4B rather than rejecting. */ coverPath?: string + /** The book's title, embedded as the M4B's title/album metadata and the FFMETADATA1 title line. Omit to skip those tags. */ + bookTitle?: string signal: AbortSignal } From 9a5509660104bf5b8fe970f2667f216e425c671a Mon Sep 17 00:00:00 2001 From: Ross Miller Date: Mon, 20 Jul 2026 20:52:18 -0500 Subject: [PATCH 027/126] feat(adapters): add the ffmpeg-backed AudioAssembly adapter Lifts runFfmpeg, getAudioDurationSec, and the M4B stitch step verbatim out of audiobook-generator.ts into a factory, createFfmpegAudioAssembly, that implements the AudioAssembly port. The process runner behind ffmpeg is now an injected dependency instead of a direct execFile call, so a caller can swap it for a fake without spawning anything, and the factory itself has no import time side effects. Cover embedding keeps its existing resilience. concatToM4b always attempts to embed a given coverPath on its first ffmpeg invocation and retries once without it if that attempt fails, producing a coverless M4B rather than rejecting. An abort never triggers that retry, it propagates as a rejection instead. Not yet wired up, audiobook-generator.ts still has its own ffmpeg internals. That follows in the next commit. Claude-Session: https://claude.ai/code/session_01FSkESkJTRpt3Ye2cM7c5n5 --- server/adapters/ffmpeg-audio-assembly.ts | 261 +++++++++++++++++++++++ 1 file changed, 261 insertions(+) create mode 100644 server/adapters/ffmpeg-audio-assembly.ts diff --git a/server/adapters/ffmpeg-audio-assembly.ts b/server/adapters/ffmpeg-audio-assembly.ts new file mode 100644 index 0000000..262d87a --- /dev/null +++ b/server/adapters/ffmpeg-audio-assembly.ts @@ -0,0 +1,261 @@ +import { execFile as nodeExecFile } from 'node:child_process' +import { writeFile, mkdir, rename, rm } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { tmpdir } from 'node:os' +import { randomUUID } from 'node:crypto' +import type { AudiobookChapterEntry } from '@shared/domain.js' +import type { AudioAssembly, ConcatToM4bRequest } from '../ports/audio-assembly.js' +import { getFfmpegPath } from '../services/audiobook-installer.js' + +/** + * ffmpeg backed AudioAssembly. Real logic lifted verbatim from the ffmpeg + * internals of server/services/audiobook-generator.ts: runFfmpeg, + * getAudioDurationSec (now probeDurationSec), and the M4B stitch step + * (buildFfmetadata, buildConcatList, writeAtomic, buildStitchArgs, and the + * cover-embed-then-retry logic) inside generateAudiobook. See that file's + * git history for the pre-extraction shape. + * + * Cover embedding is best-effort resilience, not a caller-visible choice. + * concatToM4b always attempts to embed req.coverPath (when given) on its + * first ffmpeg invocation. If that invocation fails for any reason, and + * the signal isn't the reason (an abort should propagate, not retry), it + * retries once with the exact same args minus the cover-related flags, + * producing a coverless M4B instead of rejecting. Callers never see the + * difference between "no cover was requested" and "a cover was requested + * but couldn't be embedded" — both produce a finished M4B. + */ +/** The subset of a failed execFile's error that runFfmpeg actually reads. Node's own ExecFileException types `code` as `string | number | null | undefined`, which this mirrors instead of the narrower NodeJS.ErrnoException. */ +export type ExecFileErrorLike = Error & { code?: string | number | null } + +export interface ExecFileRunner { + ( + file: string, + args: readonly string[], + options: { signal: AbortSignal; maxBuffer?: number }, + callback: (error: ExecFileErrorLike | null, stdout: string, stderr: string) => void, + ): void +} + +export interface FfmpegAudioAssemblyDeps { + /** + * Runs the ffmpeg binary and resolves its stdout/stderr, or rejects on a + * non-zero exit or an aborted signal. Defaults to a thin wrapper over + * node:child_process's execFile. Overridable so tests can capture the + * argument vectors ffmpeg would have received without spawning anything. + */ + execFile?: ExecFileRunner +} + +const ABORTED_MESSAGE = 'Audiobook generation aborted' + +// Node's execFile is happy to accept a wider callback (stdout/stderr can be +// Buffer when an encoding option isn't set), so this wrapper normalizes both +// to string, matching what runFfmpeg has always expected. +function defaultExecFile( + file: string, + args: readonly string[], + options: { signal: AbortSignal; maxBuffer?: number }, + callback: (error: ExecFileErrorLike | null, stdout: string, stderr: string) => void, +): void { + nodeExecFile(file, args as string[], options, (err, stdout, stderr) => { + callback(err, String(stdout), String(stderr)) + }) +} + +// FFMETADATA1 requires escaping =, ;, #, \, and newlines with a leading backslash. +function escapeMeta(value: string): string { + return value.replace(/[\\=;#\n]/g, (c) => (c === '\n' ? '\\\n' : `\\${c}`)) +} + +// Build the FFMETADATA1 file ffmpeg uses to write chapter markers into the M4B. +// TIMEBASE=1/1000 means START/END are in milliseconds. +function buildFfmetadata(bookTitle: string | undefined, chapters: AudiobookChapterEntry[]): string { + const lines: string[] = [';FFMETADATA1'] + if (bookTitle !== undefined) { + lines.push(`title=${escapeMeta(bookTitle)}`) + } + lines.push('artist=Tutor') + lines.push('genre=Audiobook') + lines.push('') + for (const ch of chapters) { + const startMs = Math.round(ch.startSec * 1000) + const endMs = Math.round((ch.startSec + ch.durationSec) * 1000) + lines.push('[CHAPTER]') + lines.push('TIMEBASE=1/1000') + lines.push(`START=${startMs}`) + lines.push(`END=${endMs}`) + lines.push(`title=${escapeMeta(ch.title)}`) + lines.push('') + } + return lines.join('\n') +} + +// Build the file list for ffmpeg's concat demuxer. Single quotes around the +// path; embedded single quotes get escaped per the demuxer spec ('\'' style). +function buildConcatList(absPaths: string[]): string { + return absPaths.map((p) => `file '${p.replace(/'/g, "'\\''")}'`).join('\n') +} + +async function writeAtomic(path: string, data: string | Buffer): Promise { + await mkdir(dirname(path), { recursive: true }) + const tmp = path + '.tmp' + if (typeof data === 'string') { + await writeFile(tmp, data, 'utf-8') + } else { + await writeFile(tmp, data) + } + await rename(tmp, path) +} + +async function tryRm(path: string): Promise { + try { + await rm(path, { force: true }) + } catch { + // ignore — best-effort tmp cleanup + } +} + +export function createFfmpegAudioAssembly(deps: FfmpegAudioAssemblyDeps = {}): AudioAssembly { + const runExecFile = deps.execFile ?? defaultExecFile + + // Run ffmpeg with the configured binary, surfacing stderr on non-zero exit. + // The AbortSignal kills hung processes (e.g., long stitches cancelled by + // the user). + async function runFfmpeg(args: string[], signal: AbortSignal): Promise<{ stdout: string; stderr: string }> { + if (signal.aborted) throw new Error(ABORTED_MESSAGE) + const ffmpegPath = getFfmpegPath() + return new Promise((resolve, reject) => { + runExecFile( + ffmpegPath, + args, + // maxBuffer: ffmpeg's stderr can balloon over a multi-minute stitch. + { signal, maxBuffer: 32 * 1024 * 1024 }, + (err, stdout, stderr) => { + if (err) { + const code = err.code + if (code === 'ABORT_ERR' || signal.aborted) { + reject(new Error(ABORTED_MESSAGE)) + return + } + reject(new Error(`ffmpeg failed: ${err.message}\n${stderr}`)) + return + } + resolve({ stdout, stderr }) + }, + ) + }) + } + + return { + // Probe an audio file's duration using ffmpeg's "-i ... -f null -" stderr + // output. We don't ship ffprobe (the evermeet.cx ffmpeg-only zip), so we + // parse the human-readable "Duration: HH:MM:SS.ss" line ffmpeg always emits. + async probeDurationSec(path: string, signal: AbortSignal): Promise { + const { stderr } = await runFfmpeg(['-hide_banner', '-i', path, '-f', 'null', '-'], signal) + const match = stderr.match(/Duration:\s*(\d{2}):(\d{2}):(\d{2}(?:\.\d+)?)/) + if (!match) { + throw new Error(`Could not parse duration from ffmpeg output for ${path}`) + } + const h = parseInt(match[1], 10) + const m = parseInt(match[2], 10) + const s = parseFloat(match[3]) + return h * 3600 + m * 60 + s + }, + + async concatToM4b(req: ConcatToM4bRequest): Promise { + if (req.signal.aborted) throw new Error(ABORTED_MESSAGE) + + // Write tmp helper files outside the destination dir so a failed run + // doesn't pollute it. Tag them with a uuid for parallel-safety. + const runId = randomUUID() + const concatListPath = join(tmpdir(), `tutor-audiobook-concat-${runId}.txt`) + const ffmetadataPath = join(tmpdir(), `tutor-audiobook-meta-${runId}.txt`) + const m4bTmp = req.out + '.tmp' + + // Build args for stitching. Concat demuxer = input 0; metadata = input 1; + // cover (if present) = input 2. The cover path embeds it as attached_pic; + // we force yuvj420p because PNG covers commonly arrive as RGBA which the + // mjpeg encoder otherwise refuses. + function buildStitchArgs(withCover: boolean): string[] { + const args = [ + '-y', + '-hide_banner', + '-loglevel', 'error', + '-f', 'concat', + '-safe', '0', + '-i', concatListPath, + '-i', ffmetadataPath, + ] + if (withCover && req.coverPath) { + args.push('-i', req.coverPath) + args.push( + '-map', '0:a', + '-map', '2:v', + '-map_metadata', '1', + '-c:a', 'aac', + '-b:a', req.bitrate, + '-c:v', 'mjpeg', + '-pix_fmt', 'yuvj420p', + '-disposition:v:0', 'attached_pic', + ) + } else { + args.push( + '-map', '0:a', + '-map_metadata', '1', + '-c:a', 'aac', + '-b:a', req.bitrate, + ) + } + if (req.bookTitle !== undefined) { + args.push('-metadata', `title=${req.bookTitle}`, '-metadata', `album=${req.bookTitle}`) + } + args.push( + '-metadata', 'artist=Tutor', + '-metadata', 'album_artist=Tutor', + '-metadata', 'genre=Audiobook', + // media_type=2 = "Audiobook" iTunes stik value. Without it, Apple + // Books treats the file as music and won't show chapter navigation + // the way it does for native audiobooks. + '-metadata', 'media_type=2', + // +faststart moves the moov atom to the front of the file so + // streaming clients (the in-app