summaryrefslogtreecommitdiffhomepage
path: root/src/features/layout/adapter
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/layout/adapter')
-rw-r--r--src/features/layout/adapter/abi-port.test.ts97
-rw-r--r--src/features/layout/adapter/abi-port.ts60
-rw-r--r--src/features/layout/adapter/http.test.ts182
-rw-r--r--src/features/layout/adapter/http.ts175
4 files changed, 514 insertions, 0 deletions
diff --git a/src/features/layout/adapter/abi-port.test.ts b/src/features/layout/adapter/abi-port.test.ts
new file mode 100644
index 0000000..24f9c94
--- /dev/null
+++ b/src/features/layout/adapter/abi-port.test.ts
@@ -0,0 +1,97 @@
+import { DEFAULT_LAYOUT, type LayoutTree } from "@dispatch/layout-contract";
+import { describe, expect, it, vi } from "vitest";
+import type { LayoutProviderState, LayoutStore } from "../store.svelte";
+import { abiTree, createLayoutAbiPort } from "./abi-port";
+
+/**
+ * The ABI `layout` port tests (plan 04 §4.5) — the port is pure composition
+ * over the layout store + the ops HTTP edge, so BOTH deps are fakes (the
+ * outermost edges; nothing internal is mocked — asymmetric testing).
+ */
+
+function fakeStore(
+ overrides: Partial<Pick<LayoutStore, "tree" | "providerState" | "activeWorkspaceId">> = {},
+): LayoutStore {
+ return {
+ get tree(): LayoutTree {
+ return DEFAULT_LAYOUT;
+ },
+ get providerState(): LayoutProviderState {
+ return "present";
+ },
+ get activeWorkspaceId(): string {
+ return "ws-a";
+ },
+ probe: vi.fn(async () => {}),
+ onWorkspaceChanged: vi.fn(),
+ onServerTree: vi.fn(),
+ onTreeChange: vi.fn(),
+ resetToDefault: vi.fn(async () => null),
+ onExtensionsChanged: vi.fn(),
+ onWorkspacePluginsChanged: vi.fn(),
+ ...overrides,
+ } as LayoutStore;
+}
+
+describe("abiTree — the getTree decision (pure)", () => {
+ it("a PRESENT provider exposes the tree", () => {
+ expect(abiTree("present", DEFAULT_LAYOUT)).toBe(DEFAULT_LAYOUT);
+ });
+
+ it("no-provider mode (absent) exposes NULL — the local fallback is not shared state", () => {
+ expect(abiTree("absent", DEFAULT_LAYOUT)).toBeNull();
+ });
+
+ it("an unknown (not-yet-probed) provider exposes NULL too", () => {
+ expect(abiTree("unknown", DEFAULT_LAYOUT)).toBeNull();
+ });
+});
+
+describe("createLayoutAbiPort — applyOps → the ops route", () => {
+ it("posts to the ACTIVE workspace and feeds the returned tree back through the store", async () => {
+ const ops = [{ type: "ensurePanel", panels: "side", view: "demo" }] as const;
+ const edited: LayoutTree = { ...DEFAULT_LAYOUT };
+ const store = fakeStore();
+ const seen: string[] = [];
+ const port = createLayoutAbiPort({
+ store,
+ postOps: async (workspaceId, o) => {
+ seen.push(workspaceId);
+ return { ok: true, tree: edited, applied: o.length };
+ },
+ });
+
+ const result = await port.applyOps(ops);
+ expect(seen).toEqual(["ws-a"]); // the ACTIVE workspace at CALL time
+ expect(result).toEqual({ ok: true, tree: edited, applied: 1 });
+ // The successful apply echoes the tree into the store (instant local
+ // re-render; the WS broadcast echo is idempotent with this).
+ expect(store.onServerTree).toHaveBeenCalledWith("ws-a", edited);
+ });
+
+ it("a failed batch (typed no-provider error) does NOT touch the store", async () => {
+ const store = fakeStore({ providerState: "absent" });
+ const port = createLayoutAbiPort({
+ store,
+ postOps: async () => ({ ok: false, error: "no layout provider active" }),
+ });
+
+ const result = await port.applyOps([{ type: "removeNode", target: "side" }]);
+ expect(result).toEqual({ ok: false, error: "no layout provider active" });
+ expect(store.onServerTree).not.toHaveBeenCalled();
+ });
+
+ it("getTree returns the active tree while the provider is present, null when absent", () => {
+ const port = createLayoutAbiPort({
+ store: fakeStore({ providerState: "present" }),
+ postOps: async () => ({ ok: false, error: "unused" }),
+ });
+ expect(port.getTree()).toBe(DEFAULT_LAYOUT);
+
+ const absent = createLayoutAbiPort({
+ store: fakeStore({ providerState: "absent" }),
+ postOps: async () => ({ ok: false, error: "no layout provider active" }),
+ });
+ expect(absent.getTree()).toBeNull();
+ });
+});
diff --git a/src/features/layout/adapter/abi-port.ts b/src/features/layout/adapter/abi-port.ts
new file mode 100644
index 0000000..69576c6
--- /dev/null
+++ b/src/features/layout/adapter/abi-port.ts
@@ -0,0 +1,60 @@
+import type { ApplyOpsResult, LayoutOp, LayoutTree } from "@dispatch/layout-contract";
+import type { LayoutProviderState, LayoutStore } from "../store.svelte";
+
+/**
+ * The port the frontend-plugin ABI exposes as `api.layout` (plan 04 §4.5).
+ * The composition root builds it with {@link createLayoutAbiPort} and the
+ * plugin host hands it to every `activate(api)` — plugins never see the
+ * store or the HTTP edge, only THIS.
+ */
+export interface LayoutAbiPort {
+ /**
+ * Apply op-vocabulary edits to the ACTIVE workspace's tree through the
+ * provider (`POST /workspaces/:id/layout/ops`). Atomic; the no-provider
+ * 404 maps to the typed `{ ok: false, error: "no layout provider active" }`.
+ */
+ readonly applyOps: (ops: readonly LayoutOp[]) => Promise<ApplyOpsResult>;
+ /** The ACTIVE workspace's tree; `null` in no-provider mode. */
+ readonly getTree: () => LayoutTree | null;
+}
+
+export interface LayoutAbiPortDeps {
+ /** The layout store (the active workspace's tree + provider state). */
+ readonly store: LayoutStore;
+ /** The ops HTTP edge — bound per CALL to the then-active workspace. */
+ readonly postOps: (workspaceId: string, ops: readonly LayoutOp[]) => Promise<ApplyOpsResult>;
+}
+
+/**
+ * Build the ABI's `layout` port over the layout store + the ops HTTP edge
+ * (pure composition — no ambient state; both deps injected). A successful
+ * apply feeds the returned tree back through `onServerTree` so the local
+ * client re-renders immediately (the server's WS broadcast is the
+ * authoritative echo for every client, this one included — applying it twice
+ * is idempotent). The workspace is read at CALL time, so a workspace switch
+ * between calls targets the right tree; a switch mid-flight is guarded by
+ * `onServerTree`'s own workspace filter.
+ */
+export function createLayoutAbiPort(deps: LayoutAbiPortDeps): LayoutAbiPort {
+ return {
+ async applyOps(ops): Promise<ApplyOpsResult> {
+ const workspaceId = deps.store.activeWorkspaceId;
+ const result = await deps.postOps(workspaceId, ops);
+ if (result.ok) {
+ deps.store.onServerTree(workspaceId, result.tree);
+ }
+ return result;
+ },
+ getTree(): LayoutTree | null {
+ // `present` = an active provider is serving the active workspace. In
+ // no-provider (and not-yet-probed) mode the local tree is a fallback,
+ // not shared state — null tells the plugin there is nothing to read.
+ return abiTree(deps.store.providerState, deps.store.tree);
+ },
+ };
+}
+
+/** The getTree decision, pure: only a PRESENT provider's tree is exposed. */
+export function abiTree(state: LayoutProviderState, tree: LayoutTree): LayoutTree | null {
+ return state === "present" ? tree : null;
+}
diff --git a/src/features/layout/adapter/http.test.ts b/src/features/layout/adapter/http.test.ts
new file mode 100644
index 0000000..30e194a
--- /dev/null
+++ b/src/features/layout/adapter/http.test.ts
@@ -0,0 +1,182 @@
+import { DEFAULT_LAYOUT } from "@dispatch/layout-contract";
+import { describe, expect, it } from "vitest";
+import { createLayoutHttp } from "./http";
+
+/**
+ * Layout HTTP adapter tests — the OUTERMOST edge (fetch) is the only fake:
+ * a scripted `fetchImpl` returning canned Responses. No internal mocks.
+ */
+
+const LAYOUT_URL = "http://backend.test/workspaces/ws-a/layout";
+
+function fetchReturning(
+ status: number,
+ body: unknown,
+ capture?: { url?: string; init?: RequestInit | undefined },
+): typeof fetch {
+ return async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
+ if (capture !== undefined) {
+ capture.url =
+ typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ capture.init = init;
+ }
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { "Content-Type": "application/json" },
+ });
+ };
+}
+
+describe("createLayoutHttp — getTree", () => {
+ it("GETs the well-known route and unwraps { tree }", async () => {
+ const capture: { url?: string; init?: RequestInit } = {};
+ const http = createLayoutHttp(
+ "http://backend.test",
+ fetchReturning(200, { tree: DEFAULT_LAYOUT }, capture),
+ );
+ const result = await http.getTree("ws-a");
+ expect(result).toEqual({ ok: true, tree: DEFAULT_LAYOUT });
+ expect(capture.url).toBe(LAYOUT_URL);
+ expect(capture.init?.method).toBeUndefined(); // GET is the default
+ });
+
+ it("maps a 404 to the no-provider signal (notFound: true)", async () => {
+ const http = createLayoutHttp(
+ "http://backend.test",
+ fetchReturning(404, { error: "No layout provider is active" }),
+ );
+ const result = await http.getTree("ws-a");
+ expect(result).toEqual({
+ ok: false,
+ notFound: true,
+ error: "No layout provider is active",
+ });
+ });
+
+ it("maps a 500 to a non-notFound failure carrying the body's error", async () => {
+ const http = createLayoutHttp(
+ "http://backend.test",
+ fetchReturning(500, { error: "Layout storage failure" }),
+ );
+ const result = await http.getTree("ws-a");
+ expect(result).toEqual({ ok: false, notFound: false, error: "Layout storage failure" });
+ });
+
+ it("a network failure degrades to notFound (the no-provider mode)", async () => {
+ const http = createLayoutHttp("http://backend.test", async () => {
+ throw new TypeError("Failed to fetch");
+ });
+ const result = await http.getTree("ws-a");
+ expect(result.ok).toBe(false);
+ if (!result.ok) {
+ expect(result.notFound).toBe(true);
+ expect(result.error).toContain("Failed to fetch");
+ }
+ });
+
+ it("a malformed 200 (SPA-fallback JSON without a tree) degrades to notFound", async () => {
+ const http = createLayoutHttp(
+ "http://backend.test",
+ fetchReturning(200, { chunks: [], historyEpoch: 0, latestSeq: 0 }),
+ );
+ const result = await http.getTree("ws-a");
+ expect(result).toMatchObject({ ok: false, notFound: true });
+ });
+});
+
+describe("createLayoutHttp — putTree", () => {
+ it("PUTs { tree } as JSON and unwraps the echo", async () => {
+ const capture: { url?: string; init?: RequestInit } = {};
+ const http = createLayoutHttp(
+ "http://backend.test",
+ fetchReturning(200, { tree: DEFAULT_LAYOUT }, capture),
+ );
+ const result = await http.putTree("ws-a", DEFAULT_LAYOUT);
+ expect(result).toEqual({ ok: true, tree: DEFAULT_LAYOUT });
+ expect(capture.init?.method).toBe("PUT");
+ expect(capture.init?.body).toBe(JSON.stringify({ tree: DEFAULT_LAYOUT }));
+ });
+
+ it("maps a 400 { error } to a validation failure (not notFound)", async () => {
+ const http = createLayoutHttp(
+ "http://backend.test",
+ fetchReturning(400, { error: "root: split has no children" }),
+ );
+ const result = await http.putTree("ws-a", DEFAULT_LAYOUT);
+ expect(result).toEqual({
+ ok: false,
+ notFound: false,
+ error: "root: split has no children",
+ });
+ });
+
+ it("maps a 404 to the no-provider signal", async () => {
+ const http = createLayoutHttp("http://backend.test", fetchReturning(404, {}));
+ const result = await http.putTree("ws-a", DEFAULT_LAYOUT);
+ expect(result).toMatchObject({ ok: false, notFound: true });
+ });
+});
+
+describe("createLayoutHttp — deleteTree", () => {
+ it("DELETEs and unwraps the fresh seed", async () => {
+ const capture: { url?: string; init?: RequestInit } = {};
+ const http = createLayoutHttp(
+ "http://backend.test",
+ fetchReturning(200, { tree: DEFAULT_LAYOUT }, capture),
+ );
+ const result = await http.deleteTree("ws-a");
+ expect(result).toEqual({ ok: true, tree: DEFAULT_LAYOUT });
+ expect(capture.init?.method).toBe("DELETE");
+ });
+
+ it("maps a 404 to the no-provider signal", async () => {
+ const http = createLayoutHttp("http://backend.test", fetchReturning(404, {}));
+ const result = await http.deleteTree("ws-a");
+ expect(result).toMatchObject({ ok: false, notFound: true });
+ });
+});
+
+describe("createLayoutHttp — postOps (plan 04 §4.2)", () => {
+ it("POSTs { ops } to the ops route and unwraps { tree, applied }", async () => {
+ const capture: { url?: string; init?: RequestInit } = {};
+ const ops = [{ type: "ensurePanel", panels: "side", view: "demo" }] as const;
+ const http = createLayoutHttp(
+ "http://backend.test",
+ fetchReturning(200, { tree: DEFAULT_LAYOUT, applied: 1 }, capture),
+ );
+ const result = await http.postOps("ws-a", ops);
+ expect(result).toEqual({ ok: true, tree: DEFAULT_LAYOUT, applied: 1 });
+ expect(capture.url).toBe(`${LAYOUT_URL}/ops`);
+ expect(capture.init?.method).toBe("POST");
+ expect(capture.init?.body).toBe(JSON.stringify({ ops }));
+ });
+
+ it("maps a 404 to the typed no-provider error (the ABI's documented failure)", async () => {
+ const http = createLayoutHttp("http://backend.test", fetchReturning(404, {}));
+ const result = await http.postOps("ws-a", []);
+ expect(result).toEqual({ ok: false, error: "no layout provider active" });
+ });
+
+ it("maps a 400 { error, failedOp } to the atomic-reject failure", async () => {
+ const http = createLayoutHttp(
+ "http://backend.test",
+ fetchReturning(400, { error: "op 1: no such node", failedOp: 1 }),
+ );
+ const result = await http.postOps("ws-a", [{ type: "removeNode", target: "x" }]);
+ expect(result).toEqual({ ok: false, error: "op 1: no such node", failedOp: 1 });
+ });
+
+ it("a malformed 200 (no tree in the body) is a failure, never a silent ok", async () => {
+ const http = createLayoutHttp("http://backend.test", fetchReturning(200, { applied: 2 }));
+ const result = await http.postOps("ws-a", []);
+ expect(result).toMatchObject({ ok: false });
+ });
+
+ it("a network failure degrades to a typed failure (never throws)", async () => {
+ const http = createLayoutHttp("http://backend.test", async () => {
+ throw new TypeError("fetch failed");
+ });
+ const result = await http.postOps("ws-a", []);
+ expect(result).toEqual({ ok: false, error: "fetch failed" });
+ });
+});
diff --git a/src/features/layout/adapter/http.ts b/src/features/layout/adapter/http.ts
new file mode 100644
index 0000000..ff15a70
--- /dev/null
+++ b/src/features/layout/adapter/http.ts
@@ -0,0 +1,175 @@
+import type { ApplyOpsResult, LayoutOp, LayoutTree } from "@dispatch/layout-contract";
+
+/**
+ * Layout HTTP effects (plan 02 §2.6) — the injected edge that talks to the
+ * ACTIVE layout provider's well-known routes:
+ * - `GET /workspaces/:id/layout` → `{ tree }` (read seeds a fresh workspace)
+ * - `PUT /workspaces/:id/layout` body `{ tree }` → 200 `{ tree }` / 400 `{ error }`
+ * - `DELETE /workspaces/:id/layout` → the fresh seed (reset; the plugins
+ * modal's "Reset layout to default" button — plan 04 §4.4)
+ * - `POST /workspaces/:id/layout/ops` body `{ ops }` → 200 `{ tree, applied }`
+ * / 400 `{ error, failedOp? }` / 404 no provider (plan 04 §4.2 — the FE
+ * ABI's `layout.applyOps` door)
+ *
+ * Mirrors the plugins adapter's pattern: `httpBase` + an injected `fetchImpl`
+ * (testable without the network). The 404 is a SIGNAL, not an error: it means
+ * no layout provider is active, and the store renders its local default with
+ * ephemeral edits (never an error UI). A network failure degrades the same way.
+ */
+
+/** Outcome of a layout read/write. `notFound` distinguishes the no-provider 404. */
+export type LayoutHttpResult =
+ | { readonly ok: true; readonly tree: LayoutTree }
+ | { readonly ok: false; readonly notFound: boolean; readonly error: string };
+
+export interface LayoutHttp {
+ getTree(workspaceId: string): Promise<LayoutHttpResult>;
+ putTree(workspaceId: string, tree: LayoutTree): Promise<LayoutHttpResult>;
+ deleteTree(workspaceId: string): Promise<LayoutHttpResult>;
+ /**
+ * The plan-04 ops door (§4.2): apply op-vocabulary edits through the
+ * provider. The result IS the contract's `ApplyOpsResult` — the 404
+ * (no provider) maps to the typed `{ ok: false, error: "no layout
+ * provider active" }` failure the ABI documents.
+ */
+ postOps(workspaceId: string, ops: readonly LayoutOp[]): Promise<ApplyOpsResult>;
+}
+
+/** Read an error message from a non-2xx body (`{ error }`), tolerating junk. */
+async function errorOf(res: Response): Promise<string> {
+ try {
+ const body = (await res.json()) as { error?: unknown };
+ if (typeof body.error === "string" && body.error.length > 0) return body.error;
+ } catch {
+ // fall through
+ }
+ return `HTTP ${res.status}`;
+}
+
+/** A malformed 200 (e.g. an SPA-fallback page) degrades like a 404 — never poisons the store. */
+function malformed(): LayoutHttpResult {
+ return { ok: false, notFound: true, error: "Malformed layout response" };
+}
+
+export function createLayoutHttp(httpBase: string, fetchImpl: typeof fetch): LayoutHttp {
+ return {
+ async getTree(workspaceId): Promise<LayoutHttpResult> {
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/workspaces/${encodeURIComponent(workspaceId)}/layout`,
+ );
+ if (res.status === 404) {
+ return { ok: false, notFound: true, error: "No layout provider is active" };
+ }
+ if (!res.ok) return { ok: false, notFound: false, error: await errorOf(res) };
+ const body = (await res.json()) as { tree?: unknown };
+ if (body === null || typeof body !== "object" || body.tree === undefined)
+ return malformed();
+ return { ok: true, tree: body.tree as LayoutTree };
+ } catch (err) {
+ return {
+ ok: false,
+ notFound: true,
+ error: err instanceof Error ? err.message : "Layout request failed",
+ };
+ }
+ },
+
+ async putTree(workspaceId, tree): Promise<LayoutHttpResult> {
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/workspaces/${encodeURIComponent(workspaceId)}/layout`,
+ {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ tree }),
+ },
+ );
+ if (res.status === 404) {
+ return { ok: false, notFound: true, error: "No layout provider is active" };
+ }
+ if (!res.ok) return { ok: false, notFound: false, error: await errorOf(res) };
+ const body = (await res.json()) as { tree?: unknown };
+ if (body === null || typeof body !== "object" || body.tree === undefined)
+ return malformed();
+ return { ok: true, tree: body.tree as LayoutTree };
+ } catch (err) {
+ return {
+ ok: false,
+ notFound: true,
+ error: err instanceof Error ? err.message : "Layout request failed",
+ };
+ }
+ },
+
+ async deleteTree(workspaceId): Promise<LayoutHttpResult> {
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/workspaces/${encodeURIComponent(workspaceId)}/layout`,
+ { method: "DELETE" },
+ );
+ if (res.status === 404) {
+ return { ok: false, notFound: true, error: "No layout provider is active" };
+ }
+ if (!res.ok) return { ok: false, notFound: false, error: await errorOf(res) };
+ const body = (await res.json()) as { tree?: unknown };
+ if (body === null || typeof body !== "object" || body.tree === undefined)
+ return malformed();
+ return { ok: true, tree: body.tree as LayoutTree };
+ } catch (err) {
+ return {
+ ok: false,
+ notFound: true,
+ error: err instanceof Error ? err.message : "Layout request failed",
+ };
+ }
+ },
+
+ async postOps(workspaceId, ops): Promise<ApplyOpsResult> {
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/workspaces/${encodeURIComponent(workspaceId)}/layout/ops`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ ops }),
+ },
+ );
+ // The no-provider 404 is the ABI's typed signal (plan 04 §4.5).
+ if (res.status === 404) {
+ return { ok: false, error: "no layout provider active" };
+ }
+ if (!res.ok) return await opsError(res);
+ const body = (await res.json()) as { tree?: unknown; applied?: unknown };
+ if (body === null || typeof body !== "object" || body.tree === undefined)
+ return { ok: false, error: "Malformed layout response" };
+ return {
+ ok: true,
+ tree: body.tree as LayoutTree,
+ applied: typeof body.applied === "number" ? body.applied : ops.length,
+ };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Layout request failed",
+ };
+ }
+ },
+ };
+}
+
+/** Read an ops-failure body (`{ error, failedOp? }`), tolerating junk. */
+async function opsError(res: Response): Promise<ApplyOpsResult> {
+ try {
+ const body = (await res.json()) as { error?: unknown; failedOp?: unknown };
+ const error =
+ typeof body.error === "string" && body.error.length > 0 ? body.error : `HTTP ${res.status}`;
+ return {
+ ok: false,
+ error,
+ ...(typeof body.failedOp === "number" ? { failedOp: body.failedOp } : {}),
+ };
+ } catch {
+ return { ok: false, error: `HTTP ${res.status}` };
+ }
+}