feat: browse Subject Journals and persistent Memory links

This commit is contained in:
2026-10-18 02:03:51 +09:00
parent 5ee42b8db8
commit 75b1557d63
30 changed files with 4594 additions and 120 deletions
+235
View File
@@ -0,0 +1,235 @@
declare const Deno: {
test(name: string, fn: () => void | Promise<void>): void;
};
function assertEquals(actual: unknown, expected: unknown): void {
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(
`Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
);
}
}
function assertThrows(fn: () => void): void {
try {
fn();
} catch {
return;
}
throw new Error("Expected rejection");
}
import {
boundedParam,
journalHref,
loadJournal,
offsetParam,
parseJournalResponse,
} from "../src/lib/workspace/memory/journal-api.ts";
// transport/parserの局所契約。認可と保存接続はRustの実router/store試験が所有する。
const selector = {
memory_id: "memory",
change_id: "change",
candidate_id: "candidate",
};
const entry = {
id: "entry",
journal_id: "journal",
position: 1,
state: "fixed",
title: "認証方式を整理した",
correction_refs: [],
content_token: "token",
created_at: "2026-10-16T00:00:00Z",
updated_at: "2026-10-16T00:00:00Z",
fixed_at: null,
};
const journal = {
id: "journal",
subject_id: "subject",
source_session_id: "session",
title: "認証のSession",
current_position_bytes: 0,
process_order: 1,
process_byte: 0,
state_token: "token",
created_at: entry.created_at,
updated_at: entry.updated_at,
};
const original = { state: "missing", history_href: null };
Deno.test("Journal parser preserves missing originals independently of linked saved entries", () => {
const value = {
selector,
sources: [{
link: {
source_index: 0,
original_source: "missing",
connection: {
state: "linked",
journal_id: "journal",
entry_id: "entry",
target: "available",
reconsideration: true,
},
},
entry,
original_source: original,
}],
total_sources: 8,
next_source_index: 1,
};
const parsed = parseJournalResponse(
{ kind: "memory_sources", value },
"memory_sources",
);
assertEquals(parsed.sources[0].entry?.title, entry.title);
assertEquals(parsed.sources[0].original_source.state, "missing");
assertEquals(parsed.next_source_index, 1);
assertThrows(() =>
parseJournalResponse({
kind: "memory_sources",
value: {
...value,
sources: [{ ...value.sources[0], entry: { ...entry, id: "other" } }],
},
}, "memory_sources")
);
});
Deno.test("Journal parser rejects wrong Subject, response kind, unbounded lists and unsafe history hrefs", () => {
const value = {
journal,
entry,
correction_targets: [{
...entry,
journal_id: "another-journal",
id: "another-entry",
}],
original_source: original,
};
assertEquals(
parseJournalResponse({ kind: "entry", value }, "entry", "subject")
.correction_targets[0].journal_id,
"another-journal",
);
assertThrows(() =>
parseJournalResponse({ kind: "entry", value }, "entry", "other-subject")
);
assertThrows(() => parseJournalResponse({ kind: "text", value }, "entry"));
assertThrows(() =>
parseJournalResponse({
kind: "entries",
value: { items: Array(33).fill(entry), next: null },
}, "entries")
);
assertThrows(() =>
parseJournalResponse({
kind: "entry",
value: {
...value,
original_source: {
state: "available",
history_href: "javascript:alert(1)",
},
},
}, "entry")
);
assertThrows(() =>
parseJournalResponse({
kind: "entry",
value: { ...value, entry: { ...entry, state: "recorded" } },
}, "entry")
);
assertThrows(() =>
parseJournalResponse({
kind: "text",
value: {
text: "x".repeat(16385),
next_byte: null,
content_token: "token",
},
}, "text")
);
});
Deno.test("Journal transport reads immutable links without reconciling and rejects a mismatched selector", async () => {
const requests: { path: string; request: unknown }[] = [];
const fetcher = ((path: string, init: RequestInit) => {
requests.push({ path, request: JSON.parse(String(init.body)) });
return Promise.resolve(
Response.json({
kind: "memory_sources",
value: {
selector,
sources: [],
total_sources: 0,
next_source_index: null,
},
}),
);
}) as typeof fetch;
const request = {
action: "memory_sources" as const,
selector,
source_offset: 3,
limit: 20,
};
assertEquals(
(await loadJournal(fetcher, "workspace", "subject", request)).error,
null,
);
assertEquals(requests, [{
path: "/api/w/workspace/subjektiv/subjects/subject/journal-browser",
request,
}]);
const mismatch = await loadJournal(fetcher, "workspace", "subject", {
...request,
selector: { ...selector, change_id: "other" },
});
assertEquals(mismatch.data, null);
assertEquals(mismatch.error, "Journal API response is invalid");
});
Deno.test("Journal HTTP denial and transport failure remain failures, not empty pages", async () => {
for (
const [fetcher, status] of [
[
() => Promise.resolve(new Response("not authorized", { status: 403 })),
403,
],
[() => Promise.reject(new Error("offline")), undefined],
] as const
) {
const value = await loadJournal(
fetcher as typeof fetch,
"workspace",
"subject",
{
action: "entries",
journal_id: null,
query: null,
after: null,
limit: 20,
},
);
assertEquals(value.data, null);
assertEquals(value.status, status);
assertEquals(value.error !== null, true);
}
});
Deno.test("Journal links retain exact selected entry and bounded URL offsets", () => {
const url = new URL(
journalHref("workspace", "subject/a", {
journal: "journal",
entry: "entry&selected",
q: "日本語",
}),
"https://example.com",
);
assertEquals(url.pathname, "/w/workspace/memory/subject%2Fa/journal");
assertEquals(url.searchParams.get("entry"), "entry&selected");
assertEquals(
boundedParam(new URLSearchParams({ q: "あ".repeat(5) }), "q", 14),
null,
);
assertEquals(offsetParam(new URLSearchParams({ offset: "-1" }), "offset"), 0);
assertEquals(
offsetParam(new URLSearchParams({ offset: "27" }), "offset"),
27,
);
});