feat: replace Subject retirement with durable explicit deletion

This commit is contained in:
2026-10-28 11:18:43 +09:00
parent 618880d38d
commit a06f6bfb3d
71 changed files with 7448 additions and 315 deletions
+152
View File
@@ -0,0 +1,152 @@
import {
deleteSubjektivSubject,
parseSubjektivSubjectDeleteResponse,
SubjektivSubjectDeleteError,
} from "../src/lib/workspace/memory/api.ts";
import {
clearSubjectDeleteAttempt,
readSubjectDeleteAttempt,
saveSubjectDeleteAttempt,
subjectDeleteStorageKey,
} from "../src/lib/workspace/memory/subject-delete.ts";
declare const Deno: {
test(name: string, fn: () => void | Promise<void>): void;
};
function equal(actual: unknown, expected: unknown) {
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(
`Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
);
}
}
Deno.test("Subject deletion uses DELETE with the same request after fence-only success", async () => {
const calls: unknown[] = [];
const fetcher = ((input, init) => {
calls.push([input, init]);
return Promise.resolve(
Response.json({
subject_id: "s/1",
operation_id: "same-operation",
completed: calls.length > 1,
}),
);
}) as typeof fetch;
for (const completed of [false, true]) {
equal(
await deleteSubjektivSubject(fetcher, "w/1", "s/1", {
operation_id: "same-operation",
}),
{ subject_id: "s/1", operation_id: "same-operation", completed },
);
}
equal(calls[0], calls[1]);
equal(calls[0], ["/api/w/w%2F1/subjektiv/subjects/s%2F1", {
method: "DELETE",
headers: { "content-type": "application/json" },
body: '{"operation_id":"same-operation"}',
}]);
});
Deno.test("Subject deletion never confirms a malformed or wrong-target response", () => {
for (
const payload of [
{ subject_id: "other", operation_id: "op", completed: true },
{ subject_id: "s", operation_id: "other", completed: true },
{ subject_id: "s", operation_id: "op", completed: "true" },
{ subject_id: "s", operation_id: "op" },
{ subject_id: "s", operation_id: "op", completed: true, extra: 1 },
]
) {
try {
parseSubjektivSubjectDeleteResponse(payload, "s", "op");
} catch {
continue;
}
throw new Error("Accepted an unconfirmed response");
}
});
Deno.test("Subject deletion classifies rejection separately from unknown transport/server/response outcomes", async () => {
for (
const item of [
{
fetcher: () => Promise.reject(new Error("lost")),
kind: "unknown_outcome",
},
{
fetcher: () =>
Promise.resolve(
Response.json({ error: "forbidden" }, { status: 403 }),
),
kind: "rejected",
},
{
fetcher: () =>
Promise.resolve(
Response.json({ error: "conflict" }, { status: 409 }),
),
kind: "rejected",
},
{
fetcher: () =>
Promise.resolve(
Response.json({ error: "uncertain" }, { status: 500 }),
),
kind: "unknown_outcome",
},
{
fetcher: () =>
Promise.resolve(
Response.json({
subject_id: "wrong",
operation_id: "op",
completed: true,
}),
),
kind: "unknown_outcome",
},
]
) {
try {
await deleteSubjektivSubject(item.fetcher as typeof fetch, "w", "s", {
operation_id: "op",
});
} catch (error) {
if (!(error instanceof SubjektivSubjectDeleteError)) throw error;
equal(error.kind, item.kind);
continue;
}
throw new Error("Unexpected success");
}
});
Deno.test("Retry input survives reload independently of Subject GET and is isolated by Workspace/Subject", () => {
const values = new Map<string, string>();
const storage = {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => {
values.set(key, value);
},
removeItem: (key: string) => {
values.delete(key);
},
};
const attempt = {
subjectId: "s",
operationId: "same-op",
};
saveSubjectDeleteAttempt(storage, "w", attempt);
equal(readSubjectDeleteAttempt(storage, "w", "s"), attempt);
equal(readSubjectDeleteAttempt(storage, "other", "s"), null);
equal(readSubjectDeleteAttempt(storage, "w", "other"), null);
saveSubjectDeleteAttempt(storage, "other", attempt);
clearSubjectDeleteAttempt(storage, "w", "s");
equal(readSubjectDeleteAttempt(storage, "w", "s"), null);
equal(readSubjectDeleteAttempt(storage, "other", "s"), attempt);
values.set(subjectDeleteStorageKey("w", "s"), '{"subjectId":"other"}');
try {
readSubjectDeleteAttempt(storage, "w", "s");
} catch {
return;
}
throw new Error("Corrupt retry input allowed new deletion");
});