Move the API error classes into a leaf module

api-handler imports the record-session fence and the fence threw
RecordSessionChangedError / SharingAccessDeniedError imported back from
api-handler — the one real import cycle in the tree, working only by
evaluation-order luck. HttpError, the SharingAuthError family and
StepUpRequiredError now live in api-errors.ts (importing nothing but
the fence contract), api-handler re-exports the identical class objects
so no importer moves, and the fence imports the leaf. A guard pins both
the identity and the leaf structure.
This commit is contained in:
Marc-André Bombeck committed 2026-08-14 02:26:05 +02:00
1 parent d75034a9f4
commit b7dfd236ee
4 files changed
+228 -120

No files matched your search

@@ -0,0 +1,72 @@
/**
* The error classes live in ONE leaf module — the cycle stays broken.
*
* `api-handler.ts` imports the record-session fence, and the fence throws
* `RecordSessionChangedError` / `SharingAccessDeniedError`. When those
* classes lived inside api-handler the two modules imported each other and
* the loop worked only by evaluation-order luck. This guard pins the split:
*
* 1. identity — the class an importer gets from `api-handler` IS the class
* the fence throws (from `api-errors`), so `instanceof` across the two
* import paths can never diverge;
* 2. structure — the fence carries no VALUE import from api-handler
* (a type-only import is erased and allowed), and the leaf imports
* nothing but the fence contract.
*
* Watched red: re-declaring `HttpError` inside api-handler (the pre-split
* shape) fails the identity leg; adding a value import of api-handler back
* into the fence fails the structural leg.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import * as apiErrors from "@/lib/api-errors";
import {
HttpError,
RecordSessionChangedError,
SharingAccessDeniedError,
SharingAuthError,
SharingNotPermittedError,
StepUpRequiredError,
} from "@/lib/api-handler";
const SRC = join(__dirname, "..");
describe("api-errors leaf module", () => {
it("api-handler re-exports the identical class objects", () => {
expect(HttpError).toBe(apiErrors.HttpError);
expect(SharingAuthError).toBe(apiErrors.SharingAuthError);
expect(SharingAccessDeniedError).toBe(apiErrors.SharingAccessDeniedError);
expect(SharingNotPermittedError).toBe(apiErrors.SharingNotPermittedError);
expect(RecordSessionChangedError).toBe(apiErrors.RecordSessionChangedError);
expect(StepUpRequiredError).toBe(apiErrors.StepUpRequiredError);
});
it("the record-session fence carries no value import from api-handler", () => {
const source = readFileSync(
join(SRC, "lib/sharing/record-session-fence.ts"),
"utf8",
);
const imports = source.match(/^import[^;]+from "@\/lib\/api-handler";/gms);
for (const statement of imports ?? []) {
expect(
statement.startsWith("import type"),
`record-session-fence.ts imports a VALUE from api-handler — that is the cycle the api-errors leaf removed:\n${statement}`,
).toBe(true);
}
});
it("the leaf imports nothing but the fence contract", () => {
const source = readFileSync(join(SRC, "lib/api-errors.ts"), "utf8");
const imports = source.match(/^import[^;]+;/gms) ?? [];
// Self-check: the walker found the one expected import.
expect(imports.length).toBeGreaterThan(0);
for (const statement of imports) {
expect(
statement.includes("record-session-fence-contract"),
`api-errors.ts must stay a leaf; unexpected import:\n${statement}`,
).toBe(true);
}
});
});