From c7e20e95deefe63f9e4772d44efe05a39827ce23 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sat, 8 Aug 2026 11:23:45 -0300 Subject: [PATCH] =?UTF-8?q?fix(memory):=20register=20the=20sqlite=20backen?= =?UTF-8?q?d=20on=20the=20/api/memory/[id]=20route=20=E2=80=94=20every=20h?= =?UTF-8?q?andler=20500'd=20(#9737)=20(#9785)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(api): validate request bodies with Zod in 4 routes — restores the t06 gate The release-green verdict (#9737) lists check:route-validation:t06 as a HARD failure and it is STILL red on the current tip: four routes call request.json() and hand-roll `typeof x === "string"` checks instead of using Zod, which Hard Rule #7 requires and the gate enforces (it scans source and has no allowlist). - src/app/api/plugins/marketplace/install (#9445): InstallBodySchema; the 400 'Missing or invalid name field' response is preserved verbatim. - src/app/api/services/dario/admin/accounts (#8523): DeleteAccountBodySchema for the optional { alias } DELETE body; query-param path untouched. - src/app/api/services/dario/admin/login-start (#8523): LoginStartBodySchema; trimming now happens in the schema, so the forward body is unchanged. - src/app/api/services/dario/admin/import-from-omniroute (#8523): ImportBodySchema for connectionId/alias; invalid shapes fall back to the same 'connectionId is required' 400 as before. All four keep their exact status codes and messages — this is a validation mechanism swap, not a contract change (plugins route suite still 33/33). Adds tests/unit/route-body-validation-t06.test.ts, which runs the gate's own rule inside the unit suite so the next such route fails on ITS OWN PR instead of surfacing weeks later in a base-red sweep. Guard verified by mutation: renaming .safeParse( in one route makes it fail (1 fail), restored from a pre-probe copy. Gates: route-validation:t06, file-size, test-discovery, mutation-test-coverage, dead-code exit 0; typecheck:core clean; eslint clean. Refs #9737 * fix(memory): register the sqlite backend on the /api/memory/[id] route — every handler 500'd GET/PUT/DELETE /api/memory/[id] threw `Primary backend "sqlite" not registered` and returned 500. #8752 (MemoryBackend provider pattern) wired the route to `@/lib/memory/manager` directly, but the registry is populated by an import-time side effect in the module INDEX (src/lib/memory/index.ts:23, `memoryManager.register(sqliteBackend)`). Importing the bare manager gives an empty registry. In production the failure is order-dependent, which is why it went unnoticed: if /api/memory (which imports the index) is hit first in the same process, the singleton is already populated and [id] works. Reached first — the common case for a client that edits a known memory id — every request 500s. The sibling route is the only other consumer and already imports the index; this was the lone direct-manager import in src/. - Fix: import from `@/lib/memory` (index) with a comment stating WHY the indirection matters, so the next refactor does not simplify it back. - Guard: tests/integration/memory-route-put.test.ts already covered this and was failing 2/5 on the base (it only surfaced now because the integration suite runs on the release-PR CI, not per-PR). Now 5/5. Also fixes a test-isolation defect in the same run: tests/integration/combo-matrix/context-relay-codex.test.ts reused one combo name across both tests, and the control failed with `UNIQUE constraint failed: combos.name` — resetStorage() unlinks the DB file but the previous better-sqlite3 handle keeps writing to the same inode. Gave the control its own combo name and parameterized the request builder; the assertion is unchanged (it never depended on the name). 2/2. Integration suite on this tip: 936 tests, 32m19s — under the 40min ceiling the old verdict reported as exceeded (#9737 item 6), which the migration-135 collision was causing. Refs #9737 --------- Co-authored-by: diegosouzapw --- .../fixes/9737-memory-id-route-backend.md | 1 + src/app/api/memory/[id]/route.ts | 6 ++++- .../combo-matrix/context-relay-codex.test.ts | 27 ++++++++++--------- 3 files changed, 20 insertions(+), 14 deletions(-) create mode 100644 changelog.d/fixes/9737-memory-id-route-backend.md diff --git a/changelog.d/fixes/9737-memory-id-route-backend.md b/changelog.d/fixes/9737-memory-id-route-backend.md new file mode 100644 index 0000000000..93c6e6f836 --- /dev/null +++ b/changelog.d/fixes/9737-memory-id-route-backend.md @@ -0,0 +1 @@ +- Fixed `GET`/`PUT`/`DELETE /api/memory/[id]` always failing with a 500 (`Primary backend "sqlite" not registered`) when the route was reached before any other memory endpoint in the same process. diff --git a/src/app/api/memory/[id]/route.ts b/src/app/api/memory/[id]/route.ts index f85d037ec7..9f1ede6590 100644 --- a/src/app/api/memory/[id]/route.ts +++ b/src/app/api/memory/[id]/route.ts @@ -1,6 +1,10 @@ import { NextResponse } from "next/server"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; -import { memoryManager } from "@/lib/memory/manager"; +// Import through the module index, NOT "@/lib/memory/manager" directly: the index's +// import-time side effect is what calls memoryManager.register(sqliteBackend). Importing +// the bare manager gives an EMPTY registry, so every handler here threw +// `Primary backend "sqlite" not registered` and returned 500 (#8752). +import { memoryManager } from "@/lib/memory"; import { validateBody, isValidationFailure } from "@/shared/validation/helpers"; import { MemoryUpdatePutSchema } from "@/shared/schemas/memory"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; diff --git a/tests/integration/combo-matrix/context-relay-codex.test.ts b/tests/integration/combo-matrix/context-relay-codex.test.ts index 11c89d3dfa..d3a793fe10 100644 --- a/tests/integration/combo-matrix/context-relay-codex.test.ts +++ b/tests/integration/combo-matrix/context-relay-codex.test.ts @@ -62,6 +62,12 @@ const { getHandoff } = await import("../../../src/lib/db/contextHandoffs.ts"); // ── Constants ───────────────────────────────────────────────────────────────── const CODEX_COMBO_NAME = "m-relay-codex-quota"; +// The control test needs its OWN combo name. resetStorage() unlinks the DB file +// between tests, but the previous better-sqlite3 handle survives the unlink and +// keeps writing to the same inode, so reusing the name here failed with +// `UNIQUE constraint failed: combos.name` — a test-isolation defect, not a +// routing one (the assertion below does not depend on the name). +const CONTROL_COMBO_NAME = "m-relay-openai-control"; const SESSION_HEADER_VALUE = "relay-codex-quota-001"; const SESSION_ID = `ext:${SESSION_HEADER_VALUE}`; @@ -71,8 +77,7 @@ const CODEX_RESPONSES_HOST = "chatgpt.com/backend-api/codex/responses"; // Summary JSON that parseHandoffJSON will successfully parse. const CODEX_SUMMARY_JSON = JSON.stringify({ - summary: - "User is implementing a TypeScript context-relay codex quota-handoff test using TDD.", + summary: "User is implementing a TypeScript context-relay codex quota-handoff test using TDD.", keyDecisions: ["codex provider selected", "quota threshold at 90%"], taskProgress: "writing deterministic integration test for codex handoff", activeEntities: ["combo.ts", "codexQuotaFetcher.ts", "contextHandoff.ts"], @@ -142,11 +147,11 @@ function buildCodexUsageBody( // ── Request builder ─────────────────────────────────────────────────────────── -function codexRequest(withSessionId = true) { +function codexRequest(withSessionId = true, comboName = CODEX_COMBO_NAME) { return buildRequest({ headers: withSessionId ? { "x-session-id": SESSION_HEADER_VALUE } : {}, body: { - model: CODEX_COMBO_NAME, + model: comboName, stream: false, messages: [{ role: "user", content: "Write a TypeScript hello world." }], }, @@ -259,9 +264,7 @@ test("context-relay codex quota handoff: fires and expiresAt matches session-win name: CODEX_COMBO_NAME, strategy: "context-relay", config: { maxRetries: 0, retryDelayMs: 0, stickyRoundRobinLimit: 1 }, - models: [ - { id: "rc-codex-1", kind: "model", providerId: "codex", model: "gpt-5.3-codex" }, - ], + models: [{ id: "rc-codex-1", kind: "model", providerId: "codex", model: "gpt-5.3-codex" }], }); // 4. Compute quota reset times (future timestamps). @@ -328,12 +331,10 @@ test("context-relay codex quota handoff: does NOT fire when provider is openai ( await seedConnection("openai", { apiKey: "sk-openai-control-no-codex-block" }); await combosDb.createCombo({ - name: CODEX_COMBO_NAME, + name: CONTROL_COMBO_NAME, strategy: "context-relay", config: { maxRetries: 0, retryDelayMs: 0, stickyRoundRobinLimit: 1 }, - models: [ - { id: "rc-openai-ctrl", kind: "model", providerId: "openai", model: "gpt-4o-mini" }, - ], + models: [{ id: "rc-openai-ctrl", kind: "model", providerId: "openai", model: "gpt-4o-mini" }], }); const seenUrls: string[] = []; @@ -342,7 +343,7 @@ test("context-relay codex quota handoff: does NOT fire when provider is openai ( return buildOpenAIResponse("assistant reply ok"); }; - const r = await handleChat(codexRequest(true)); + const r = await handleChat(codexRequest(true, CONTROL_COMBO_NAME)); assert.equal(r.status, 200, "openai request must return 200"); // Give setImmediate time to fire if the block were incorrectly entered. @@ -358,7 +359,7 @@ test("context-relay codex quota handoff: does NOT fire when provider is openai ( // No codex quota handoff record in DB. // (The universal handoff also does not fire because no prior model is seeded, // so getLastSessionModel returns null → no model switch detected.) - const handoff = getHandoff(SESSION_ID, CODEX_COMBO_NAME); + const handoff = getHandoff(SESSION_ID, CONTROL_COMBO_NAME); assert.equal( handoff, null,