diff --git a/apps/pwa/CLAUDE.md b/apps/pwa/CLAUDE.md index b5416b9cd..1e52a824f 100644 --- a/apps/pwa/CLAUDE.md +++ b/apps/pwa/CLAUDE.md @@ -12,6 +12,7 @@ Free PWA. Session-only by default; opt-in local persistence; education + trainin ## Invariants - **Architecture aligned with Azure per ADR-078** (same product, gated tiers): shared domain Zustand stores from `@variscout/stores` (`useProjectStore`, `useInvestigationStore`, `useImprovementStore`, `useSessionStore`, `useWallLayoutStore`, `useCanvasStore`); state shapes tier-agnostic, persistence tier-gated (Q8-revised: IndexedDB Hub-of-one + `.vrs`); tier-gated features check `isPaidTier()` at mount; shared orchestration components live in `@variscout/ui` with ~40 LOC route-shell per app. The "DataContext only, no Zustand" rule was retired by ADR-078. +- **Persistence boundary** (F1+F2 PR2): hub-blob writes flow through `pwaHubRepository` (`apps/pwa/src/persistence/`, module-scoped singleton implementing `@variscout/core/persistence#HubRepository`). Direct `hubRepository.{saveHub,loadHub}` outside `apps/pwa/src/persistence/` is the boundary contract (F2 PR3 will enforce with an ESLint rule); `getOptInFlag`/`setOptInFlag` are documented exceptions. `HUB_PERSIST_SNAPSHOT` is the bootstrap action — bypasses the "no active hub" guard. - Embedded mode supported for iframes (see flows in `docs/02-journeys/flows/pwa-education.md`). - Entry: `src/components/Dashboard.tsx`. Hosts the timeline-window picker (investigation-time, default `open-ended`; session-local in V1). diff --git a/apps/pwa/package.json b/apps/pwa/package.json index 301fcf529..c53b042a5 100644 --- a/apps/pwa/package.json +++ b/apps/pwa/package.json @@ -25,6 +25,7 @@ "d3-array": "^3.2.4", "dexie": "^4.4.2", "html-to-image": "^1.11.13", + "immer": "^11.1.4", "lucide-react": "^1.14.0", "react": "^19.2.5", "react-dom": "^19.2.5", diff --git a/apps/pwa/src/App.tsx b/apps/pwa/src/App.tsx index fc3bcebaf..f554b3bdf 100644 --- a/apps/pwa/src/App.tsx +++ b/apps/pwa/src/App.tsx @@ -31,6 +31,7 @@ import { SaveToBrowserButton } from './components/SaveToBrowserButton'; import { VrsExportButton } from './components/VrsExportButton'; import { SessionProvider, useSession } from './store/sessionStore'; import { hubRepository } from './db/hubRepository'; +import { pwaHubRepository } from './persistence'; import { Beaker, Settings, Download, Table2, RotateCcw, FileText } from 'lucide-react'; import { useFindings, @@ -161,7 +162,10 @@ function AppMain() { let cancelled = false; void hubRepository.getOptInFlag().then(async opted => { if (!opted || cancelled) return; - const loaded = await hubRepository.loadHub(); + // Load via repository pattern (P4.2). pwaHubRepository.hubs.list() returns + // [] or [hub]; no literal ID needed. hubRepository.getOptInFlag stays + // direct-call — no HubAction equivalent until F3 adds HubMetaAction. + const [loaded] = await pwaHubRepository.hubs.list(); if (loaded && !cancelled) setSessionHub(loaded); }); return () => { diff --git a/apps/pwa/src/components/SaveToBrowserButton.tsx b/apps/pwa/src/components/SaveToBrowserButton.tsx index 4139177f9..d81692229 100644 --- a/apps/pwa/src/components/SaveToBrowserButton.tsx +++ b/apps/pwa/src/components/SaveToBrowserButton.tsx @@ -1,7 +1,8 @@ // apps/pwa/src/components/SaveToBrowserButton.tsx import { useEffect, useState } from 'react'; import type { ProcessHub } from '@variscout/core/processHub'; -import { hubRepository } from '../db/hubRepository'; +import { hubRepository } from '../db/hubRepository'; // getOptInFlag / setOptInFlag only +import { pwaHubRepository } from '../persistence'; export interface SaveToBrowserButtonProps { currentHub: ProcessHub; @@ -18,7 +19,7 @@ export function SaveToBrowserButton({ currentHub }: SaveToBrowserButtonProps) { // Auto-save on Hub change once opted in useEffect(() => { if (optedIn) { - void hubRepository.saveHub(currentHub); + void pwaHubRepository.dispatch({ kind: 'HUB_PERSIST_SNAPSHOT', hub: currentHub }); } }, [optedIn, currentHub]); @@ -33,7 +34,7 @@ export function SaveToBrowserButton({ currentHub }: SaveToBrowserButtonProps) { onClick={async () => { setBusy(true); await hubRepository.setOptInFlag(true); - await hubRepository.saveHub(currentHub); + await pwaHubRepository.dispatch({ kind: 'HUB_PERSIST_SNAPSHOT', hub: currentHub }); setOptedIn(true); setBusy(false); }} diff --git a/apps/pwa/src/components/__tests__/SaveToBrowserButton.test.tsx b/apps/pwa/src/components/__tests__/SaveToBrowserButton.test.tsx index f2d351388..be023e849 100644 --- a/apps/pwa/src/components/__tests__/SaveToBrowserButton.test.tsx +++ b/apps/pwa/src/components/__tests__/SaveToBrowserButton.test.tsx @@ -4,6 +4,7 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { SaveToBrowserButton } from '../SaveToBrowserButton'; import { hubRepository } from '../../db/hubRepository'; +import { pwaHubRepository } from '../../persistence'; import { DEFAULT_PROCESS_HUB } from '@variscout/core/processHub'; const hub = { ...DEFAULT_PROCESS_HUB, processGoal: 'Test goal.' }; @@ -43,4 +44,20 @@ describe('SaveToBrowserButton', () => { await waitFor(async () => expect(await hubRepository.getOptInFlag()).toBe(false)); expect(await hubRepository.loadHub()).toBeNull(); }); + + it('clicking save routes through pwaHubRepository.dispatch with HUB_PERSIST_SNAPSHOT', async () => { + // Verifies the dispatch path is exercised — the write goes through + // pwaHubRepository.dispatch rather than hubRepository.saveHub directly. + const dispatchSpy = vi.spyOn(pwaHubRepository, 'dispatch'); + render(); + fireEvent.click(await screen.findByRole('button', { name: /save to this browser/i })); + await waitFor(() => expect(dispatchSpy).toHaveBeenCalled()); + expect(dispatchSpy).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'HUB_PERSIST_SNAPSHOT', + hub: expect.objectContaining({ processGoal: 'Test goal.' }), + }) + ); + dispatchSpy.mockRestore(); + }); }); diff --git a/apps/pwa/src/persistence/PwaHubRepository.ts b/apps/pwa/src/persistence/PwaHubRepository.ts new file mode 100644 index 000000000..fbe7e109a --- /dev/null +++ b/apps/pwa/src/persistence/PwaHubRepository.ts @@ -0,0 +1,175 @@ +// apps/pwa/src/persistence/PwaHubRepository.ts +// +// PWA persistence model: Hub-of-one blob only. +// The PWA stores exactly one row in IndexedDB — `{ id: 'hub-of-one', hub: ProcessHub }`. +// There are no per-entity tables. The grouped read APIs below serve data from +// that single hub blob; F3 will normalize into dedicated tables. + +import type { + HubRepository, + HubReadAPI, + OutcomeReadAPI, + EvidenceSnapshotReadAPI, + EvidenceSourceReadAPI, + InvestigationReadAPI, + FindingReadAPI, + QuestionReadAPI, + CausalLinkReadAPI, + SuspectedCauseReadAPI, + CanvasStateReadAPI, +} from '@variscout/core/persistence'; +import type { HubAction } from '@variscout/core/actions'; +import { hubRepository } from '../db/hubRepository'; +import { applyAction } from './applyAction'; + +export class PwaHubRepository implements HubRepository { + // --------------------------------------------------------------------------- + // Single write path + // --------------------------------------------------------------------------- + + async dispatch(action: HubAction): Promise { + // HUB_PERSIST_SNAPSHOT is the bootstrap/save path — the action carries the + // full hub blob, so no existing hub needs to be loaded first. This is the + // only action that can execute before a hub has been persisted (e.g. the + // first "Save to this browser" click). applyAction still handles this kind + // for purity over HubAction, but dispatch short-circuits to avoid the + // unnecessary load round-trip and to support the null-hub bootstrap case. + if (action.kind === 'HUB_PERSIST_SNAPSHOT') { + await hubRepository.saveHub(action.hub); + return; + } + const hub = await hubRepository.loadHub(); + if (!hub) { + throw new Error('No active hub to dispatch action against'); + } + const next = applyAction(hub, action); + await hubRepository.saveHub(next); + } + + // --------------------------------------------------------------------------- + // Read APIs — hubs + // --------------------------------------------------------------------------- + + hubs: HubReadAPI = { + async get(id) { + const hub = await hubRepository.loadHub(); + return hub?.id === id ? hub : undefined; + }, + async list() { + const hub = await hubRepository.loadHub(); + return hub ? [hub] : []; + }, + }; + + // --------------------------------------------------------------------------- + // Read APIs — outcomes + // Outcomes are hub-resident arrays; filter for live entries (deletedAt === null). + // --------------------------------------------------------------------------- + + outcomes: OutcomeReadAPI = { + async get(id) { + const hub = await hubRepository.loadHub(); + return hub?.outcomes?.find(o => o.id === id && o.deletedAt === null); + }, + async listByHub(hubId) { + const hub = await hubRepository.loadHub(); + if (!hub || hub.id !== hubId) return []; + return (hub.outcomes ?? []).filter(o => o.deletedAt === null); + }, + }; + + // --------------------------------------------------------------------------- + // Read APIs — canvas state + // canonicalProcessMap is the hub's canvas snapshot. + // --------------------------------------------------------------------------- + + canvasState: CanvasStateReadAPI = { + async getByHub(hubId) { + const hub = await hubRepository.loadHub(); + if (!hub || hub.id !== hubId) return undefined; + return hub.canonicalProcessMap; + }, + }; + + // --------------------------------------------------------------------------- + // Stub read APIs — entities not yet stored in PWA hub blob. + // PWA persists hub blob only; F3 normalizes these into dedicated tables. + // --------------------------------------------------------------------------- + + evidenceSnapshots: EvidenceSnapshotReadAPI = { + // PWA persists hub blob only; F3 normalizes evidenceSnapshots into a dedicated table. + async get(_id) { + return undefined; + }, + async listByHub(_hubId) { + return []; + }, + }; + + evidenceSources: EvidenceSourceReadAPI = { + // PWA persists hub blob only; F3 normalizes evidenceSources into a dedicated table. + async get(_id) { + return undefined; + }, + async listByHub(_hubId) { + return []; + }, + async getCursor(_hubId, _sourceId) { + return undefined; + }, + }; + + investigations: InvestigationReadAPI = { + // PWA persists hub blob only; investigations live in session-only Zustand store today. + async get(_id) { + return undefined; + }, + async listByHub(_hubId) { + return []; + }, + }; + + findings: FindingReadAPI = { + // PWA persists hub blob only; findings live in session-only Zustand store today. + async get(_id) { + return undefined; + }, + async listByInvestigation(_investigationId) { + return []; + }, + }; + + questions: QuestionReadAPI = { + // PWA persists hub blob only; questions live in session-only Zustand store today. + async get(_id) { + return undefined; + }, + async listByInvestigation(_investigationId) { + return []; + }, + }; + + causalLinks: CausalLinkReadAPI = { + // PWA persists hub blob only; causalLinks live in session-only Zustand store today. + async get(_id) { + return undefined; + }, + async listByInvestigation(_investigationId) { + return []; + }, + }; + + suspectedCauses: SuspectedCauseReadAPI = { + // PWA persists hub blob only; suspectedCauses live in session-only Zustand store today. + async get(_id) { + return undefined; + }, + async listByInvestigation(_investigationId) { + return []; + }, + }; +} + +// Module-scoped singleton. Composition root + dispatch boundary documented in apps/pwa/CLAUDE.md. +// Vitest module-mocking handles test override. +export const pwaHubRepository = new PwaHubRepository(); diff --git a/apps/pwa/src/persistence/__tests__/PwaHubRepository.test.ts b/apps/pwa/src/persistence/__tests__/PwaHubRepository.test.ts new file mode 100644 index 000000000..073ab875e --- /dev/null +++ b/apps/pwa/src/persistence/__tests__/PwaHubRepository.test.ts @@ -0,0 +1,505 @@ +// apps/pwa/src/persistence/__tests__/PwaHubRepository.test.ts +// +// Smoke tests for PwaHubRepository skeleton (P3.1). +// - dispatch error paths (no-hub, applyAction-not-implemented) +// - hubs.get / hubs.list happy + empty paths +// - outcomes.listByHub with live + tombstoned entries +// - canvasState.getByHub with matching + mismatched hubId +// - stub APIs return undefined / [] as documented +// +// Mocking strategy: +// vi.hoisted() ensures mock vars are available inside vi.mock factory closures. +// vi.mock() BEFORE subject imports — required per testing.md and MEMORY.md. +// vi is imported explicitly (globals:true gives runtime access; tsc needs the import). + +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; + +// vi.hoisted + vi.mock must appear before the subject imports so that vitest +// can hoist them above the import block at transform time. +const mocks = vi.hoisted(() => ({ + loadHub: vi.fn(), + saveHub: vi.fn(), +})); + +vi.mock('../../db/hubRepository', () => ({ + hubRepository: { + loadHub: mocks.loadHub, + saveHub: mocks.saveHub, + getOptInFlag: vi.fn(), + setOptInFlag: vi.fn(), + clearHub: vi.fn(), + clearAll: vi.fn(), + }, +})); + +import { PwaHubRepository } from '../PwaHubRepository'; +import type { ProcessHub, OutcomeSpec } from '@variscout/core/processHub'; +import type { ProcessMap } from '@variscout/core/frame'; +import type { HubAction } from '@variscout/core/actions'; + +// --------------------------------------------------------------------------- +// Minimal ProcessMap fixture (required fields only) +// --------------------------------------------------------------------------- + +const FIXTURE_MAP: ProcessMap = { + version: 1, + nodes: [], + tributaries: [], + createdAt: '2026-05-06T00:00:00.000Z', + updatedAt: '2026-05-06T00:00:00.000Z', +}; + +// --------------------------------------------------------------------------- +// Fixture helpers +// --------------------------------------------------------------------------- + +const NOW = 1_700_000_000_000; + +function makeHub(overrides: Partial = {}): ProcessHub { + return { + id: 'hub-of-one', + name: 'Test Hub', + createdAt: NOW, + deletedAt: null, + ...overrides, + }; +} + +function makeOutcome(id: string, deletedAt: number | null = null): OutcomeSpec { + return { + id, + hubId: 'hub-of-one', + columnName: 'fill_weight', + characteristicType: 'nominalIsBest', + createdAt: NOW, + deletedAt, + }; +} + +// Minimal stub action — kind does not matter for skeleton dispatch tests because +// applyAction will throw before reaching any switch branch. +const STUB_ACTION: HubAction = { + kind: 'OUTCOME_UPSERT', + payload: makeOutcome('outcome-1'), +} as unknown as HubAction; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('PwaHubRepository', () => { + let repo: PwaHubRepository; + + beforeEach(() => { + repo = new PwaHubRepository(); + mocks.loadHub.mockReset(); + mocks.saveHub.mockReset(); + }); + + // ---- dispatch ---- + + describe('dispatch', () => { + it('throws "No active hub" when loadHub returns null', async () => { + mocks.loadHub.mockResolvedValue(null); + await expect(repo.dispatch(STUB_ACTION)).rejects.toThrow( + 'No active hub to dispatch action against' + ); + }); + + it('calls saveHub with the next hub snapshot when dispatch succeeds', async () => { + const hub = makeHub(); + mocks.loadHub.mockResolvedValue(hub); + mocks.saveHub.mockResolvedValue(undefined); + // OUTCOME_UPDATE on a hub with no outcomes is a no-op that still saves + const action: HubAction = { + kind: 'OUTCOME_UPDATE', + outcomeId: 'nonexistent', + patch: { target: 5 }, + }; + await repo.dispatch(action); + expect(mocks.saveHub).toHaveBeenCalledOnce(); + // The saved hub should equal the input hub (OUTCOME_UPDATE on nonexistent id is a no-op) + expect(mocks.saveHub).toHaveBeenCalledWith(expect.objectContaining({ id: 'hub-of-one' })); + }); + + it('does not call saveHub when loadHub returns null', async () => { + mocks.loadHub.mockResolvedValue(null); + await expect(repo.dispatch(STUB_ACTION)).rejects.toThrow(); + expect(mocks.saveHub).not.toHaveBeenCalled(); + }); + }); + + // ---- hubs.get ---- + + describe('hubs.get', () => { + it('returns the hub when ids match', async () => { + const hub = makeHub({ id: 'hub-of-one' }); + mocks.loadHub.mockResolvedValue(hub); + const result = await repo.hubs.get('hub-of-one'); + expect(result).toEqual(hub); + }); + + it('returns undefined when ids do not match', async () => { + mocks.loadHub.mockResolvedValue(makeHub({ id: 'hub-of-one' })); + const result = await repo.hubs.get('different-id'); + expect(result).toBeUndefined(); + }); + + it('returns undefined when no hub is loaded', async () => { + mocks.loadHub.mockResolvedValue(null); + const result = await repo.hubs.get('hub-of-one'); + expect(result).toBeUndefined(); + }); + }); + + // ---- hubs.list ---- + + describe('hubs.list', () => { + it('returns [hub] when a hub is loaded', async () => { + const hub = makeHub(); + mocks.loadHub.mockResolvedValue(hub); + const result = await repo.hubs.list(); + expect(result).toEqual([hub]); + }); + + it('returns [] when no hub is loaded', async () => { + mocks.loadHub.mockResolvedValue(null); + const result = await repo.hubs.list(); + expect(result).toEqual([]); + }); + }); + + // ---- outcomes.listByHub ---- + + describe('outcomes.listByHub', () => { + it('returns only live outcomes (deletedAt === null) for matching hubId', async () => { + const live = makeOutcome('outcome-live', null); + const tombstoned = makeOutcome('outcome-dead', NOW); + mocks.loadHub.mockResolvedValue(makeHub({ outcomes: [live, tombstoned] })); + const result = await repo.outcomes.listByHub('hub-of-one'); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('outcome-live'); + }); + + it('returns [] when hubId does not match', async () => { + mocks.loadHub.mockResolvedValue(makeHub({ outcomes: [makeOutcome('outcome-1')] })); + const result = await repo.outcomes.listByHub('other-hub'); + expect(result).toEqual([]); + }); + + it('returns [] when hub has no outcomes array', async () => { + mocks.loadHub.mockResolvedValue(makeHub({ outcomes: undefined })); + const result = await repo.outcomes.listByHub('hub-of-one'); + expect(result).toEqual([]); + }); + + it('returns [] when no hub is loaded', async () => { + mocks.loadHub.mockResolvedValue(null); + const result = await repo.outcomes.listByHub('hub-of-one'); + expect(result).toEqual([]); + }); + }); + + // ---- outcomes.get ---- + + describe('outcomes.get', () => { + it('returns the matching live outcome', async () => { + const outcome = makeOutcome('outcome-1'); + mocks.loadHub.mockResolvedValue(makeHub({ outcomes: [outcome] })); + const result = await repo.outcomes.get('outcome-1'); + expect(result).toEqual(outcome); + }); + + it('returns undefined for a tombstoned outcome', async () => { + const tombstoned = makeOutcome('outcome-dead', NOW); + mocks.loadHub.mockResolvedValue(makeHub({ outcomes: [tombstoned] })); + const result = await repo.outcomes.get('outcome-dead'); + expect(result).toBeUndefined(); + }); + }); + + // ---- canvasState.getByHub ---- + + describe('canvasState.getByHub', () => { + it('returns canonicalProcessMap when hubId matches', async () => { + mocks.loadHub.mockResolvedValue(makeHub({ canonicalProcessMap: FIXTURE_MAP })); + const result = await repo.canvasState.getByHub('hub-of-one'); + expect(result).toEqual(FIXTURE_MAP); + }); + + it('returns undefined when hubId does not match', async () => { + mocks.loadHub.mockResolvedValue(makeHub({ canonicalProcessMap: FIXTURE_MAP })); + const result = await repo.canvasState.getByHub('other-hub'); + expect(result).toBeUndefined(); + }); + + it('returns undefined when hub has no canonicalProcessMap', async () => { + mocks.loadHub.mockResolvedValue(makeHub({ canonicalProcessMap: undefined })); + const result = await repo.canvasState.getByHub('hub-of-one'); + expect(result).toBeUndefined(); + }); + + it('returns undefined when no hub is loaded', async () => { + mocks.loadHub.mockResolvedValue(null); + const result = await repo.canvasState.getByHub('hub-of-one'); + expect(result).toBeUndefined(); + }); + }); + + // ---- dispatch end-to-end ---- + // + // These integration tests prove that dispatch() calls loadHub → applyAction → saveHub + // with the correct contract for each action category. + // + // applyAction.test.ts proves recipe correctness in isolation; + // these tests prove the wiring is intact through the repository layer. + + describe('dispatch end-to-end', () => { + // Freeze time for all tests in this block so Date.now()-dependent assertions are + // deterministic. Use the plan's reference timestamp: 2026-05-06T12:00:00.000Z. + const FROZEN_MS = new Date('2026-05-06T12:00:00.000Z').getTime(); + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-06T12:00:00.000Z')); + mocks.saveHub.mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('HUB_PERSIST_SNAPSHOT replaces the hub blob entirely (existing hub in store)', async () => { + // dispatch short-circuits for HUB_PERSIST_SNAPSHOT — loadHub is NOT called. + // The action payload IS the new hub; no load+merge round-trip needed. + const replacement = makeHub({ + id: 'hub-of-one', + name: 'New Hub', + processGoal: 'ship on time', + }); + + await repo.dispatch({ kind: 'HUB_PERSIST_SNAPSHOT', hub: replacement }); + + expect(mocks.loadHub).not.toHaveBeenCalled(); + expect(mocks.saveHub).toHaveBeenCalledOnce(); + const saved = mocks.saveHub.mock.calls[0][0] as ProcessHub; + expect(saved).toEqual(replacement); + }); + + it('HUB_PERSIST_SNAPSHOT works when no hub is loaded (bootstrap path)', async () => { + // First "Save to this browser" click: no hub in IndexedDB yet. dispatch must + // not throw even when loadHub would return null. + mocks.loadHub.mockResolvedValue(null); + const newHub = makeHub({ id: 'hub-of-one', name: 'Brand New Hub' }); + + await expect( + repo.dispatch({ kind: 'HUB_PERSIST_SNAPSHOT', hub: newHub }) + ).resolves.toBeUndefined(); + + expect(mocks.loadHub).not.toHaveBeenCalled(); + expect(mocks.saveHub).toHaveBeenCalledOnce(); + expect(mocks.saveHub).toHaveBeenCalledWith(newHub); + }); + + it('HUB_PERSIST_SNAPSHOT saves action.hub, not the previously loaded hub', async () => { + // Replacement semantics: even if a different hub is in the store, the + // action payload wins — no merge, no load. + const actionHub = makeHub({ id: 'hub-of-one', name: 'Action Hub', processGoal: 'new goal' }); + + await repo.dispatch({ kind: 'HUB_PERSIST_SNAPSHOT', hub: actionHub }); + + expect(mocks.saveHub).toHaveBeenCalledWith(actionHub); + }); + + it('HUB_UPDATE_GOAL persists the new processGoal and sets updatedAt', async () => { + const hub = makeHub({ id: 'hub-of-one', processGoal: 'old goal' }); + mocks.loadHub.mockResolvedValue(hub); + + await repo.dispatch({ + kind: 'HUB_UPDATE_GOAL', + hubId: 'hub-of-one', + processGoal: 'new goal', + }); + + expect(mocks.saveHub).toHaveBeenCalledOnce(); + const saved = mocks.saveHub.mock.calls[0][0] as ProcessHub; + expect(saved.processGoal).toBe('new goal'); + expect(saved.updatedAt).toBe(FROZEN_MS); + }); + + it('OUTCOME_ADD pushes the new outcome to the outcomes array', async () => { + const hub = makeHub({ id: 'hub-of-one', outcomes: [] }); + mocks.loadHub.mockResolvedValue(hub); + const newOutcome = makeOutcome('outcome-new'); + + await repo.dispatch({ + kind: 'OUTCOME_ADD', + hubId: 'hub-of-one', + outcome: newOutcome, + }); + + expect(mocks.saveHub).toHaveBeenCalledOnce(); + const saved = mocks.saveHub.mock.calls[0][0] as ProcessHub; + expect(saved.outcomes).toHaveLength(1); + expect(saved.outcomes![0].id).toBe('outcome-new'); + }); + + it('OUTCOME_UPDATE patches the target on the matching outcome', async () => { + const outcome = makeOutcome('outcome-1'); + const hub = makeHub({ id: 'hub-of-one', outcomes: [outcome] }); + mocks.loadHub.mockResolvedValue(hub); + + await repo.dispatch({ + kind: 'OUTCOME_UPDATE', + outcomeId: 'outcome-1', + patch: { target: 5 }, + }); + + expect(mocks.saveHub).toHaveBeenCalledOnce(); + const saved = mocks.saveHub.mock.calls[0][0] as ProcessHub; + expect(saved.outcomes![0].target).toBe(5); + }); + + it('OUTCOME_ARCHIVE soft-marks deletedAt on the matching outcome', async () => { + const outcome = makeOutcome('outcome-1', null); + const hub = makeHub({ id: 'hub-of-one', outcomes: [outcome] }); + mocks.loadHub.mockResolvedValue(hub); + + await repo.dispatch({ kind: 'OUTCOME_ARCHIVE', outcomeId: 'outcome-1' }); + + expect(mocks.saveHub).toHaveBeenCalledOnce(); + const saved = mocks.saveHub.mock.calls[0][0] as ProcessHub; + expect(saved.outcomes![0].deletedAt).toBe(FROZEN_MS); + }); + + it('OUTCOME_ARCHIVE is idempotent: second dispatch preserves the first deletedAt', async () => { + const outcome = makeOutcome('outcome-1', null); + const hub = makeHub({ id: 'hub-of-one', outcomes: [outcome] }); + + // First dispatch — captures FROZEN_MS as deletedAt + mocks.loadHub.mockResolvedValue(hub); + await repo.dispatch({ kind: 'OUTCOME_ARCHIVE', outcomeId: 'outcome-1' }); + const savedFirst = mocks.saveHub.mock.calls[0][0] as ProcessHub; + const firstDeletedAt = savedFirst.outcomes![0].deletedAt; + expect(firstDeletedAt).toBe(FROZEN_MS); + + // Advance frozen clock so a second mutation would use a different timestamp + vi.advanceTimersByTime(30_000); + + // Second dispatch — loadHub returns the already-archived hub from the first call + mocks.saveHub.mockReset(); + mocks.saveHub.mockResolvedValue(undefined); + mocks.loadHub.mockResolvedValue(savedFirst); + await repo.dispatch({ kind: 'OUTCOME_ARCHIVE', outcomeId: 'outcome-1' }); + + expect(mocks.saveHub).toHaveBeenCalledOnce(); + const savedSecond = mocks.saveHub.mock.calls[0][0] as ProcessHub; + // deletedAt must not be overwritten by the second dispatch + expect(savedSecond.outcomes![0].deletedAt).toBe(firstDeletedAt); + }); + + it('INVESTIGATION_ARCHIVE is a no-op for the PWA blob (investigations are session-only)', async () => { + // Contract: INVESTIGATION_ARCHIVE dispatches successfully but does not mutate + // the hub blob on PWA, because investigations are not blob-resident (F3 normalizes). + // The hub is saved unchanged (same reference equality via Immer structural sharing + // is not guaranteed, but the shape must be deep-equal to the input). + const outcome = makeOutcome('outcome-1', null); + const hub = makeHub({ id: 'hub-of-one', outcomes: [outcome] }); + mocks.loadHub.mockResolvedValue(hub); + + await repo.dispatch({ kind: 'INVESTIGATION_ARCHIVE', investigationId: 'inv-1' }); + + expect(mocks.saveHub).toHaveBeenCalledOnce(); + const saved = mocks.saveHub.mock.calls[0][0] as ProcessHub; + // Hub shape is unchanged — no outcome mutations because investigation cascade + // finds no blob-resident children (hub→outcome only cascades when parentKind='hub'). + expect(saved.outcomes).toHaveLength(1); + expect(saved.outcomes![0].deletedAt).toBeNull(); + }); + + it('PLACE_CHIP_ON_STEP is a no-op for the PWA blob (canvas mutations live in canvasStore, R15)', async () => { + // Contract: canvas actions dispatch successfully but do not mutate the hub blob. + // Canvas state is owned by canvasStore; the blob is overwritten via + // HUB_PERSIST_SNAPSHOT when the user saves. See R15 in applyAction.ts. + const hub = makeHub({ id: 'hub-of-one', outcomes: [makeOutcome('outcome-1', null)] }); + mocks.loadHub.mockResolvedValue(hub); + + await repo.dispatch({ kind: 'PLACE_CHIP_ON_STEP', chipId: 'chip-1', stepId: 'step-1' }); + + expect(mocks.saveHub).toHaveBeenCalledOnce(); + const saved = mocks.saveHub.mock.calls[0][0] as ProcessHub; + // Hub blob is unchanged — canvas is not a blob-resident structure on PWA. + expect(saved).toMatchObject({ id: 'hub-of-one' }); + expect(saved.outcomes).toHaveLength(1); + expect(saved.outcomes![0].deletedAt).toBeNull(); + }); + }); + + // ---- stub APIs ---- + + describe('stub read APIs (F3 not yet implemented)', () => { + beforeEach(() => { + mocks.loadHub.mockResolvedValue(makeHub()); + }); + + it('evidenceSnapshots.get returns undefined', async () => { + expect(await repo.evidenceSnapshots.get('any')).toBeUndefined(); + }); + + it('evidenceSnapshots.listByHub returns []', async () => { + expect(await repo.evidenceSnapshots.listByHub('hub-of-one')).toEqual([]); + }); + + it('evidenceSources.get returns undefined', async () => { + expect(await repo.evidenceSources.get('any')).toBeUndefined(); + }); + + it('evidenceSources.listByHub returns []', async () => { + expect(await repo.evidenceSources.listByHub('hub-of-one')).toEqual([]); + }); + + it('evidenceSources.getCursor returns undefined', async () => { + expect(await repo.evidenceSources.getCursor('hub-of-one', 'src-1')).toBeUndefined(); + }); + + it('investigations.get returns undefined', async () => { + expect(await repo.investigations.get('any')).toBeUndefined(); + }); + + it('investigations.listByHub returns []', async () => { + expect(await repo.investigations.listByHub('hub-of-one')).toEqual([]); + }); + + it('findings.get returns undefined', async () => { + expect(await repo.findings.get('any')).toBeUndefined(); + }); + + it('findings.listByInvestigation returns []', async () => { + expect(await repo.findings.listByInvestigation('inv-1')).toEqual([]); + }); + + it('questions.get returns undefined', async () => { + expect(await repo.questions.get('any')).toBeUndefined(); + }); + + it('questions.listByInvestigation returns []', async () => { + expect(await repo.questions.listByInvestigation('inv-1')).toEqual([]); + }); + + it('causalLinks.get returns undefined', async () => { + expect(await repo.causalLinks.get('any')).toBeUndefined(); + }); + + it('causalLinks.listByInvestigation returns []', async () => { + expect(await repo.causalLinks.listByInvestigation('inv-1')).toEqual([]); + }); + + it('suspectedCauses.get returns undefined', async () => { + expect(await repo.suspectedCauses.get('any')).toBeUndefined(); + }); + + it('suspectedCauses.listByInvestigation returns []', async () => { + expect(await repo.suspectedCauses.listByInvestigation('inv-1')).toEqual([]); + }); + }); +}); diff --git a/apps/pwa/src/persistence/__tests__/applyAction.test.ts b/apps/pwa/src/persistence/__tests__/applyAction.test.ts new file mode 100644 index 000000000..f65bf24b6 --- /dev/null +++ b/apps/pwa/src/persistence/__tests__/applyAction.test.ts @@ -0,0 +1,638 @@ +// apps/pwa/src/persistence/__tests__/applyAction.test.ts +// +// Unit tests for applyAction — per-action Immer recipe dispatcher. +// +// Coverage: +// - Each hub-resident action produces the expected diff +// - OUTCOME_ARCHIVE soft-marks deletedAt; idempotent if already archived +// - Non-hub-resident actions return the input hub unchanged (deep-equal) +// - HUB_PERSIST_SNAPSHOT returns action.hub directly (reference equality) +// - Canvas actions return input hub unchanged (canvasStore is canonical) +// - assertNever fires for unhandled actions (TypeScript prevents this in prod) +// - Cascade helpers via transitiveCascade (P3.3): +// hub→outcome cascade soft-marks all hub outcomes (only observable path on PWA blob) +// investigation/finding/question/causalLink/suspectedCause archive actions are no-ops +// hub→outcome cascade is idempotent (already-archived outcomes keep original timestamp) +// +// Determinism: vi.useFakeTimers + vi.setSystemTime pins Date.now() to a fixed value. + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { produce } from 'immer'; +import { applyAction, cascadeArchiveDescendantsInDraft } from '../applyAction'; +import type { ProcessHub, OutcomeSpec } from '@variscout/core/processHub'; +import type { OutcomeAction, HubMetaAction, CanvasAction } from '@variscout/core/actions'; + +// --------------------------------------------------------------------------- +// Fixed time for deterministic Date.now() assertions +// --------------------------------------------------------------------------- + +const FIXED_NOW = new Date('2026-05-06T12:00:00.000Z'); +const FIXED_NOW_MS = FIXED_NOW.getTime(); + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const BASE_HUB: ProcessHub = { + id: 'hub-001', + name: 'Test Hub', + createdAt: 1_000_000, + deletedAt: null, + processGoal: 'Reduce cycle time', + primaryScopeDimensions: ['shift'], + outcomes: [], +}; + +const BASE_OUTCOME: OutcomeSpec = { + id: 'outcome-001', + hubId: 'hub-001', + createdAt: 1_000_000, + deletedAt: null, + columnName: 'cycle_time', + characteristicType: 'smallerIsBetter', + target: 10, +}; + +const HUB_WITH_OUTCOMES: ProcessHub = { + ...BASE_HUB, + outcomes: [{ ...BASE_OUTCOME }], +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Deep clone a plain object (fixture safety — prevents draft mutations leaking). */ +function clone(obj: T): T { + return JSON.parse(JSON.stringify(obj)) as T; +} + +// --------------------------------------------------------------------------- +// Setup / teardown +// --------------------------------------------------------------------------- + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(FIXED_NOW); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +// --------------------------------------------------------------------------- +// HUB_PERSIST_SNAPSHOT +// --------------------------------------------------------------------------- + +describe('HUB_PERSIST_SNAPSHOT', () => { + it('returns action.hub directly (reference equality)', () => { + const replacement = clone({ ...BASE_HUB, name: 'Replaced Hub' }); + const action: HubMetaAction = { kind: 'HUB_PERSIST_SNAPSHOT', hub: replacement }; + const result = applyAction(clone(BASE_HUB), action); + expect(result).toBe(replacement); // reference equality — bypasses produce + }); + + it('discards any prior hub state', () => { + const replacement: ProcessHub = { + ...BASE_HUB, + processGoal: 'New goal', + outcomes: [{ ...BASE_OUTCOME, columnName: 'throughput' }], + }; + const action: HubMetaAction = { kind: 'HUB_PERSIST_SNAPSHOT', hub: replacement }; + const result = applyAction(clone(HUB_WITH_OUTCOMES), action); + expect(result.processGoal).toBe('New goal'); + expect(result.outcomes).toHaveLength(1); + expect(result.outcomes![0].columnName).toBe('throughput'); + }); +}); + +// --------------------------------------------------------------------------- +// HUB_UPDATE_GOAL +// --------------------------------------------------------------------------- + +describe('HUB_UPDATE_GOAL', () => { + it('sets processGoal and bumps updatedAt', () => { + const action: HubMetaAction = { + kind: 'HUB_UPDATE_GOAL', + hubId: 'hub-001', + processGoal: 'Eliminate defects', + }; + const result = applyAction(clone(BASE_HUB), action); + expect(result.processGoal).toBe('Eliminate defects'); + expect(result.updatedAt).toBe(FIXED_NOW_MS); + }); + + it('is a no-op when hubId does not match', () => { + const action: HubMetaAction = { + kind: 'HUB_UPDATE_GOAL', + hubId: 'different-hub', + processGoal: 'Should not apply', + }; + const input = clone(BASE_HUB); + const result = applyAction(input, action); + expect(result.processGoal).toBe('Reduce cycle time'); + expect(result.updatedAt).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// HUB_UPDATE_PRIMARY_SCOPE_DIMENSIONS +// --------------------------------------------------------------------------- + +describe('HUB_UPDATE_PRIMARY_SCOPE_DIMENSIONS', () => { + it('sets primaryScopeDimensions and bumps updatedAt', () => { + const action: HubMetaAction = { + kind: 'HUB_UPDATE_PRIMARY_SCOPE_DIMENSIONS', + hubId: 'hub-001', + dimensions: ['shift', 'line'], + }; + const result = applyAction(clone(BASE_HUB), action); + expect(result.primaryScopeDimensions).toEqual(['shift', 'line']); + expect(result.updatedAt).toBe(FIXED_NOW_MS); + }); + + it('is a no-op when hubId does not match', () => { + const action: HubMetaAction = { + kind: 'HUB_UPDATE_PRIMARY_SCOPE_DIMENSIONS', + hubId: 'different-hub', + dimensions: ['should-not-apply'], + }; + const input = clone(BASE_HUB); + const result = applyAction(input, action); + expect(result.primaryScopeDimensions).toEqual(['shift']); + expect(result.updatedAt).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// OUTCOME_ADD +// --------------------------------------------------------------------------- + +describe('OUTCOME_ADD', () => { + it('pushes outcome into outcomes array', () => { + const action: OutcomeAction = { + kind: 'OUTCOME_ADD', + hubId: 'hub-001', + outcome: { ...BASE_OUTCOME, id: 'outcome-002', columnName: 'defect_rate' }, + }; + const input = clone({ ...BASE_HUB, outcomes: [] }); + const result = applyAction(input, action); + expect(result.outcomes).toHaveLength(1); + expect(result.outcomes![0].columnName).toBe('defect_rate'); + }); + + it('initializes outcomes array if undefined', () => { + const hubWithoutOutcomes: ProcessHub = { + id: 'hub-001', + name: 'Test Hub', + createdAt: 1_000_000, + deletedAt: null, + // outcomes intentionally absent + }; + const action: OutcomeAction = { + kind: 'OUTCOME_ADD', + hubId: 'hub-001', + outcome: { ...BASE_OUTCOME }, + }; + const result = applyAction(clone(hubWithoutOutcomes), action); + expect(result.outcomes).toHaveLength(1); + expect(result.outcomes![0].id).toBe('outcome-001'); + }); + + it('throws when hubId mismatches (hub-of-one constraint)', () => { + const action: OutcomeAction = { + kind: 'OUTCOME_ADD', + hubId: 'different-hub', + outcome: { ...BASE_OUTCOME }, + }; + expect(() => applyAction(clone(BASE_HUB), action)).toThrow('OUTCOME_ADD hubId mismatch'); + }); +}); + +// --------------------------------------------------------------------------- +// OUTCOME_UPDATE +// --------------------------------------------------------------------------- + +describe('OUTCOME_UPDATE', () => { + it('merges patch into matching outcome', () => { + const action: OutcomeAction = { + kind: 'OUTCOME_UPDATE', + outcomeId: 'outcome-001', + patch: { target: 8, usl: 15 }, + }; + const result = applyAction(clone(HUB_WITH_OUTCOMES), action); + const updated = result.outcomes!.find(o => o.id === 'outcome-001')!; + expect(updated.target).toBe(8); + expect(updated.usl).toBe(15); + expect(updated.columnName).toBe('cycle_time'); // unchanged + }); + + it('is a no-op when outcomeId is not found', () => { + const action: OutcomeAction = { + kind: 'OUTCOME_UPDATE', + outcomeId: 'nonexistent', + patch: { target: 99 }, + }; + const input = clone(HUB_WITH_OUTCOMES); + const result = applyAction(input, action); + expect(result.outcomes![0].target).toBe(10); // unchanged + }); +}); + +// --------------------------------------------------------------------------- +// OUTCOME_ARCHIVE +// --------------------------------------------------------------------------- + +describe('OUTCOME_ARCHIVE', () => { + it('soft-marks deletedAt on the matching outcome', () => { + const action: OutcomeAction = { + kind: 'OUTCOME_ARCHIVE', + outcomeId: 'outcome-001', + }; + const result = applyAction(clone(HUB_WITH_OUTCOMES), action); + const archived = result.outcomes!.find(o => o.id === 'outcome-001')!; + expect(archived.deletedAt).toBe(FIXED_NOW_MS); + }); + + it('is idempotent — already-archived outcome stays unchanged', () => { + const alreadyArchived: ProcessHub = { + ...BASE_HUB, + outcomes: [{ ...BASE_OUTCOME, deletedAt: 999_999 }], + }; + const action: OutcomeAction = { + kind: 'OUTCOME_ARCHIVE', + outcomeId: 'outcome-001', + }; + const result = applyAction(clone(alreadyArchived), action); + const outcome = result.outcomes!.find(o => o.id === 'outcome-001')!; + expect(outcome.deletedAt).toBe(999_999); // preserved — not overwritten + }); + + it('is a no-op when outcomeId is not found', () => { + const action: OutcomeAction = { + kind: 'OUTCOME_ARCHIVE', + outcomeId: 'nonexistent', + }; + const input = clone(HUB_WITH_OUTCOMES); + const result = applyAction(input, action); + expect(result.outcomes![0].deletedAt).toBeNull(); // unchanged + }); +}); + +// --------------------------------------------------------------------------- +// Non-hub-resident actions — return hub unchanged (deep-equal) +// +// Each entity type (Investigation, Finding, Question, CausalLink, SuspectedCause, +// Evidence, EvidenceSource) has required fields beyond EntityBase that are not +// relevant to the no-op behavior being tested here. We cast with `as unknown as +// HubAction` to avoid building out full entity fixtures — the hub blob doesn't +// use these fields anyway (they live in session-only Zustand stores). +// --------------------------------------------------------------------------- + +describe('Non-hub-resident actions return hub unchanged', () => { + // Helper type alias for clarity + type A = Parameters[1]; + + const noopCases: Array<{ label: string; action: A }> = [ + { + label: 'INVESTIGATION_CREATE', + action: { + kind: 'INVESTIGATION_CREATE', + hubId: 'hub-001', + investigation: { id: 'inv-001', createdAt: 0, deletedAt: null, name: 'I', updatedAt: 0 }, + } as unknown as A, + }, + { + label: 'INVESTIGATION_UPDATE_METADATA', + action: { kind: 'INVESTIGATION_UPDATE_METADATA', investigationId: 'inv-001', patch: {} } as A, + }, + { + label: 'INVESTIGATION_ARCHIVE', + action: { kind: 'INVESTIGATION_ARCHIVE', investigationId: 'inv-001' } as A, + }, + { + label: 'FINDING_ADD', + action: { + kind: 'FINDING_ADD', + investigationId: 'inv-001', + finding: { id: 'f-001', createdAt: 0, deletedAt: null }, + } as unknown as A, + }, + { + label: 'FINDING_UPDATE', + action: { kind: 'FINDING_UPDATE', findingId: 'f-001', patch: {} } as A, + }, + { + label: 'FINDING_ARCHIVE', + action: { kind: 'FINDING_ARCHIVE', findingId: 'f-001' } as A, + }, + { + label: 'QUESTION_ADD', + action: { + kind: 'QUESTION_ADD', + investigationId: 'inv-001', + question: { id: 'q-001', createdAt: 0, deletedAt: null }, + } as unknown as A, + }, + { + label: 'QUESTION_UPDATE', + action: { kind: 'QUESTION_UPDATE', questionId: 'q-001', patch: {} } as A, + }, + { + label: 'QUESTION_ARCHIVE', + action: { kind: 'QUESTION_ARCHIVE', questionId: 'q-001' } as A, + }, + { + label: 'CAUSAL_LINK_ADD', + action: { + kind: 'CAUSAL_LINK_ADD', + investigationId: 'inv-001', + link: { id: 'cl-001', createdAt: 0, deletedAt: null }, + } as unknown as A, + }, + { + label: 'CAUSAL_LINK_UPDATE', + action: { kind: 'CAUSAL_LINK_UPDATE', linkId: 'cl-001', patch: {} } as A, + }, + { + label: 'CAUSAL_LINK_ARCHIVE', + action: { kind: 'CAUSAL_LINK_ARCHIVE', linkId: 'cl-001' } as A, + }, + { + label: 'SUSPECTED_CAUSE_ADD', + action: { + kind: 'SUSPECTED_CAUSE_ADD', + investigationId: 'inv-001', + cause: { id: 'sc-001', createdAt: 0, deletedAt: null }, + } as unknown as A, + }, + { + label: 'SUSPECTED_CAUSE_UPDATE', + action: { kind: 'SUSPECTED_CAUSE_UPDATE', causeId: 'sc-001', patch: {} } as A, + }, + { + label: 'SUSPECTED_CAUSE_ARCHIVE', + action: { kind: 'SUSPECTED_CAUSE_ARCHIVE', causeId: 'sc-001' } as A, + }, + { + label: 'EVIDENCE_ADD_SNAPSHOT', + action: { + kind: 'EVIDENCE_ADD_SNAPSHOT', + hubId: 'hub-001', + snapshot: { id: 'es-001', createdAt: 0, deletedAt: null }, + provenance: [], + } as unknown as A, + }, + { + label: 'EVIDENCE_ARCHIVE_SNAPSHOT', + action: { kind: 'EVIDENCE_ARCHIVE_SNAPSHOT', snapshotId: 'es-001' } as A, + }, + { + label: 'EVIDENCE_SOURCE_ADD', + action: { + kind: 'EVIDENCE_SOURCE_ADD', + hubId: 'hub-001', + source: { id: 'src-001', createdAt: 0, deletedAt: null }, + } as unknown as A, + }, + { + label: 'EVIDENCE_SOURCE_UPDATE_CURSOR', + action: { + kind: 'EVIDENCE_SOURCE_UPDATE_CURSOR', + sourceId: 'src-001', + cursor: {}, + } as unknown as A, + }, + { + label: 'EVIDENCE_SOURCE_REMOVE', + action: { kind: 'EVIDENCE_SOURCE_REMOVE', sourceId: 'src-001' } as A, + }, + ]; + + for (const { label, action } of noopCases) { + it(`${label} — hub returned deep-equal to input`, () => { + const input = clone(HUB_WITH_OUTCOMES); + const result = applyAction(input, action); + expect(result).toEqual(input); + }); + } +}); + +// --------------------------------------------------------------------------- +// Canvas actions — no-ops (canvasStore is the canonical mutation surface) +// --------------------------------------------------------------------------- + +describe('Canvas actions return hub unchanged', () => { + const canvasCases: Array<{ label: string; action: CanvasAction }> = [ + { + label: 'PLACE_CHIP_ON_STEP', + action: { kind: 'PLACE_CHIP_ON_STEP', chipId: 'c1', stepId: 's1' }, + }, + { label: 'UNASSIGN_CHIP', action: { kind: 'UNASSIGN_CHIP', chipId: 'c1' } }, + { + label: 'REORDER_CHIP_IN_STEP', + action: { kind: 'REORDER_CHIP_IN_STEP', chipId: 'c1', stepId: 's1', toIndex: 0 }, + }, + { label: 'ADD_STEP', action: { kind: 'ADD_STEP', stepName: 'New Step' } }, + { label: 'REMOVE_STEP', action: { kind: 'REMOVE_STEP', stepId: 's1' } }, + { label: 'RENAME_STEP', action: { kind: 'RENAME_STEP', stepId: 's1', newName: 'Renamed' } }, + { label: 'CONNECT_STEPS', action: { kind: 'CONNECT_STEPS', fromStepId: 's1', toStepId: 's2' } }, + { + label: 'DISCONNECT_STEPS', + action: { kind: 'DISCONNECT_STEPS', fromStepId: 's1', toStepId: 's2' }, + }, + { + label: 'GROUP_INTO_SUB_STEP', + action: { kind: 'GROUP_INTO_SUB_STEP', stepIds: ['s1', 's2'], parentStepId: 'p1' }, + }, + { label: 'UNGROUP_SUB_STEP', action: { kind: 'UNGROUP_SUB_STEP', stepId: 's1' } }, + ]; + + for (const { label, action } of canvasCases) { + it(`${label} — hub returned deep-equal to input`, () => { + const input = clone(HUB_WITH_OUTCOMES); + const result = applyAction(input, action as Parameters[1]); + expect(result).toEqual(input); + }); + } +}); + +// --------------------------------------------------------------------------- +// Input immutability — hub is not mutated in place +// --------------------------------------------------------------------------- + +describe('Input immutability', () => { + it('does not mutate the input hub (OUTCOME_ADD)', () => { + const action: OutcomeAction = { + kind: 'OUTCOME_ADD', + hubId: 'hub-001', + outcome: { ...BASE_OUTCOME, id: 'outcome-002', columnName: 'throughput' }, + }; + const input = clone({ ...BASE_HUB, outcomes: [] }); + const inputCopy = clone(input); + applyAction(input, action); + expect(input).toEqual(inputCopy); // input unchanged + }); + + it('does not mutate the input hub (OUTCOME_ARCHIVE)', () => { + const action: OutcomeAction = { kind: 'OUTCOME_ARCHIVE', outcomeId: 'outcome-001' }; + const input = clone(HUB_WITH_OUTCOMES); + const inputCopy = clone(input); + applyAction(input, action); + expect(input).toEqual(inputCopy); // input unchanged + }); +}); + +// --------------------------------------------------------------------------- +// Cascade helpers (P3.3) — cascadeArchiveDescendantsInDraft +// +// PWA cascade paths: +// hub → outcome OBSERVABLE: only PWA-blob-resident cascade target +// investigation → … NO-OP: findings/questions/causalLinks/suspectedCauses +// not on blob +// evidenceSnapshot → … NO-OP: rowProvenance not on blob +// evidenceSource → … NO-OP: evidenceSourceCursor not on blob +// All leaf kinds (outcome, finding, question, etc.) → empty cascade (no-ops) +// --------------------------------------------------------------------------- + +const OUTCOME_A: OutcomeSpec = { ...BASE_OUTCOME, id: 'outcome-a', deletedAt: null }; +const OUTCOME_B: OutcomeSpec = { ...BASE_OUTCOME, id: 'outcome-b', deletedAt: null }; + +const HUB_TWO_OUTCOMES: ProcessHub = { + ...BASE_HUB, + outcomes: [clone(OUTCOME_A), clone(OUTCOME_B)], +}; + +describe('cascadeArchiveDescendantsInDraft — hub → outcome (observable cascade)', () => { + it('soft-marks all hub outcomes when parentKind is hub and parentId matches', () => { + const result = produce(clone(HUB_TWO_OUTCOMES), draft => { + cascadeArchiveDescendantsInDraft(draft, 'hub', 'hub-001', FIXED_NOW_MS); + }); + expect(result.outcomes![0].deletedAt).toBe(FIXED_NOW_MS); + expect(result.outcomes![1].deletedAt).toBe(FIXED_NOW_MS); + }); + + it('skips outcomes that are already archived (idempotent)', () => { + const PRIOR_ARCHIVE_TS = 999_000; + const hubWithOneArchived: ProcessHub = { + ...BASE_HUB, + outcomes: [ + { ...OUTCOME_A, deletedAt: PRIOR_ARCHIVE_TS }, // already archived + { ...OUTCOME_B, deletedAt: null }, // not yet archived + ], + }; + const result = produce(clone(hubWithOneArchived), draft => { + cascadeArchiveDescendantsInDraft(draft, 'hub', 'hub-001', FIXED_NOW_MS); + }); + // Already-archived outcome keeps original timestamp + expect(result.outcomes![0].deletedAt).toBe(PRIOR_ARCHIVE_TS); + // Unarchived outcome gets new timestamp + expect(result.outcomes![1].deletedAt).toBe(FIXED_NOW_MS); + }); + + it('idempotency — applying cascade twice leaves timestamps unchanged', () => { + const FIRST_ARCHIVE_TS = FIXED_NOW_MS; + // First pass + const afterFirst = produce(clone(HUB_TWO_OUTCOMES), draft => { + cascadeArchiveDescendantsInDraft(draft, 'hub', 'hub-001', FIRST_ARCHIVE_TS); + }); + expect(afterFirst.outcomes![0].deletedAt).toBe(FIRST_ARCHIVE_TS); + expect(afterFirst.outcomes![1].deletedAt).toBe(FIRST_ARCHIVE_TS); + + // Second pass with a different timestamp — already-archived outcomes are skipped + const SECOND_ARCHIVE_TS = FIXED_NOW_MS + 5_000; + const afterSecond = produce(afterFirst, draft => { + cascadeArchiveDescendantsInDraft(draft, 'hub', 'hub-001', SECOND_ARCHIVE_TS); + }); + expect(afterSecond.outcomes![0].deletedAt).toBe(FIRST_ARCHIVE_TS); // unchanged + expect(afterSecond.outcomes![1].deletedAt).toBe(FIRST_ARCHIVE_TS); // unchanged + }); + + it('is a no-op when parentId does not match hub.id', () => { + const result = produce(clone(HUB_TWO_OUTCOMES), draft => { + cascadeArchiveDescendantsInDraft(draft, 'hub', 'different-hub-id', FIXED_NOW_MS); + }); + expect(result.outcomes![0].deletedAt).toBeNull(); + expect(result.outcomes![1].deletedAt).toBeNull(); + }); + + it('is a no-op when hub has no outcomes', () => { + const result = produce(clone(BASE_HUB), draft => { + cascadeArchiveDescendantsInDraft(draft, 'hub', 'hub-001', FIXED_NOW_MS); + }); + // No crash; outcomes still undefined/empty + expect(result.outcomes ?? []).toHaveLength(0); + }); +}); + +describe('cascadeArchiveDescendantsInDraft — non-hub parents are no-ops on PWA blob', () => { + // For each of these parent kinds, the transitive cascade descendants are not + // resident on the PWA blob, so the hub should be returned deep-equal to input. + + const noopParents = [ + { parentKind: 'investigation' as const, parentId: 'inv-001' }, + { parentKind: 'finding' as const, parentId: 'f-001' }, + { parentKind: 'question' as const, parentId: 'q-001' }, + { parentKind: 'causalLink' as const, parentId: 'cl-001' }, + { parentKind: 'suspectedCause' as const, parentId: 'sc-001' }, + { parentKind: 'evidenceSnapshot' as const, parentId: 'snap-001' }, + { parentKind: 'evidenceSource' as const, parentId: 'src-001' }, + { parentKind: 'outcome' as const, parentId: 'outcome-001' }, // leaf — cascadesTo = [] + ] as const; + + for (const { parentKind, parentId } of noopParents) { + it(`${parentKind} cascade leaves hub deep-equal to input`, () => { + const input = clone(HUB_TWO_OUTCOMES); + const result = produce(input, draft => { + cascadeArchiveDescendantsInDraft(draft, parentKind, parentId, FIXED_NOW_MS); + }); + expect(result).toEqual(input); + }); + } +}); + +describe('cascadeArchiveDescendantsInDraft — INVESTIGATION_ARCHIVE action is no-op via helper', () => { + // The archive helpers (archiveInvestigationInDraft etc.) call + // cascadeArchiveDescendantsInDraft internally. Confirm via action dispatch + // that the returned hub is deep-equal (the cascade walk is a no-op on PWA blob). + type A = Parameters[1]; + + const archiveCases: Array<{ label: string; action: A }> = [ + { + label: 'INVESTIGATION_ARCHIVE', + action: { kind: 'INVESTIGATION_ARCHIVE', investigationId: 'inv-001' } as A, + }, + { + label: 'FINDING_ARCHIVE', + action: { kind: 'FINDING_ARCHIVE', findingId: 'f-001' } as A, + }, + { + label: 'QUESTION_ARCHIVE', + action: { kind: 'QUESTION_ARCHIVE', questionId: 'q-001' } as A, + }, + { + label: 'CAUSAL_LINK_ARCHIVE', + action: { kind: 'CAUSAL_LINK_ARCHIVE', linkId: 'cl-001' } as A, + }, + { + label: 'SUSPECTED_CAUSE_ARCHIVE', + action: { kind: 'SUSPECTED_CAUSE_ARCHIVE', causeId: 'sc-001' } as A, + }, + { + label: 'EVIDENCE_ARCHIVE_SNAPSHOT', + action: { kind: 'EVIDENCE_ARCHIVE_SNAPSHOT', snapshotId: 'snap-001' } as A, + }, + ]; + + for (const { label, action } of archiveCases) { + it(`${label} — hub outcomes remain unchanged (cascade is no-op on PWA blob)`, () => { + const input = clone(HUB_TWO_OUTCOMES); + const result = applyAction(input, action); + // outcomes must not be touched — cascade walk found no blob-resident descendants + expect(result.outcomes![0].deletedAt).toBeNull(); + expect(result.outcomes![1].deletedAt).toBeNull(); + // full hub deep-equals input + expect(result).toEqual(input); + }); + } +}); diff --git a/apps/pwa/src/persistence/applyAction.ts b/apps/pwa/src/persistence/applyAction.ts new file mode 100644 index 000000000..07e62dd5c --- /dev/null +++ b/apps/pwa/src/persistence/applyAction.ts @@ -0,0 +1,450 @@ +// apps/pwa/src/persistence/applyAction.ts +// +// Per-action Immer recipe dispatcher for the PWA hub blob. +// +// PWA PERSISTENCE MODEL (Hub-of-one blob): +// The hub blob (ProcessHub) contains: name, processGoal, outcomes, primaryScopeDimensions, +// canonicalProcessMap, reviewSignal, plus EntityBase fields. It does NOT contain +// investigations, findings, questions, causalLinks, suspectedCauses, evidenceSnapshots, +// or evidenceSources — those live in session-only Zustand stores today. +// +// CASCADE STRATEGY: +// Cascade helpers use transitiveCascade() from @variscout/core/persistence to walk +// descendant kinds. On the PWA blob, only hub→outcome produces observable mutations; +// all other parent kinds are structurally correct but yield no mutations because their +// arrays do not exist on the blob. F3 normalization will add Dexie queries for the rest. +// +// CANVAS STRATEGY (R15): +// Canvas mutations live in canvasStore; the hub's canonicalProcessMap is overwritten +// via HUB_PERSIST_SNAPSHOT when the user saves. All CANVAS_* cases are therefore no-ops +// here. F3 may revisit if canvas state is normalized into its own Dexie table. +// +// NON-HUB-RESIDENT ACTIONS: +// INVESTIGATION_*, FINDING_*, QUESTION_*, CAUSAL_LINK_*, SUSPECTED_CAUSE_*, +// EVIDENCE_*, and EVIDENCE_SOURCE_* handlers exist for type-exhaustiveness (TypeScript +// validates the switch is exhaustive) but their bodies are no-ops. The dispatch still +// satisfies the HubRepository contract; the entities live in session-only stores. +// F3 normalization will give these handlers real bodies. + +import { produce, type Draft } from 'immer'; +import type { HubAction } from '@variscout/core/actions'; +import type { ProcessHub } from '@variscout/core/processHub'; +import { transitiveCascade, type EntityKind } from '@variscout/core/persistence'; + +// --------------------------------------------------------------------------- +// Exhaustiveness helper +// --------------------------------------------------------------------------- + +function assertNever(x: never): never { + throw new Error(`Unhandled action: ${JSON.stringify(x)}`); +} + +// --------------------------------------------------------------------------- +// Cascade helpers +// +// PWA blob holds only `outcomes` and `canonicalProcessMap`. Investigations, +// findings, questions, causalLinks, suspectedCauses, evidenceSnapshots, +// evidenceSources, rowProvenance, evidenceSourceCursors, and canvasState live +// in session-only Zustand stores (or Dexie tables introduced in F3) — the walk +// below is structurally correct but yields zero mutations on the PWA blob today +// for all parent kinds except hub→outcome. F3 normalization will fill in the +// remaining cases with real Dexie queries; the helper shape is already correct. +// --------------------------------------------------------------------------- + +/** + * Soft-mark descendants of a single EntityKind on the draft hub. + * + * The switch below covers every EntityKind. The only PWA-blob-resident kind + * that can be cascade-targeted is `outcome` (via hub→outcome). All other kinds + * are documented no-ops for PWA; F3 will add Dexie queries for the rest. + * + * The exhaustive switch (with no `default`) means TypeScript will error if a + * new EntityKind is added to cascadeRules without updating this function. + */ +function archiveDescendantsOfKindInDraft( + draft: Draft, + kind: EntityKind, + parentKind: EntityKind, + parentId: string, + archivedAt: number +): void { + switch (kind) { + case 'outcome': { + // hub.outcomes is the only cascade target resident on the PWA blob. + // Soft-mark all unarchived outcomes whose hubId matches the parent. + if (parentKind === 'hub' && draft.id === parentId) { + for (const outcome of draft.outcomes ?? []) { + if (outcome.deletedAt === null) outcome.deletedAt = archivedAt; + } + } + return; + } + case 'hub': + case 'investigation': + case 'finding': + case 'question': + case 'causalLink': + case 'suspectedCause': + case 'evidenceSnapshot': + case 'evidenceSource': + case 'rowProvenance': + case 'evidenceSourceCursor': + case 'canvasState': + // PWA blob does not persist these arrays today; F3 will fill in. + return; + } +} + +/** + * Walks the transitive cascade rules for the given parent and soft-marks + * matching descendants on the draft hub. + * + * On PWA, only the hub→outcome cascade has observable mutations. All other + * parent kinds (investigation, evidenceSnapshot, evidenceSource, …) are + * structurally correct but yield zero mutations because their descendant + * arrays do not exist on the PWA blob. F3 normalizes. + * + * @internal Exported for unit-testing only. Do not call from app code. + */ +export function cascadeArchiveDescendantsInDraft( + draft: Draft, + parentKind: EntityKind, + parentId: string, + archivedAt: number +): void { + const descendantKinds = transitiveCascade(parentKind); + for (const kind of descendantKinds) { + archiveDescendantsOfKindInDraft(draft, kind, parentKind, parentId, archivedAt); + } +} + +/** + * Archive an investigation and its cascade descendants (finding, question, + * causalLink, suspectedCause) on the draft hub. + * + * PWA blob has no investigations array; the parent soft-mark and the cascade + * walk are both no-ops today. F3 normalizes. + */ +function archiveInvestigationInDraft(draft: Draft, investigationId: string): void { + // PWA blob has no investigations array; the parent soft-mark is a no-op today. + // The cascade walk also yields no mutations on the PWA blob. F3 normalizes. + const archivedAt = Date.now(); + cascadeArchiveDescendantsInDraft(draft, 'investigation', investigationId, archivedAt); +} + +/** + * Archive a finding and its cascade descendants on the draft hub. + * + * PWA blob has no findings array; both the parent soft-mark and cascade walk + * are no-ops today. F3 normalizes. + */ +function archiveFindingInDraft(draft: Draft, findingId: string): void { + // PWA blob has no findings array; the parent soft-mark is a no-op today. + // The cascade walk also yields no mutations on the PWA blob. F3 normalizes. + const archivedAt = Date.now(); + cascadeArchiveDescendantsInDraft(draft, 'finding', findingId, archivedAt); +} + +/** + * Archive a question and its cascade descendants on the draft hub. + * + * PWA blob has no questions array; both the parent soft-mark and cascade walk + * are no-ops today. F3 normalizes. + */ +function archiveQuestionInDraft(draft: Draft, questionId: string): void { + // PWA blob has no questions array; the parent soft-mark is a no-op today. + // The cascade walk also yields no mutations on the PWA blob. F3 normalizes. + const archivedAt = Date.now(); + cascadeArchiveDescendantsInDraft(draft, 'question', questionId, archivedAt); +} + +/** + * Archive a causal link and its cascade descendants on the draft hub. + * + * PWA blob has no causalLinks array; both the parent soft-mark and cascade walk + * are no-ops today. F3 normalizes. + */ +function archiveCausalLinkInDraft(draft: Draft, linkId: string): void { + // PWA blob has no causalLinks array; the parent soft-mark is a no-op today. + // The cascade walk also yields no mutations on the PWA blob. F3 normalizes. + const archivedAt = Date.now(); + cascadeArchiveDescendantsInDraft(draft, 'causalLink', linkId, archivedAt); +} + +/** + * Archive a suspected cause and its cascade descendants on the draft hub. + * + * PWA blob has no suspectedCauses array; both the parent soft-mark and cascade + * walk are no-ops today. F3 normalizes. + */ +function archiveSuspectedCauseInDraft(draft: Draft, causeId: string): void { + // PWA blob has no suspectedCauses array; the parent soft-mark is a no-op today. + // The cascade walk also yields no mutations on the PWA blob. F3 normalizes. + const archivedAt = Date.now(); + cascadeArchiveDescendantsInDraft(draft, 'suspectedCause', causeId, archivedAt); +} + +// --------------------------------------------------------------------------- +// applyAction +// --------------------------------------------------------------------------- + +/** + * Apply a single HubAction to a ProcessHub snapshot and return the next snapshot. + * Pure function — no side effects, no I/O. + * + * Hub-resident actions produce real Immer mutations. + * Non-hub-resident actions are no-ops (documented above). + * Canvas actions are no-ops (canvasStore is the canonical mutation surface). + * Exhaustiveness is enforced at the TypeScript level via assertNever(). + */ +export function applyAction(hub: ProcessHub, action: HubAction): ProcessHub { + // HUB_PERSIST_SNAPSHOT is a full replacement — bypass produce for clarity. + if (action.kind === 'HUB_PERSIST_SNAPSHOT') { + return action.hub; + } + + return produce(hub, draft => { + switch (action.kind) { + // ----------------------------------------------------------------------- + // Hub meta — hub-resident real mutations + // ----------------------------------------------------------------------- + + case 'HUB_UPDATE_GOAL': { + if (action.hubId !== draft.id) break; // defensive: should never happen in hub-of-one + draft.processGoal = action.processGoal; + draft.updatedAt = Date.now(); + break; + } + + case 'HUB_UPDATE_PRIMARY_SCOPE_DIMENSIONS': { + if (action.hubId !== draft.id) break; // defensive: should never happen in hub-of-one + draft.primaryScopeDimensions = action.dimensions; + draft.updatedAt = Date.now(); + break; + } + + // ----------------------------------------------------------------------- + // Outcomes — hub-resident real mutations + // ----------------------------------------------------------------------- + + case 'OUTCOME_ADD': { + if (action.hubId !== draft.id) { + // Loud failure: hub-of-one constraint — foreign outcomes must never arrive. + throw new Error(`OUTCOME_ADD hubId mismatch: expected ${draft.id}, got ${action.hubId}`); + } + if (!draft.outcomes) { + draft.outcomes = []; + } + draft.outcomes.push(action.outcome); + break; + } + + case 'OUTCOME_UPDATE': { + const outcome = draft.outcomes?.find(o => o.id === action.outcomeId); + if (!outcome) break; // no-op per Immer recipe pattern + Object.assign(outcome, action.patch); + break; + } + + case 'OUTCOME_ARCHIVE': { + const outcome = draft.outcomes?.find(o => o.id === action.outcomeId); + if (!outcome || outcome.deletedAt !== null) break; // idempotent + outcome.deletedAt = Date.now(); + // outcome has no cascadesTo descendants (cascadeRules.outcome.cascadesTo === []) + break; + } + + // ----------------------------------------------------------------------- + // Investigations — PWA blob does not persist investigations today; F3 normalizes. + // ----------------------------------------------------------------------- + + case 'INVESTIGATION_CREATE': { + // PWA blob does not persist investigations today; F3 normalizes. + break; + } + + case 'INVESTIGATION_UPDATE_METADATA': { + // PWA blob does not persist investigations today; F3 normalizes. + break; + } + + case 'INVESTIGATION_ARCHIVE': { + // PWA blob does not persist investigations today; F3 normalizes. + archiveInvestigationInDraft(draft, action.investigationId); + break; + } + + // ----------------------------------------------------------------------- + // Findings — PWA blob does not persist findings today; F3 normalizes. + // ----------------------------------------------------------------------- + + case 'FINDING_ADD': { + // PWA blob does not persist findings today; F3 normalizes. + break; + } + + case 'FINDING_UPDATE': { + // PWA blob does not persist findings today; F3 normalizes. + break; + } + + case 'FINDING_ARCHIVE': { + // PWA blob does not persist findings today; F3 normalizes. + archiveFindingInDraft(draft, action.findingId); + break; + } + + // ----------------------------------------------------------------------- + // Questions — PWA blob does not persist questions today; F3 normalizes. + // ----------------------------------------------------------------------- + + case 'QUESTION_ADD': { + // PWA blob does not persist questions today; F3 normalizes. + break; + } + + case 'QUESTION_UPDATE': { + // PWA blob does not persist questions today; F3 normalizes. + break; + } + + case 'QUESTION_ARCHIVE': { + // PWA blob does not persist questions today; F3 normalizes. + archiveQuestionInDraft(draft, action.questionId); + break; + } + + // ----------------------------------------------------------------------- + // Causal links — PWA blob does not persist causalLinks today; F3 normalizes. + // ----------------------------------------------------------------------- + + case 'CAUSAL_LINK_ADD': { + // PWA blob does not persist causalLinks today; F3 normalizes. + break; + } + + case 'CAUSAL_LINK_UPDATE': { + // PWA blob does not persist causalLinks today; F3 normalizes. + break; + } + + case 'CAUSAL_LINK_ARCHIVE': { + // PWA blob does not persist causalLinks today; F3 normalizes. + archiveCausalLinkInDraft(draft, action.linkId); + break; + } + + // ----------------------------------------------------------------------- + // Suspected causes — PWA blob does not persist suspectedCauses today; F3 normalizes. + // ----------------------------------------------------------------------- + + case 'SUSPECTED_CAUSE_ADD': { + // PWA blob does not persist suspectedCauses today; F3 normalizes. + break; + } + + case 'SUSPECTED_CAUSE_UPDATE': { + // PWA blob does not persist suspectedCauses today; F3 normalizes. + break; + } + + case 'SUSPECTED_CAUSE_ARCHIVE': { + // PWA blob does not persist suspectedCauses today; F3 normalizes. + archiveSuspectedCauseInDraft(draft, action.causeId); + break; + } + + // ----------------------------------------------------------------------- + // Evidence snapshots — PWA blob does not persist evidenceSnapshots today; F3 normalizes. + // ----------------------------------------------------------------------- + + case 'EVIDENCE_ADD_SNAPSHOT': { + // PWA blob does not persist evidenceSnapshots today; F3 normalizes. + break; + } + + case 'EVIDENCE_ARCHIVE_SNAPSHOT': { + // PWA blob does not persist evidenceSnapshots today; F3 normalizes. + break; + } + + // ----------------------------------------------------------------------- + // Evidence sources — PWA blob does not persist evidenceSources today; F3 normalizes. + // ----------------------------------------------------------------------- + + case 'EVIDENCE_SOURCE_ADD': { + // PWA blob does not persist evidenceSources today; F3 normalizes. + break; + } + + case 'EVIDENCE_SOURCE_UPDATE_CURSOR': { + // PWA blob does not persist evidenceSourceCursors today; F3 normalizes. + break; + } + + case 'EVIDENCE_SOURCE_REMOVE': { + // PWA blob does not persist evidenceSources today; F3 normalizes. + break; + } + + // ----------------------------------------------------------------------- + // Canvas actions — no-ops: canvasStore is the canonical mutation surface. + // Canvas mutations live in canvasStore; PWA persists via HUB_PERSIST_SNAPSHOT + // carrying the assembled canonicalProcessMap. F3 may revisit. + // ----------------------------------------------------------------------- + + case 'PLACE_CHIP_ON_STEP': { + // Canvas mutations live in canvasStore; PWA persists via HUB_PERSIST_SNAPSHOT. + break; + } + + case 'UNASSIGN_CHIP': { + // Canvas mutations live in canvasStore; PWA persists via HUB_PERSIST_SNAPSHOT. + break; + } + + case 'REORDER_CHIP_IN_STEP': { + // Canvas mutations live in canvasStore; PWA persists via HUB_PERSIST_SNAPSHOT. + break; + } + + case 'ADD_STEP': { + // Canvas mutations live in canvasStore; PWA persists via HUB_PERSIST_SNAPSHOT. + break; + } + + case 'REMOVE_STEP': { + // Canvas mutations live in canvasStore; PWA persists via HUB_PERSIST_SNAPSHOT. + break; + } + + case 'RENAME_STEP': { + // Canvas mutations live in canvasStore; PWA persists via HUB_PERSIST_SNAPSHOT. + break; + } + + case 'CONNECT_STEPS': { + // Canvas mutations live in canvasStore; PWA persists via HUB_PERSIST_SNAPSHOT. + break; + } + + case 'DISCONNECT_STEPS': { + // Canvas mutations live in canvasStore; PWA persists via HUB_PERSIST_SNAPSHOT. + break; + } + + case 'GROUP_INTO_SUB_STEP': { + // Canvas mutations live in canvasStore; PWA persists via HUB_PERSIST_SNAPSHOT. + break; + } + + case 'UNGROUP_SUB_STEP': { + // Canvas mutations live in canvasStore; PWA persists via HUB_PERSIST_SNAPSHOT. + break; + } + + default: + assertNever(action); + } + }); +} diff --git a/apps/pwa/src/persistence/index.ts b/apps/pwa/src/persistence/index.ts new file mode 100644 index 000000000..f4df17e25 --- /dev/null +++ b/apps/pwa/src/persistence/index.ts @@ -0,0 +1,5 @@ +// apps/pwa/src/persistence/index.ts +// Barrel: re-exports the class, singleton, and applyAction stub. + +export { PwaHubRepository, pwaHubRepository } from './PwaHubRepository'; +export { applyAction } from './applyAction'; diff --git a/packages/stores/CLAUDE.md b/packages/stores/CLAUDE.md index 99c3e93c5..98e13d6bd 100644 --- a/packages/stores/CLAUDE.md +++ b/packages/stores/CLAUDE.md @@ -12,10 +12,12 @@ ## Invariants - `sessionStore` auto-persists via idb-keyval middleware. Domain stores (project/investigation/improvement) persist at document-level via `useProjectActions`. -- `wallLayoutStore` persists to a dedicated Dexie DB (`variscout-wall-layout`, distinct from `VaRiScoutAzure`) keyed by `projectId`. Call `rehydrateWallLayout(projectId)` on project open, `persistWallLayout(projectId)` debounced on mutations. PWA is session-only (hook is no-op when projectId is null). +- Domain stores (project/investigation/improvement/canvas/session) stay persistence-free; the dispatch boundary lives at app/UI composition root via `pwaHubRepository.dispatch(action)` (PWA, `apps/pwa/src/persistence/`) and `azureHubRepository.dispatch(action)` (Azure, F2 PR3). `addHubComment` network IO in `investigationStore` is a deliberate exception (optimistic-update IO, not persistence) — see plan audit R14. +- `canvasStore` exposes its own `dispatch(action: CanvasAction)` as the canvas-state mutation entry (audit R15); per-action methods stay as transitional wrappers for PR2 and are removed in PR3 cleanup. +- `wallLayoutStore` persists to a dedicated Dexie DB (`variscout-wall-layout`, distinct from `VaRiScoutAzure`) keyed by `projectId`. Call `rehydrateWallLayout(projectId)` on project open, `persistWallLayout(projectId)` debounced on mutations. PWA is session-only (hook is no-op when projectId is null). Whitelisted from F2's "no `dexie` outside persistence" ESLint rule because it operates a separate DB for cross-app UI state — audit R12. - Testing pattern: `beforeEach(() => useStore.setState(useStore.getInitialState()))` to reset between tests. Selectors tested as pure functions. - Cross-store reads: `otherStore.getState()` inside a selector is allowed but should be mocked in tests. -- Complete list of stores: `projectStore`, `investigationStore`, `improvementStore`, `sessionStore`, `wallLayoutStore`. +- Complete list of stores: `projectStore`, `investigationStore`, `improvementStore`, `sessionStore`, `canvasStore`, `wallLayoutStore`. ## Test command diff --git a/packages/stores/src/__tests__/canvasStore.test.ts b/packages/stores/src/__tests__/canvasStore.test.ts index 5c6aa1bbe..9d21d09b7 100644 --- a/packages/stores/src/__tests__/canvasStore.test.ts +++ b/packages/stores/src/__tests__/canvasStore.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { useCanvasStore } from '../canvasStore'; +import type { CanvasAction } from '@variscout/core/actions'; function resetCanvasStore() { useCanvasStore.setState(useCanvasStore.getInitialState()); @@ -449,3 +450,155 @@ describe('canvasStore history controls', () => { expect(useCanvasStore.getState().redoDepth()).toBe(0); }); }); + +describe('canvasStore dispatch', () => { + it('PLACE_CHIP_ON_STEP routes through placeChipOnStep', () => { + useCanvasStore.getState().addStep('Step A'); + const stepId = useCanvasStore.getState().canonicalMap.nodes[0]!.id; + + const action: CanvasAction = { kind: 'PLACE_CHIP_ON_STEP', chipId: 'chip-1', stepId }; + useCanvasStore.getState().dispatch(action); + + expect(useCanvasStore.getState().canonicalMap.assignments?.['chip-1']).toBe(stepId); + }); + + it('UNASSIGN_CHIP routes through unassignChip', () => { + useCanvasStore.getState().addStep('Step A'); + const stepId = useCanvasStore.getState().canonicalMap.nodes[0]!.id; + useCanvasStore.getState().placeChipOnStep('chip-1', stepId); + + const action: CanvasAction = { kind: 'UNASSIGN_CHIP', chipId: 'chip-1' }; + useCanvasStore.getState().dispatch(action); + + expect(useCanvasStore.getState().canonicalMap.assignments?.['chip-1']).toBeUndefined(); + }); + + it('REORDER_CHIP_IN_STEP routes through reorderChipInStep (stable no-op)', () => { + useCanvasStore.getState().addStep('Step A'); + const stepId = useCanvasStore.getState().canonicalMap.nodes[0]!.id; + useCanvasStore.getState().placeChipOnStep('chip-1', stepId); + const version = useCanvasStore.getState().canonicalMapVersion; + + const action: CanvasAction = { + kind: 'REORDER_CHIP_IN_STEP', + chipId: 'chip-1', + stepId, + toIndex: 2, + }; + useCanvasStore.getState().dispatch(action); + + // reorderChipInStep is a stable no-op — version must not change + expect(useCanvasStore.getState().canonicalMapVersion).toBe(version); + }); + + it('ADD_STEP routes through addStep and creates a new node', () => { + const action: CanvasAction = { kind: 'ADD_STEP', stepName: 'Weld' }; + useCanvasStore.getState().dispatch(action); + + expect(useCanvasStore.getState().canonicalMap.nodes).toHaveLength(1); + expect(useCanvasStore.getState().canonicalMap.nodes[0]?.name).toBe('Weld'); + }); + + it('REMOVE_STEP routes through removeStep', () => { + useCanvasStore.getState().addStep('Remove Me'); + const stepId = useCanvasStore.getState().canonicalMap.nodes[0]!.id; + + const action: CanvasAction = { kind: 'REMOVE_STEP', stepId }; + useCanvasStore.getState().dispatch(action); + + expect(useCanvasStore.getState().canonicalMap.nodes).toHaveLength(0); + }); + + it('RENAME_STEP routes through renameStep', () => { + useCanvasStore.getState().addStep('Old Name'); + const stepId = useCanvasStore.getState().canonicalMap.nodes[0]!.id; + + const action: CanvasAction = { kind: 'RENAME_STEP', stepId, newName: 'New Name' }; + useCanvasStore.getState().dispatch(action); + + expect(useCanvasStore.getState().canonicalMap.nodes[0]?.name).toBe('New Name'); + }); + + it('CONNECT_STEPS routes through connectSteps', () => { + useCanvasStore.getState().addStep('From'); + useCanvasStore.getState().addStep('To'); + const [fromNode, toNode] = useCanvasStore.getState().canonicalMap.nodes; + const fromStepId = fromNode!.id; + const toStepId = toNode!.id; + + const action: CanvasAction = { kind: 'CONNECT_STEPS', fromStepId, toStepId }; + useCanvasStore.getState().dispatch(action); + + expect(useCanvasStore.getState().canonicalMap.arrows).toHaveLength(1); + expect(useCanvasStore.getState().canonicalMap.arrows?.[0]).toMatchObject({ + fromStepId, + toStepId, + }); + }); + + it('DISCONNECT_STEPS routes through disconnectSteps', () => { + useCanvasStore.getState().addStep('From'); + useCanvasStore.getState().addStep('To'); + const [fromNode, toNode] = useCanvasStore.getState().canonicalMap.nodes; + const fromStepId = fromNode!.id; + const toStepId = toNode!.id; + useCanvasStore.getState().connectSteps(fromStepId, toStepId); + + const action: CanvasAction = { kind: 'DISCONNECT_STEPS', fromStepId, toStepId }; + useCanvasStore.getState().dispatch(action); + + expect(useCanvasStore.getState().canonicalMap.arrows).toHaveLength(0); + }); + + it('GROUP_INTO_SUB_STEP routes through groupIntoSubStep', () => { + useCanvasStore.getState().addStep('Parent'); + useCanvasStore.getState().addStep('Child'); + const [parentNode, childNode] = useCanvasStore.getState().canonicalMap.nodes; + const parentStepId = parentNode!.id; + const childStepId = childNode!.id; + + const action: CanvasAction = { + kind: 'GROUP_INTO_SUB_STEP', + stepIds: [childStepId], + parentStepId, + }; + useCanvasStore.getState().dispatch(action); + + const childAfter = useCanvasStore + .getState() + .canonicalMap.nodes.find(node => node.id === childStepId); + expect(childAfter?.parentStepId).toBe(parentStepId); + }); + + it('UNGROUP_SUB_STEP routes through ungroupSubStep', () => { + useCanvasStore.getState().addStep('Parent'); + useCanvasStore.getState().addStep('Child'); + const [parentNode, childNode] = useCanvasStore.getState().canonicalMap.nodes; + const parentStepId = parentNode!.id; + const childStepId = childNode!.id; + useCanvasStore.getState().groupIntoSubStep([childStepId], parentStepId); + + const action: CanvasAction = { kind: 'UNGROUP_SUB_STEP', stepId: childStepId }; + useCanvasStore.getState().dispatch(action); + + const childAfter = useCanvasStore + .getState() + .canonicalMap.nodes.find(node => node.id === childStepId); + expect(childAfter?.parentStepId).toBeNull(); + }); + + it('dispatch produces the same state as the equivalent direct method call', () => { + // Dispatch ADD_STEP and compare to direct addStep result + useCanvasStore.getState().dispatch({ kind: 'ADD_STEP', stepName: 'Via Dispatch' }); + const dispatchedNode = useCanvasStore.getState().canonicalMap.nodes[0]; + + resetCanvasStore(); + + useCanvasStore.getState().addStep('Via Dispatch'); + const directNode = useCanvasStore.getState().canonicalMap.nodes[0]; + + // Node names match; ids differ due to sequential counters but shape is the same + expect(dispatchedNode?.name).toBe(directNode?.name); + expect(dispatchedNode?.order).toBe(directNode?.order); + }); +}); diff --git a/packages/stores/src/canvasStore.ts b/packages/stores/src/canvasStore.ts index d77f3e5d5..817eb7e86 100644 --- a/packages/stores/src/canvasStore.ts +++ b/packages/stores/src/canvasStore.ts @@ -1,7 +1,8 @@ import { create } from 'zustand'; import { devtools } from 'zustand/middleware'; import { immer } from 'zustand/middleware/immer'; -import type { ProcessHub } from '@variscout/core'; +import { assertNever, type ProcessHub } from '@variscout/core'; +import type { CanvasAction } from '@variscout/core/actions'; import { createEmptyMap, type ProcessMap, type ProcessMapNode } from '@variscout/core/frame'; const HISTORY_CAP = 50; @@ -26,6 +27,14 @@ export interface CanvasHistoryControls { export interface CanvasStoreActions { hydrateCanvasDocument: (snapshot: CanvasHydrationSnapshot) => void; + /** + * Action-shape entry point. Dispatches the canvas action to the corresponding + * method-per-action handler. Per audit R15: per-action methods stay as transitional + * wrappers in PR2; PR3 cleanup removes them once consumers migrate to `dispatch`. + * Only handles `CanvasAction` kinds — `createStepFromChip` and `hydrateCanvasDocument` + * remain method-only because they're not in the canonical CanvasAction union. + */ + dispatch: (action: CanvasAction) => void; placeChipOnStep: (chipId: string, stepId: string) => void; unassignChip: (chipId: string) => void; reorderChipInStep: (chipId: string, stepId: string, toIndex: number) => void; @@ -198,6 +207,43 @@ export const useCanvasStore = create()( ); }, + dispatch: action => { + switch (action.kind) { + case 'PLACE_CHIP_ON_STEP': + get().placeChipOnStep(action.chipId, action.stepId); + return; + case 'UNASSIGN_CHIP': + get().unassignChip(action.chipId); + return; + case 'REORDER_CHIP_IN_STEP': + get().reorderChipInStep(action.chipId, action.stepId, action.toIndex); + return; + case 'ADD_STEP': + get().addStep(action.stepName, action.position); + return; + case 'REMOVE_STEP': + get().removeStep(action.stepId); + return; + case 'RENAME_STEP': + get().renameStep(action.stepId, action.newName); + return; + case 'CONNECT_STEPS': + get().connectSteps(action.fromStepId, action.toStepId); + return; + case 'DISCONNECT_STEPS': + get().disconnectSteps(action.fromStepId, action.toStepId); + return; + case 'GROUP_INTO_SUB_STEP': + get().groupIntoSubStep(action.stepIds, action.parentStepId); + return; + case 'UNGROUP_SUB_STEP': + get().ungroupSubStep(action.stepId); + return; + default: + assertNever(action); + } + }, + undo: () => { const entry = get().undoStack.at(-1); if (!entry) return; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index efef6207f..e649e50b4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -268,6 +268,9 @@ importers: html-to-image: specifier: ^1.11.13 version: 1.11.13 + immer: + specifier: ^11.1.4 + version: 11.1.4 lucide-react: specifier: ^1.14.0 version: 1.14.0(react@19.2.5)