fix(memory): register the sqlite backend on the /api/memory/[id] route — every handler 500'd (#9737) (#9785)

* 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 <diegosouzapw@users.noreply.github.com>
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-08-08 11:23:45 -03:00
committed by GitHub
parent 24bdae29ca
commit c7e20e95de
3 changed files with 20 additions and 14 deletions

View File

@@ -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.

View File

@@ -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";

View File

@@ -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,