diff --git a/src/services/local-vector-search.ts b/src/services/local-vector-search.ts index 99dbea1..d34c110 100644 --- a/src/services/local-vector-search.ts +++ b/src/services/local-vector-search.ts @@ -211,6 +211,11 @@ export class LocalVectorSearch { return this.scars.length; } + /** Ids of the indexed scars (GIT-119: prefix resolution without a query). */ + getScarIds(): string[] { + return this.scars.map((s) => s.scar.id); + } + /** * Search for scars similar to the query * diff --git a/src/services/metrics.ts b/src/services/metrics.ts index 16eac35..bafda1b 100644 --- a/src/services/metrics.ts +++ b/src/services/metrics.ts @@ -443,9 +443,11 @@ export async function updateRelevanceData( ); if (applied.length === 0 && Object.keys(relevance).length === 0) continue; - await supabase.directPatch("gitmem_query_metrics", { id: `eq.${metric.id}` }, { + const patched = await supabase.directPatch("gitmem_query_metrics", { id: `eq.${metric.id}` }, { metadata: { ...(metric.metadata ?? {}), memories_applied: applied, memory_relevance: relevance }, }); + // GIT-119: 0 rows = relevance not recorded; throw so the tracker counts it. + if (patched.count === 0) throw new Error(`relevance for metrics row ${metric.id} updated no row`); } }); } diff --git a/src/services/supabase-client.ts b/src/services/supabase-client.ts index ca1855a..cd206f3 100644 --- a/src/services/supabase-client.ts +++ b/src/services/supabase-client.ts @@ -473,11 +473,22 @@ export async function directUpsert( * @param filters PostgREST filter to identify the row(s) to update * @param data Fields to update (partial — omitted columns stay unchanged) */ +/** + * GIT-119: what a PATCH changed. PostgREST answers 200 with [] when the filter + * matched nothing (a wrong id, a row not created yet, RLS), so a PATCH that + * returns is not evidence of a write: `count` is. All callers treat 0 as not + * durable. + */ +export interface PatchResult { + count: number; + rows: T[]; +} + export async function directPatch( table: string, filters: Record, data: Record -): Promise { +): Promise> { if (!isConfigured()) { throw new Error("Supabase not configured"); } @@ -507,7 +518,8 @@ export async function directPatch( throw new Error(`Supabase patch error: ${response.status} - ${text.slice(0, 200)}`); } - return response.json() as Promise; + const rows = ((await response.json()) as T[]) || []; + return { count: Array.isArray(rows) ? rows.length : 0, rows: Array.isArray(rows) ? rows : [] }; } /** @@ -888,10 +900,12 @@ export async function saveTranscript( // Update the session record with transcript_path (direct REST API) let patch_warning: string | undefined; try { - await directPatch(getTableName("sessions"), + const patched = await directPatch(getTableName("sessions"), { id: sessionId }, { transcript_path: path } ); + // GIT-119: 0 rows = no session row to update, not a success. + if (patched.count === 0) throw new Error(`no session row ${sessionId} to update`); } catch (error) { // File is saved; session record update failed — warn, don't fail const msg = error instanceof Error ? error.message : String(error); diff --git a/src/services/thread-supabase.ts b/src/services/thread-supabase.ts index 1494521..4b22c88 100644 --- a/src/services/thread-supabase.ts +++ b/src/services/thread-supabase.ts @@ -261,7 +261,12 @@ export async function resolveThreadInSupabase( patchData.resolved_by_session = options.resolvedBySession; } - await supabase.directPatch(getTableName("threads"), { id: uuid }, patchData); + const patched = await supabase.directPatch(getTableName("threads"), { id: uuid }, patchData); + // GIT-119: 0 rows = not resolved. + if (patched.count === 0) { + console.error(`[thread-supabase] Resolve of ${threadId} updated no row`); + return false; + } console.error(`[thread-supabase] Resolved thread ${threadId} in Supabase`); return true; } catch (error) { @@ -551,13 +556,15 @@ export async function touchThreadsInSupabase( delete metadata.dormant_since; } - await supabase.directPatch(getTableName("threads"), { id: row.id }, { + const patched = await supabase.directPatch(getTableName("threads"), { id: row.id }, { touch_count: newTouchCount, last_touched_at: nowIso, vitality_score: vitality.vitality_score, status: lifecycle_status, metadata, }); + // GIT-119: 0 rows = not touched. + if (patched.count === 0) throw new Error(`touch of ${threadId} updated no row`); } catch (error) { console.error(`[thread-supabase] Failed to touch thread ${threadId}:`, error instanceof Error ? error.message : error); // Continue with other threads @@ -752,10 +759,12 @@ export async function archiveDormantThreads( const daysDormant = (now.getTime() - dormantStart.getTime()) / (1000 * 60 * 60 * 24); if (daysDormant >= dormantDays) { - await supabase.directPatch(getTableName("threads"), { id: row.id }, { + const patched = await supabase.directPatch(getTableName("threads"), { id: row.id }, { status: "archived", }); - archived_ids.push(row.thread_id); + // GIT-119: count only rows the store actually archived. + if (patched.count > 0) archived_ids.push(row.thread_id); + else console.error(`[thread-supabase] Archive of ${row.thread_id} updated no row`); } } diff --git a/src/tools/archive-learning.ts b/src/tools/archive-learning.ts index eeb020e..cb4dd3a 100644 --- a/src/tools/archive-learning.ts +++ b/src/tools/archive-learning.ts @@ -14,6 +14,7 @@ import { supportedColumns } from "../services/store-columns.js"; import { hasSupabase, getTableName } from "../services/tier.js"; import { getStorage } from "../services/storage.js"; import { flushCache } from "../services/startup.js"; +import { getLocalVectorSearch } from "../services/local-vector-search.js"; import { Timer } from "../services/metrics.js"; import { wrapDisplay } from "../services/display-protocol.js"; import { writeResult, notStored } from "../services/write-result.js"; @@ -37,6 +38,25 @@ export interface ArchiveLearningResult extends WriteResult { } const FULL_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** + * GIT-119: the UUIDs a hex prefix covers, as an inclusive range. uuid values + * order bytewise, i.e. by their hex digits, so prefix+"000…" .. prefix+"fff…" + * is exactly the set that starts with the prefix. + */ +export function uuidRangeForPrefix(prefix: string): [string, string] { + const fmt = (hex: string) => `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; + return [fmt(prefix.padEnd(32, "0")), fmt(prefix.padEnd(32, "f"))]; +} + +/** Ids in the warm local vector index (empty when it is not loaded). */ +function localIndexIds(): string[] { + try { + return getLocalVectorSearch().getScarIds(); + } catch { + return []; + } +} const HEX_PREFIX_RE = /^[0-9a-f]{4,32}$/i; /** @@ -58,10 +78,19 @@ async function resolveIdPrefix(input: string): Promise<{ id: string } | { error: const prefix = input.toLowerCase(); if (hasSupabase() && isConfigured()) { - // Supabase: use PostgREST like filter + // GIT-119: this sent id=like.% — Postgres has no LIKE on a UUID + // column (42883 "operator does not exist: uuid ~~ unknown"), so prefix + // archiving failed on every store. Resolve from the local index when it + // has the answer, else ask the store for the UUID range the prefix spans. + const local = localIndexIds().filter((id) => id.replace(/-/g, "").startsWith(prefix)); + if (local.length === 1) return { id: local[0] }; + if (local.length > 1) { + return { error: `Ambiguous prefix "${prefix}" — matches multiple learnings: ${local.slice(0, 2).map((m) => m.slice(0, 12) + "…").join(", ")}` }; + } + const [low, high] = uuidRangeForPrefix(prefix); const matches = await directQuery<{ id: string }>(getTableName("learnings"), { select: "id", - filters: { id: `like.${prefix}%` }, + filters: { and: `(id.gte.${low},id.lte.${high})` }, limit: 2, }); @@ -130,10 +159,24 @@ export async function archiveLearning(params: ArchiveLearningParams): Promise { + const patched = await supabase.directPatch(getTableName("sessions"), { id: sessionId }, { embedding: embeddingJson }); + if (patched.count === 0) throw new Error(`session ${sessionId.slice(0, 8)}: embedding PATCH updated no row`); +} + /** * GIT-21: Clean up all session files for a closed session. * Unregisters from registry, deletes per-session directory, and removes legacy file. @@ -1566,11 +1575,7 @@ export async function sessionClose( const embeddingVector = await embed(embeddingText); if (embeddingVector) { const embeddingJson = JSON.stringify(embeddingVector); - // Update session with embedding (PATCH, not upsert — row already exists) - await supabase.directPatch(getTableName("sessions"), - { id: sessionId }, - { embedding: embeddingJson } - ); + await saveSessionEmbedding(sessionId, embeddingJson); console.error("[session_close] Embedding saved to session"); // Phase 5: Implicit thread detection (chained after embedding) diff --git a/src/tools/session-start.ts b/src/tools/session-start.ts index b177c3c..dad1b95 100644 --- a/src/tools/session-start.ts +++ b/src/tools/session-start.ts @@ -478,7 +478,8 @@ async function createSessionRecord( * Fire-and-forget — failures logged but don't block session_start. * Only sets close_compliance if it's currently null (truly abandoned). */ -async function markSessionSuperseded(oldSessionId: string, newSessionId: string): Promise { +/** @internal exported for the GIT-119 zero-row test. */ +export async function markSessionSuperseded(oldSessionId: string, newSessionId: string): Promise { if (!hasSupabase()) return; // Free tier: no remote session tracking try { // Check if session already has close_compliance (was properly closed) @@ -490,7 +491,7 @@ async function markSessionSuperseded(oldSessionId: string, newSessionId: string) // Already closed — don't overwrite return; } - await supabase.directPatch(getTableName("sessions"), + const patched = await supabase.directPatch(getTableName("sessions"), { id: oldSessionId }, { close_compliance: { @@ -500,6 +501,8 @@ async function markSessionSuperseded(oldSessionId: string, newSessionId: string) }, } ); + // GIT-119: 0 rows = the superseded row isn't there; not marked. + if (patched.count === 0) throw new Error(`no session row ${oldSessionId.slice(0, 8)} to mark superseded`); console.error(`[session_start] Marked session ${oldSessionId.slice(0, 8)} as superseded by ${newSessionId.slice(0, 8)}`); } catch (error) { console.error(`[session_start] Failed to mark session ${oldSessionId.slice(0, 8)} as superseded:`, error); diff --git a/tests/e2e/blank-supabase.mjs b/tests/e2e/blank-supabase.mjs index 0a16761..1d61757 100644 --- a/tests/e2e/blank-supabase.mjs +++ b/tests/e2e/blank-supabase.mjs @@ -400,6 +400,25 @@ async function flow() { await waitQuiet(netlog, { minMs: 2000, quietMs: 3000 }); const health = await step("health", { failure_limit: 20 }); + // GIT-119: archive_learning by id prefix sent id=like.% against a + // uuid column (42883), and a full UUID that matched no row still reported + // "Archived". The row below is inserted after the server loaded its index, + // so the prefix must be resolved by the store (UUID range), not locally. + const archiveRowId = randomUUID(); + await rest("POST", "gitmem_learnings", [{ + id: archiveRowId, learning_type: "scar", title: `Driver archive target (${label})`, + description: "Inserted after startup so archive_learning resolves its prefix from the store.", + severity: "low", counter_arguments: ["You might think it is permanent — but it is archived here."], + project: PROJECT, is_active: true, + }]); + const archivePrefix = archiveRowId.slice(0, 8); + const ap = await step("archive_learning", { id: archivePrefix, reason: "venue driver (GIT-119)" }); + const archivedRow = await rest("GET", `gitmem_learnings?select=is_active&id=eq.${archiveRowId}`, undefined, { Prefer: "" }).then((r) => r.json()); + const archivePrefixDurable = /Archived learning/.test(ap) && archivedRow[0]?.is_active === false; + const ghostId = "00000000-0000-4000-8000-00000000d119"; + const ag = await step("archive_learning", { id: ghostId, reason: "venue driver (GIT-119)" }); + const archiveGhostHonest = !/Archived learning/.test(ag) && /nothing was archived|not found|No learning/i.test(ag); + // GIT-102: every WARN on the close names a subsystem and cause and says // whether the session content was stored; health reports the write-path verdict. const closeWarns = sc.replace(/\x1b\[[0-9;]*m/g, "").split("\n").filter((l) => /^WARN /.test(l)); @@ -443,6 +462,10 @@ async function flow() { session_close_persisted: closedRows.length === 1 && closedRows[0].closing_reflection != null, missing_payload_named: missingPayloadNamed, resolve_thread_durable: resolveDurable, + archive_prefix_durable: archivePrefixDurable, + archive_prefix_text: ap.slice(0, 300), + archive_ghost_honest: archiveGhostHonest, + archive_ghost_text: ag.slice(0, 300), close_warns: closeWarns, close_warns_self_describing: closeWarnsSelfDescribing, health_has_write_path: healthHasWritePath, @@ -624,6 +647,12 @@ if (edgeCalls.length) failureCheck.unexpected.push(...edgeCalls.map((c) => `edge if (mode === "flow" && !result.resolve_thread_durable) { failureCheck.unexpected.push("resolve_thread did not land durably in gitmem_threads, or claimed it did not (GIT-101)"); } +if (mode === "flow" && !result.archive_prefix_durable) { + failureCheck.unexpected.push(`archive_learning by id prefix did not archive the row (GIT-119): ${result.archive_prefix_text}`); +} +if (mode === "flow" && !result.archive_ghost_honest) { + failureCheck.unexpected.push(`archive_learning of a nonexistent id claimed success (GIT-119): ${result.archive_ghost_text}`); +} if (mode === "flow" && !result.missing_payload_named) { failureCheck.unexpected.push("session_close without a payload did not name the absolute closing-payload.json path (GIT-99)"); } diff --git a/tests/unit/services/directpatch-zero-rows.test.ts b/tests/unit/services/directpatch-zero-rows.test.ts new file mode 100644 index 0000000..e27e4fe --- /dev/null +++ b/tests/unit/services/directpatch-zero-rows.test.ts @@ -0,0 +1,131 @@ +/** + * GIT-119: a PATCH that matched no row is not a write. + * + * PostgREST answers 200 with [] when a PATCH's filter matches nothing (a wrong + * id, a row not created yet, RLS). directPatch returned that array and all 8 + * callers ignored it, so each reported or assumed success for a write that did + * not happen. directPatch now returns { count, rows } and every caller treats + * count 0 as not durable, each in the way its result can carry it. + * + * One parameterized test over all 8 callers. The store is stubbed at fetch: + * every PATCH answers 200 [], every GET answers the rows the caller needs to + * reach its PATCH. + */ +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from "vitest"; + +const env = vi.hoisted(() => { + const keys = ["GITMEM_TIER", "SUPABASE_URL", "SUPABASE_SERVICE_ROLE_KEY", "GITMEM_DIR"]; + const before = Object.fromEntries(keys.map((k) => [k, process.env[k]])); + const dir = require("fs").mkdtempSync(require("path").join(require("os").tmpdir(), "gitmem-git119-")); + Object.assign(process.env, { + GITMEM_TIER: "pro", + SUPABASE_URL: "https://stubbed.supabase.co", + SUPABASE_SERVICE_ROLE_KEY: "stub", + GITMEM_DIR: dir, + }); + return { before, dir }; +}); + +import { resetTier } from "../../../src/services/tier.js"; +import { archiveLearning } from "../../../src/tools/archive-learning.js"; +import { saveSessionEmbedding } from "../../../src/tools/session-close.js"; +import { markSessionSuperseded } from "../../../src/tools/session-start.js"; +import { resolveThreadInSupabase, touchThreadsInSupabase, archiveDormantThreads } from "../../../src/services/thread-supabase.js"; +import { updateRelevanceData } from "../../../src/services/metrics.js"; +import { saveTranscript } from "../../../src/services/supabase-client.js"; +import { getEffectTracker } from "../../../src/services/effect-tracker.js"; + +const UUID = "a501c95e-1234-4678-9abc-def012345678"; +const SCAR = "b1111111-2222-4333-8444-555555555555"; +const patches: string[] = []; + +/** Rows a GET returns, by table, so each caller reaches its PATCH. */ +function getRows(url: URL): unknown[] { + const table = url.pathname.split("/").pop() ?? ""; + if (url.searchParams.get("limit") === "1" && url.searchParams.get("select") === "archived_at") return [{ archived_at: null }]; // column probe + if (table.endsWith("sessions")) return [{ close_compliance: null }]; + if (table.endsWith("threads")) { + if (url.searchParams.get("status") === "eq.dormant") { + return [{ id: UUID, thread_id: "t-dormant", metadata: { dormant_since: new Date(Date.now() - 60 * 86400_000).toISOString() } }]; + } + return [{ id: UUID, thread_id: "t-x", touch_count: 1, created_at: new Date().toISOString(), thread_class: "backlog", status: "active" }]; + } + if (table === "gitmem_query_metrics") return [{ id: UUID, memories_surfaced: [SCAR], metadata: {} }]; + return []; +} + +beforeAll(() => { + resetTier(); + vi.stubGlobal("fetch", vi.fn(async (input: unknown, init?: { method?: string }) => { + const url = new URL(String(input)); + const method = init?.method ?? "GET"; + const body = method === "PATCH" ? (patches.push(url.pathname), []) : method === "GET" ? getRows(url) : { Key: "ok" }; + return new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } }); + })); +}); + +afterAll(() => { + vi.unstubAllGlobals(); + for (const [k, v] of Object.entries(env.before)) { + if (v === undefined) delete process.env[k]; else process.env[k] = v; + } + resetTier(); + require("fs").rmSync(env.dir, { recursive: true, force: true }); +}); + +beforeEach(() => { patches.length = 0; }); + +/** A background write's failure is visible in stderr or the effect tracker. */ +const errorsLogged = () => (console.error as unknown as { mock: { calls: unknown[][] } }).mock.calls.map((c) => c.join(" ")).join("\n"); +const trackerFailures = (path: string) => getEffectTracker().getHealthReport().byPath[path]?.failed ?? 0; + +type Case = { caller: string; run: () => Promise }; + +const CASES: Case[] = [ + { caller: "archive_learning (full UUID)", run: async () => { + const r = await archiveLearning({ id: UUID }); + expect(r.success).toBe(false); + expect(r.durable).toBe(false); + } }, + { caller: "session_close: session embedding", run: async () => { + await expect(saveSessionEmbedding(UUID, "[0.1]")).rejects.toThrow(/updated no row/); + } }, + { caller: "session_start: mark superseded", run: async () => { + await markSessionSuperseded(UUID, SCAR); + expect(errorsLogged()).toMatch(/Failed to mark session .* superseded[\s\S]*no session row/); + } }, + { caller: "thread resolve", run: async () => { + expect(await resolveThreadInSupabase("t-x")).toBe(false); + } }, + { caller: "thread touch", run: async () => { + await touchThreadsInSupabase(["t-x"]); + expect(errorsLogged()).toMatch(/Failed to touch thread t-x[\s\S]*updated no row/); + } }, + { caller: "thread auto-archive", run: async () => { + const r = await archiveDormantThreads("default", 30); + expect(r.archived_count).toBe(0); + expect(r.archived_ids).toEqual([]); + } }, + { caller: "relevance update (metrics)", run: async () => { + const before = trackerFailures("relevance_update"); + await updateRelevanceData(UUID, [SCAR], { [SCAR]: "high" }); + expect(trackerFailures("relevance_update")).toBe(before + 1); + } }, + { caller: "saveTranscript: transcript_path", run: async () => { + const r = await saveTranscript(UUID, "hello"); + expect(r.patch_warning).toMatch(/no session row/); + } }, +]; + +describe("GIT-119: every directPatch caller treats 0 rows as not durable", () => { + it("covers all 8 callers", () => { + expect(CASES).toHaveLength(8); + }); + + it.each(CASES)("$caller", async ({ run }) => { + vi.spyOn(console, "error").mockImplementation(() => {}); + await run(); + expect(patches.length).toBeGreaterThan(0); // the PATCH was really sent + vi.restoreAllMocks(); + }); +}); diff --git a/tests/unit/tools/archive-learning.test.ts b/tests/unit/tools/archive-learning.test.ts index 7a004be..211ea06 100644 --- a/tests/unit/tools/archive-learning.test.ts +++ b/tests/unit/tools/archive-learning.test.ts @@ -7,7 +7,9 @@ import { SUPABASE_TIER_MOCKS } from "../../helpers/supabase-mocks.js"; // --- Mocks --- -const mockDirectPatch = vi.fn().mockResolvedValue(undefined); +// GIT-119: directPatch returns what it changed; the default is one row. +const PATCHED_ONE = { count: 1, rows: [{ id: "a501c95e-1234-5678-9abc-def012345678" }] }; +const mockDirectPatch = vi.fn().mockResolvedValue(PATCHED_ONE); const mockDirectQuery = vi.fn().mockResolvedValue([]); const mockIsConfigured = vi.fn(() => true); @@ -44,6 +46,11 @@ vi.mock("../../../src/services/startup.js", () => ({ flushCache: vi.fn().mockResolvedValue(undefined), })); +const localIds = vi.hoisted(() => ({ ids: [] as string[] })); +vi.mock("../../../src/services/local-vector-search.js", () => ({ + getLocalVectorSearch: () => ({ getScarIds: () => localIds.ids }), +})); + vi.mock("../../../src/services/metrics.js", () => ({ Timer: class { stop() { return 42; } }, })); @@ -52,7 +59,7 @@ vi.mock("../../../src/services/display-protocol.js", () => ({ wrapDisplay: (msg: string) => msg, })); -import { archiveLearning } from "../../../src/tools/archive-learning.js"; +import { archiveLearning, uuidRangeForPrefix } from "../../../src/tools/archive-learning.js"; import { hasSupabase } from "../../../src/services/tier.js"; const FULL_UUID = "a501c95e-1234-5678-9abc-def012345678"; @@ -60,6 +67,8 @@ const FULL_UUID = "a501c95e-1234-5678-9abc-def012345678"; describe("archive_learning prefix resolution", () => { beforeEach(() => { vi.clearAllMocks(); + mockDirectPatch.mockResolvedValue(PATCHED_ONE); + localIds.ids = []; vi.mocked(hasSupabase).mockReturnValue(true); mockIsConfigured.mockReturnValue(true); }); @@ -107,19 +116,21 @@ describe("archive_learning prefix resolution", () => { // --- Supabase prefix resolution --- - it("resolves 8-char prefix via Supabase like filter", async () => { + // GIT-119: these asserted id=like.%. Postgres has no LIKE on a UUID + // column (42883 on every store), so they pinned the bug; the store is now + // asked for the UUID range the prefix spans. + it("resolves 8-char prefix via a UUID range query", async () => { mockDirectQuery.mockResolvedValueOnce([{ id: FULL_UUID }]); const result = await archiveLearning({ id: "a501c95e" }); expect(result.success).toBe(true); expect(result.id).toBe(FULL_UUID); - // Check directQuery was called with like filter expect(mockDirectQuery).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ select: "id", - filters: { id: "like.a501c95e%" }, + filters: { and: "(id.gte.a501c95e-0000-0000-0000-000000000000,id.lte.a501c95e-ffff-ffff-ffff-ffffffffffff)" }, limit: 2, }), ); @@ -141,7 +152,7 @@ describe("archive_learning prefix resolution", () => { expect(mockDirectQuery).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ - filters: { id: "like.a501%" }, + filters: { and: "(id.gte.a5010000-0000-0000-0000-000000000000,id.lte.a501ffff-ffff-ffff-ffff-ffffffffffff)" }, }), ); }); @@ -224,3 +235,42 @@ describe("archive_learning prefix resolution", () => { expect(result.display).toContain("superseded"); }); }); + +describe("GIT-119: archive_learning on a store that has no LIKE for UUIDs", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockDirectPatch.mockResolvedValue(PATCHED_ONE); + localIds.ids = []; + vi.mocked(hasSupabase).mockReturnValue(true); + mockIsConfigured.mockReturnValue(true); + }); + + it("uuidRangeForPrefix spans exactly the ids with that prefix", () => { + expect(uuidRangeForPrefix("a501c95e1")).toEqual([ + "a501c95e-1000-0000-0000-000000000000", + "a501c95e-1fff-ffff-ffff-ffffffffffff", + ]); + const [lo, hi] = uuidRangeForPrefix("a501"); + for (const id of ["a5010000-0000-0000-0000-000000000000", FULL_UUID, "a501ffff-ffff-ffff-ffff-ffffffffffff"]) { + expect(id >= lo && id <= hi).toBe(true); + } + for (const id of ["a500ffff-ffff-ffff-ffff-ffffffffffff", "a5020000-0000-0000-0000-000000000000"]) { + expect(id >= lo && id <= hi).toBe(false); + } + }); + + it("resolves from the warm local index without a query", async () => { + localIds.ids = [FULL_UUID, "b1111111-1111-1111-1111-111111111111"]; + const result = await archiveLearning({ id: "a501c9" }); + expect(result.id).toBe(FULL_UUID); + expect(mockDirectQuery).not.toHaveBeenCalled(); + }); + + it("an id the PATCH matched no row for is not archived, and not durable", async () => { + mockDirectPatch.mockResolvedValueOnce({ count: 0, rows: [] }); + const result = await archiveLearning({ id: FULL_UUID }); + expect(result.success).toBe(false); + expect(result.durable).toBe(false); + expect(result.error).toMatch(/nothing was archived/); + }); +});