From 49589dbc7acc8f50221610cdce80ebdbeae1501a Mon Sep 17 00:00:00 2001 From: James Gatz Date: Thu, 6 Aug 2026 09:08:56 +0200 Subject: [PATCH] chore: Phase 2c - Insomnia facade, consumed via React Router context Replaces the ad-hoc per-use-case bootstrap exports (one bound function per use-case) with a single Insomnia facade, namespaced by aggregate (insomnia.workspace.renameById(...)), constructed once and threaded through apps via dependency injection rather than imported piecemeal. - application/src/insomnia.ts - the Insomnia class + InsomniaDependencies interface. Framework-agnostic: takes repository ports (from domain), never a concrete infrastructure class, so application stays usable by any app. - application/src/workspace/workspace.module.ts - WorkspaceModule wraps the existing renameWorkspace use-case as a method. One module per aggregate; renameWorkspace itself is unchanged and still independently exported/unit-tested. - apps/desktop/src/common/application-bootstrap.ts - constructs the one Insomnia instance (wiring nedbWorkspaceRepository in), and defines the InsomniaContext React Router context token. The token lives here, not in application: react-router is a desktop-specific framework dependency, and application must stay usable by apps/cli and any future app that isn't built on react-router at all. - apps/desktop/src/entry.client.tsx - wires the Insomnia instance into via getContext, using RouterContextProvider. This works today under clientLoader/clientAction (SPA mode) because react-router.config.ts already has future.v8_middleware: true enabled - confirmed by reading react-router's own type definitions before building on it, since context/middleware is a relatively new, easy-to-get-wrong API. No SSR or Phase 6 dependency. - workspace.update.tsx - reads the facade via context.get(InsomniaContext).workspace.renameById(...) instead of importing a bound function. apps/cli has no router at all, so none of the context plumbing applies there - it would construct `new Insomnia(...)` once in its own bootstrap and call methods on it directly from command handlers, same as any other plain object. Verified: lint, type-check, check-boundaries, and the full test suite all pass clean (application now has 2 test files / 3 tests, including a dedicated test for the facade's delegation). Re-ran the actual desktop app via the project's Playwright/Electron smoke-test harness - dashboard-interactions.test.ts's rename test passed again, confirming the context wiring works end-to-end in the real running app, not just against fakes. --- application/src/index.ts | 1 + application/src/insomnia.test.ts | 48 +++++++++++++++++++ application/src/insomnia.ts | 27 +++++++++++ application/src/workspace/workspace.module.ts | 11 +++++ .../src/common/application-bootstrap.ts | 23 ++++++--- apps/desktop/src/entry.client.tsx | 4 +- ...Id.project.$projectId.workspace.update.tsx | 6 +-- 7 files changed, 109 insertions(+), 11 deletions(-) create mode 100644 application/src/insomnia.test.ts create mode 100644 application/src/insomnia.ts create mode 100644 application/src/workspace/workspace.module.ts diff --git a/application/src/index.ts b/application/src/index.ts index 302aede588eb..ce7270823eb8 100644 --- a/application/src/index.ts +++ b/application/src/index.ts @@ -2,4 +2,5 @@ // cross-cutting application-level ports (NetworkClient, TemplatingEngine, Clock, // IdGenerator, ...). Depends on domain only. // Populated incrementally, one aggregate at a time. +export { Insomnia, type InsomniaDependencies } from './insomnia'; export { renameWorkspace } from './workspace/rename-workspace.use-case'; diff --git a/application/src/insomnia.test.ts b/application/src/insomnia.test.ts new file mode 100644 index 000000000000..4c966d598b7b --- /dev/null +++ b/application/src/insomnia.test.ts @@ -0,0 +1,48 @@ +import type { Workspace, WorkspaceRepository } from 'insomnia-domain'; +import { describe, expect, it } from 'vitest'; + +import { Insomnia } from './insomnia'; + +function createFakeWorkspaceRepository(seed: Workspace[] = []): WorkspaceRepository { + const store = new Map(seed.map(w => [w._id, w])); + return { + async findById(id) { + return store.get(id) ?? null; + }, + async findByProjectId(projectId) { + return [...store.values()].filter(w => w.parentId === projectId); + }, + async save(workspace) { + store.set(workspace._id, workspace); + }, + async delete(id) { + store.delete(id); + }, + }; +} + +const buildWorkspace = (overrides: Partial = {}): Workspace => ({ + _id: 'wrk_1', + type: 'Workspace', + parentId: 'proj_1', + created: 0, + modified: 0, + isPrivate: false, + name: 'Original', + description: '', + scope: 'collection', + ...overrides, +}); + +describe('Insomnia', () => { + it('workspace.renameById() delegates to the injected WorkspaceRepository', async () => { + const workspace = buildWorkspace(); + const workspaceRepository = createFakeWorkspaceRepository([workspace]); + const insomnia = new Insomnia({ workspaceRepository }); + + const renamed = await insomnia.workspace.renameById(workspace._id, 'Renamed'); + + expect(renamed.name).toBe('Renamed'); + expect((await workspaceRepository.findById(workspace._id))?.name).toBe('Renamed'); + }); +}); diff --git a/application/src/insomnia.ts b/application/src/insomnia.ts new file mode 100644 index 000000000000..0f9f6d1d318f --- /dev/null +++ b/application/src/insomnia.ts @@ -0,0 +1,27 @@ +import type { WorkspaceRepository } from 'insomnia-domain'; + +import { WorkspaceModule } from './workspace/workspace.module'; + +/** + * Concrete infrastructure adapters this app-level facade needs. Each app's own bootstrap code + * constructs these (which repository implementation, which secret-storage adapter, etc.) and + * passes them in here - this interface only names *what* is needed, never a concrete class, so + * `application` stays dependency-free of `infrastructure`. + */ +export interface InsomniaDependencies { + workspaceRepository: WorkspaceRepository; +} + +/** + * The single entry point apps use to reach application use-cases, namespaced by aggregate + * (`insomnia.workspace`, `insomnia.request`, ...). One instance is constructed once, in each + * app's own bootstrap code, and threaded through from there (e.g. via React Router context) - + * never constructed per-call or scattered through routes/commands. + */ +export class Insomnia { + workspace: WorkspaceModule; + + constructor(dependencies: InsomniaDependencies) { + this.workspace = new WorkspaceModule(dependencies.workspaceRepository); + } +} diff --git a/application/src/workspace/workspace.module.ts b/application/src/workspace/workspace.module.ts new file mode 100644 index 000000000000..213541973ef6 --- /dev/null +++ b/application/src/workspace/workspace.module.ts @@ -0,0 +1,11 @@ +import type { WorkspaceRepository } from 'insomnia-domain'; + +import { renameWorkspace } from './rename-workspace.use-case'; + +export class WorkspaceModule { + constructor(private readonly workspaceRepository: WorkspaceRepository) {} + + renameById(id: string, name: string) { + return renameWorkspace(this.workspaceRepository, id, name); + } +} diff --git a/apps/desktop/src/common/application-bootstrap.ts b/apps/desktop/src/common/application-bootstrap.ts index fa6a2d9a42a7..535ce7c63b59 100644 --- a/apps/desktop/src/common/application-bootstrap.ts +++ b/apps/desktop/src/common/application-bootstrap.ts @@ -1,9 +1,18 @@ -// Wires concrete infrastructure adapters to application use-cases. Per the architecture plan, -// this kind of wiring belongs only in each app's own bootstrap code, never scattered through -// routes/commands. Location here is provisional - where each app's bootstrap code should live is -// still an open decision; this file exists to have exactly one place doing this wiring today. -import { renameWorkspace as renameWorkspaceUseCase } from 'application'; +// Wires concrete infrastructure adapters to the application-layer Insomnia facade. Per the +// architecture plan, this kind of wiring belongs only in each app's own bootstrap code, never +// scattered through routes/commands. Location here is provisional - where each app's bootstrap +// code should live is still an open decision; this file exists to have exactly one place doing +// this wiring today. +// +// The React Router context token (createContext) lives here rather than in `application`: +// react-router is a desktop-specific framework dependency, and `application` must stay usable by +// any app (apps/cli, future MCP/web apps) - only the Insomnia class itself belongs there. +import { Insomnia } from 'application'; import { nedbWorkspaceRepository } from 'infrastructure'; +import { createContext } from 'react-router'; -export const renameWorkspace = (workspaceId: string, name: string) => - renameWorkspaceUseCase(nedbWorkspaceRepository, workspaceId, name); +export const insomnia = new Insomnia({ + workspaceRepository: nedbWorkspaceRepository, +}); + +export const InsomniaContext = createContext(); diff --git a/apps/desktop/src/entry.client.tsx b/apps/desktop/src/entry.client.tsx index 3531655d6d3e..b936c1ace66a 100644 --- a/apps/desktop/src/entry.client.tsx +++ b/apps/desktop/src/entry.client.tsx @@ -5,6 +5,7 @@ import { configureFetch } from 'insomnia-api'; import { initDatabase, initServices, services } from 'insomnia-data'; import { startTransition, StrictMode } from 'react'; import { hydrateRoot } from 'react-dom/client'; +import { RouterContextProvider } from 'react-router'; import { HydratedRouter } from 'react-router/dom'; import { insomniaFetch } from '~/common/insomnia-fetch'; @@ -18,6 +19,7 @@ import { createServicesProxy } from '~/ui/services-proxy'; import { clearOAuthWindowSessionId } from '~/ui/spawn-oauth-window'; import { getInitialEntry } from '~/ui/utils/router'; +import { insomnia, InsomniaContext } from './common/application-bootstrap'; import { configureV3ClientDefaults } from './common/configure-v3-client'; import { getInsomniaSession, getInsomniaVaultKey, getInsomniaVaultSalt, getSkipOnboarding } from './common/constants'; import { HtmlElementWrapper } from './ui/components/html-element-wrapper'; @@ -156,7 +158,7 @@ startTransition(() => { hydrateRoot( document, - + new RouterContextProvider(new Map([[InsomniaContext, insomnia]]))} /> , ); }); diff --git a/apps/desktop/src/routes/organization.$organizationId.project.$projectId.workspace.update.tsx b/apps/desktop/src/routes/organization.$organizationId.project.$projectId.workspace.update.tsx index 48337070dc7a..1e9680ea12a3 100644 --- a/apps/desktop/src/routes/organization.$organizationId.project.$projectId.workspace.update.tsx +++ b/apps/desktop/src/routes/organization.$organizationId.project.$projectId.workspace.update.tsx @@ -1,7 +1,7 @@ import { models, services } from 'insomnia-data'; import { href } from 'react-router'; -import { renameWorkspace } from '~/common/application-bootstrap'; +import { InsomniaContext } from '~/common/application-bootstrap'; import { invariant } from '~/common/utils/invariant'; import { safeToUseInsomniaFileNameWithExt } from '~/sync/git/insomnia-filename'; import { AnalyticsEvent } from '~/ui/analytics'; @@ -17,7 +17,7 @@ interface WorkspacePatch { mockServerUrl?: string; } -export async function clientAction({ request }: Route.ClientActionArgs) { +export async function clientAction({ request, context }: Route.ClientActionArgs) { const patch = (await request.json()) as WorkspacePatch; const workspaceId = patch.workspaceId; invariant(typeof workspaceId === 'string', 'Workspace ID is required'); @@ -61,7 +61,7 @@ export async function clientAction({ request }: Route.ClientActionArgs) { patch.name = patch.name || workspace.name || (workspace.scope === 'collection' ? 'My Collection' : 'my-spec.yaml'); - await renameWorkspace(workspace._id, patch.name); + await context.get(InsomniaContext).workspace.renameById(workspace._id, patch.name); const project = await services.project.getById(workspace.parentId); invariant(project, 'Project not found');