From 8b40a90d51a6e238d594321d7ad50f5a35775102 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Sat, 30 May 2026 21:04:37 +0100 Subject: [PATCH] refactor(persistence): this was way to bloated and premature --- apps/desktop/src/renderer/src/app/App.tsx | 25 +- .../src/renderer/src/app/Document.test.ts | 35 +- apps/desktop/src/renderer/src/app/Document.ts | 26 +- .../src/renderer/src/lib/editor/Editor.ts | 78 +--- .../src/renderer/src/lib/signals/docs/DOCS.md | 1 - .../renderer/src/lib/state/ShiftState.test.ts | 214 --------- .../src/renderer/src/lib/state/ShiftState.ts | 120 ----- .../src/renderer/src/persistence/index.ts | 3 - .../src/renderer/src/persistence/kernel.ts | 442 ------------------ .../src/renderer/src/persistence/module.ts | 27 -- .../src/persistence/modules/toolState.ts | 31 -- .../src/renderer/src/persistence/types.ts | 21 - apps/desktop/src/renderer/src/store/store.ts | 2 - .../src/renderer/src/views/RecentFiles.tsx | 69 +-- packages/validation/docs/DOCS.md | 23 +- packages/validation/src/index.ts | 16 - packages/validation/src/persistence.test.ts | 75 --- packages/validation/src/persistence.ts | 65 --- 18 files changed, 9 insertions(+), 1264 deletions(-) delete mode 100644 apps/desktop/src/renderer/src/lib/state/ShiftState.test.ts delete mode 100644 apps/desktop/src/renderer/src/lib/state/ShiftState.ts delete mode 100644 apps/desktop/src/renderer/src/persistence/index.ts delete mode 100644 apps/desktop/src/renderer/src/persistence/kernel.ts delete mode 100644 apps/desktop/src/renderer/src/persistence/module.ts delete mode 100644 apps/desktop/src/renderer/src/persistence/modules/toolState.ts delete mode 100644 apps/desktop/src/renderer/src/persistence/types.ts delete mode 100644 packages/validation/src/persistence.test.ts delete mode 100644 packages/validation/src/persistence.ts diff --git a/apps/desktop/src/renderer/src/app/App.tsx b/apps/desktop/src/renderer/src/app/App.tsx index 5eebba67..ed6c259a 100644 --- a/apps/desktop/src/renderer/src/app/App.tsx +++ b/apps/desktop/src/renderer/src/app/App.tsx @@ -9,7 +9,6 @@ import { ZoomToast } from "@/components/chrome/ZoomToast"; import { isDev } from "@/lib/utils/utils"; import { dumpSelectionPatternsToConsole } from "@/lib/debug/dumpSelectionPatterns"; import { getDocument } from "@/store/store"; -import { documentPersistence } from "@/persistence"; import { RouteDispatcher } from "./RouteDispatcher"; @@ -41,14 +40,8 @@ export const App = () => { useEffect(() => { const fontDocument = getDocument(); const editor = fontDocument.editor; - documentPersistence.init(editor); let didOpenFont = false; - const handleBeforeUnload = () => { - documentPersistence.flushNow(); - }; - window.addEventListener("beforeunload", handleBeforeUnload); - const handleOpenFont = (filePath: string, source: "event" | "restore" = "event") => { if (source === "restore" && didOpenFont) { return; @@ -79,21 +72,7 @@ export const App = () => { return; } - const state = documentPersistence.getState(); - const mostRecentDocId = state.registry.lruDocIds[0]; - const mostRecentPath = mostRecentDocId ? state.registry.docIdToPath[mostRecentDocId] : null; - if (!mostRecentPath) { - fontDocument.createFont(); - return; - } - - const exists = await window.electronAPI?.pathsExist([mostRecentPath]); - if (!exists?.[0]) { - fontDocument.createFont(); - return; - } - - handleOpenFont(mostRecentPath, "restore"); + fontDocument.createFont(); })(); } else if (needsLoadedDocument(window.location.hash) && !fontDocument.loaded) { fontDocument.createFont(); @@ -128,8 +107,6 @@ export const App = () => { }); return () => { - window.removeEventListener("beforeunload", handleBeforeUnload); - documentPersistence.dispose(); if (unsubscribeNew) unsubscribeNew(); if (unsubscribeOpen) unsubscribeOpen(); if (unsubscribeExternalOpen) unsubscribeExternalOpen(); diff --git a/apps/desktop/src/renderer/src/app/Document.test.ts b/apps/desktop/src/renderer/src/app/Document.test.ts index 35f9020d..297dbf8f 100644 --- a/apps/desktop/src/renderer/src/app/Document.test.ts +++ b/apps/desktop/src/renderer/src/app/Document.test.ts @@ -1,42 +1,13 @@ import { describe, expect, it } from "vitest"; import { TestEditor } from "@/testing/TestEditor"; import { MUTATORSANS_DESIGNSPACE } from "@/testing/fixtures"; -import { Document, type DocumentPersistence } from "./Document"; - -class InMemoryDocumentPersistence implements DocumentPersistence { - currentDocId: string | null = null; - currentPath: string | null = null; - - closeDocument(): void { - this.currentDocId = null; - this.currentPath = null; - } - - openDocument(filePath: string): void { - this.currentPath = filePath; - this.currentDocId = `file:${filePath}`; - } - - openUntitledDocument(docId: string): void { - this.currentDocId = docId; - this.currentPath = null; - } - - onDocumentPathChanged(filePath: string | null): void { - this.currentPath = filePath; - if (filePath) this.currentDocId ??= `file:${filePath}`; - } - - flushNow(): void {} -} +import { Document } from "./Document"; function testDocument() { const editor = new TestEditor(); - const persistence = new InMemoryDocumentPersistence(); let filePath: string | null = "stale.ufo"; let dirty = true; const document = new Document(editor, { - persistence, createUntitledId: () => "untitled-1", setFilePath: (nextPath) => { filePath = nextPath; @@ -49,7 +20,6 @@ function testDocument() { return { document, editor, - persistence, get filePath() { return filePath; }, @@ -71,7 +41,6 @@ describe("Document", () => { }); expect(state.editor.font.loaded).toBe(true); expect(state.editor.font.defaultSource.name).toBe("Regular"); - expect(state.persistence.currentDocId).toBe("untitled-1"); expect(state.filePath).toBeNull(); expect(state.dirty).toBe(false); }); @@ -90,7 +59,6 @@ describe("Document", () => { name: "A", unicode: 65, }); - expect(state.persistence.currentPath).toBe(MUTATORSANS_DESIGNSPACE); expect(state.filePath).toBe(MUTATORSANS_DESIGNSPACE); expect(state.dirty).toBe(false); }); @@ -104,7 +72,6 @@ describe("Document", () => { expect(state.document.identity).toBeNull(); expect(state.editor.font.loaded).toBe(false); expect(state.editor.font.sources).toEqual([]); - expect(state.persistence.currentDocId).toBeNull(); expect(state.filePath).toBeNull(); }); diff --git a/apps/desktop/src/renderer/src/app/Document.ts b/apps/desktop/src/renderer/src/app/Document.ts index 63fdba8f..a1241ce1 100644 --- a/apps/desktop/src/renderer/src/app/Document.ts +++ b/apps/desktop/src/renderer/src/app/Document.ts @@ -5,32 +5,22 @@ export type DocumentIdentity = | { readonly kind: "file"; readonly path: string }; export interface DocumentServices { - readonly persistence: DocumentPersistence; readonly setFilePath: (filePath: string | null) => void; readonly clearDirty: () => void; readonly createUntitledId?: () => string; readonly notifySaveCompleted?: (path: string) => Promise | void; } -export interface DocumentPersistence { - closeDocument(): void; - openDocument(filePath: string): void; - openUntitledDocument(docId: string): void; - onDocumentPathChanged(filePath: string | null): void; - flushNow(): void; -} - /** * App-level lifecycle for the current font document. * * `Document` owns the distinction between no document, a new untitled font, * and a file-backed font. It coordinates editor font lifecycle, file identity, - * dirty state, and document-scoped persistence. + * and dirty state. */ export class Document { readonly editor: Editor; - readonly #persistence: DocumentPersistence; readonly #setFilePath: (filePath: string | null) => void; readonly #clearDirty: () => void; readonly #createUntitledId: () => string; @@ -40,7 +30,6 @@ export class Document { constructor(editor: Editor, services: DocumentServices) { this.editor = editor; - this.#persistence = services.persistence; this.#setFilePath = services.setFilePath; this.#clearDirty = services.clearDirty; this.#createUntitledId = services.createUntitledId ?? createUntitledId; @@ -56,30 +45,20 @@ export class Document { } createFont(): void { - this.#persistence.closeDocument(); - const id = this.#createUntitledId(); this.editor.createFont(); this.#identity = { kind: "untitled", id }; this.#setFilePath(null); this.#clearDirty(); - - this.#persistence.openUntitledDocument(id); - this.#persistence.flushNow(); } openFont(path: string): void { - this.#persistence.closeDocument(); - this.editor.loadFont(path); this.#identity = { kind: "file", path }; this.#setFilePath(path); this.#clearDirty(); - - this.#persistence.openDocument(path); - this.#persistence.flushNow(); } async saveFont(path?: string): Promise { @@ -94,8 +73,6 @@ export class Document { this.#setFilePath(savePath); this.#clearDirty(); - this.#persistence.onDocumentPathChanged(savePath); - this.#persistence.flushNow(); await this.#notifySaveCompleted(savePath); } @@ -104,7 +81,6 @@ export class Document { } close(): void { - this.#persistence.closeDocument(); this.editor.closeFont(); this.#identity = null; this.#setFilePath(null); diff --git a/apps/desktop/src/renderer/src/lib/editor/Editor.ts b/apps/desktop/src/renderer/src/lib/editor/Editor.ts index 2e74a64f..f646fbe4 100644 --- a/apps/desktop/src/renderer/src/lib/editor/Editor.ts +++ b/apps/desktop/src/renderer/src/lib/editor/Editor.ts @@ -65,13 +65,10 @@ import { TextRuns } from "@/lib/text/TextRuns"; import { TextRun, type FocusedGlyph } from "@/lib/text/TextRun"; import { glyphTextItem, Positioner } from "@/lib/text/layout"; import type { GlyphAnchor } from "@/lib/text/layout"; -import { TextRunModuleSchema } from "@shift/validation"; -import type { TextRunModule } from "@/persistence/types"; import type { ToolManifest, ToolShortcutEntry } from "@/types/tools"; import type { ToolStateScope } from "@/types/editor"; import { EventEmitter } from "./lifecycle"; -import { StateRegistry, type ShiftState, type ShiftStateOptions } from "@/lib/state/ShiftState"; import type { LineSegmentPoints } from "@shift/glyph-state"; import type { ShiftBridge } from "@shift/bridge"; @@ -184,8 +181,6 @@ export class Editor { #events: EventEmitter; - #stateRegistry: StateRegistry; - #textRuns: TextRuns; #glyphFinderOpen: WritableSignal; @@ -207,7 +202,7 @@ export class Editor { app: Map; document: Map; }; - #toolStateVersion: WritableSignal; + /** * Initializes all subsystems, wires signal dependencies, and sets up * reactive effects that schedule canvas redraws when state changes. @@ -227,12 +222,10 @@ export class Editor { this.#commandHistory = new CommandHistory(this.#glyph.edit.glyphSource); - this.#stateRegistry = new StateRegistry(); this.#toolState = { app: new Map(), document: new Map(), }; - this.#toolStateVersion = signal(0, { name: "editor.toolState.version" }); this.#glyphFinderOpen = signal(false, { name: "editor.glyphFinder.open" }); @@ -263,31 +256,6 @@ export class Editor { this.#renderer = new Renderer(this); - const textRunPersistence = this.registerState({ - id: "text-run", - scope: "document", - initial: () => ({ runsByGlyph: {} }), - serialize: () => ({ runsByGlyph: this.#textRuns.serialize() }), - deserialize: (json) => { - const payload = TextRunModuleSchema.parse(json); - this.#textRuns.deserialize(payload.runsByGlyph); - return payload; - }, - }); - - // Bridge: when active run's buffer changes (or active switches), notify persistence. - effect( - () => { - const run = this.#textRuns.activeCell.value; - run.buffer.itemsCell.value; - run.buffer.cursorCell.value; - run.buffer.anchorCell.value; - run.buffer.originXCell.value; - textRunPersistence.set({ runsByGlyph: this.#textRuns.serialize() }); - }, - { name: "editor.textRun.persistence" }, - ); - this.#events.on("fontLoaded", () => { this.#commandHistory.clear(); this.#textRuns.clearAll(); @@ -422,10 +390,6 @@ export class Editor { return this.#commandHistory.withBatch(label, fn); } - public get toolStateVersionCell(): Signal { - return this.#toolStateVersion; - } - public get debugOverlays(): DebugOverlays { return this.#view.debugOverlaysCell.peek(); } @@ -805,38 +769,12 @@ export class Editor { const stateKey = this.#toolStateKey(toolId, key); if (scopedState.get(stateKey) === value) return; scopedState.set(stateKey, value); - this.#bumpToolStateVersion(); } public deleteToolState(scope: ToolStateScope, toolId: string, key: string): void { const scopedState = this.#getToolScopeMap(scope); const stateKey = this.#toolStateKey(toolId, key); if (!scopedState.delete(stateKey)) return; - this.#bumpToolStateVersion(); - } - - public exportToolState(scope: ToolStateScope): Record { - const out: Record = {}; - for (const [key, value] of this.#getToolScopeMap(scope).entries()) { - out[key] = value; - } - return out; - } - - public hydrateToolState(scope: ToolStateScope, state: Record): void { - const scopedState = this.#getToolScopeMap(scope); - scopedState.clear(); - for (const [key, value] of Object.entries(state)) { - scopedState.set(key, value); - } - this.#bumpToolStateVersion(); - } - - public clearToolState(scope: ToolStateScope): void { - const scopedState = this.#getToolScopeMap(scope); - if (scopedState.size === 0) return; - scopedState.clear(); - this.#bumpToolStateVersion(); } public get glyph(): Signal { @@ -850,16 +788,6 @@ export class Editor { /** Subscribe to a lifecycle event. Returns an unsubscribe function. */ public on: EventEmitter["on"] = (...args) => this.#events.on(...args); - /** Register persistent state. Returns a reactive handle for reading/writing. */ - public registerState(options: ShiftStateOptions): ShiftState { - return this.#stateRegistry.register(options); - } - - /** @internal Used by persistence kernel. */ - get stateRegistry(): StateRegistry { - return this.#stateRegistry; - } - public get commands(): CommandHistory { return this.#commandHistory; } @@ -1423,8 +1351,4 @@ export class Editor { #getToolScopeMap(scope: ToolStateScope): Map { return this.#toolState[scope]; } - - #bumpToolStateVersion(): void { - this.#toolStateVersion.set(this.#toolStateVersion.peek() + 1); - } } diff --git a/apps/desktop/src/renderer/src/lib/signals/docs/DOCS.md b/apps/desktop/src/renderer/src/lib/signals/docs/DOCS.md index 8b0f2bbd..de9815f3 100644 --- a/apps/desktop/src/renderer/src/lib/signals/docs/DOCS.md +++ b/apps/desktop/src/renderer/src/lib/signals/docs/DOCS.md @@ -109,7 +109,6 @@ cd apps/desktop && npm test - `HoverManager` -- uses `$hoveredPointId`, `$hoveredSegmentId`, etc. - `Selection` -- uses `WritableSignal` fields for selected point/anchor/segment state - `NativeBridge` -- `$glyph` signal with `equals: () => false` for identity changes -- `ShiftState` -- uses `signal` for application-level reactive state - `useSignalState` -- React bridge hook (in this module) - `useSignalEffect` -- lifecycle-aware effect hook (in `@/hooks/useSignalEffect`) - `CommandHistory` -- imports from reactive for undo/redo state signals diff --git a/apps/desktop/src/renderer/src/lib/state/ShiftState.test.ts b/apps/desktop/src/renderer/src/lib/state/ShiftState.test.ts deleted file mode 100644 index a6430437..00000000 --- a/apps/desktop/src/renderer/src/lib/state/ShiftState.test.ts +++ /dev/null @@ -1,214 +0,0 @@ -import { describe, it, expect, beforeEach } from "vitest"; -import { ShiftStateImpl, StateRegistry } from "./ShiftState"; -import { effect } from "@/lib/signals/signal"; - -describe("ShiftState", () => { - it("initializes with the factory value", () => { - const state = new ShiftStateImpl({ - id: "test", - scope: "app", - initial: () => 42, - serialize: (v) => v, - deserialize: (v) => v as number, - }); - - expect(state.value).toBe(42); - }); - - it("set updates the value", () => { - const state = new ShiftStateImpl({ - id: "test", - scope: "app", - initial: () => "hello", - serialize: (v) => v, - deserialize: (v) => v as string, - }); - - state.set("world"); - - expect(state.value).toBe("world"); - }); - - it("reset returns to initial value", () => { - const state = new ShiftStateImpl({ - id: "test", - scope: "app", - initial: () => 0, - serialize: (v) => v, - deserialize: (v) => v as number, - }); - - state.set(99); - state.reset(); - - expect(state.value).toBe(0); - }); - - it("is reactive — triggers effects on set", () => { - const state = new ShiftStateImpl({ - id: "test", - scope: "app", - initial: () => "a", - serialize: (v) => v, - deserialize: (v) => v as string, - }); - - const values: string[] = []; - const fx = effect(() => { - values.push(state.value); - }); - - state.set("b"); - state.set("c"); - - expect(values).toEqual(["a", "b", "c"]); - fx.dispose(); - }); - - it("capture serializes the current value", () => { - const state = new ShiftStateImpl({ - id: "test", - scope: "document", - initial: () => ({ count: 0 }), - serialize: (v) => ({ ...v, serialized: true }), - deserialize: (v) => v as { count: number }, - }); - - state.set({ count: 5 }); - const captured = state.capture() as { count: number; serialized: boolean }; - - expect(captured.count).toBe(5); - expect(captured.serialized).toBe(true); - }); - - it("hydrate deserializes and sets the value", () => { - const state = new ShiftStateImpl({ - id: "test", - scope: "document", - initial: () => ({ count: 0 }), - serialize: (v) => v, - deserialize: (v) => v as { count: number }, - }); - - state.hydrate({ count: 42 }); - - expect(state.value).toEqual({ count: 42 }); - }); - - it("peek reads without tracking", () => { - const state = new ShiftStateImpl({ - id: "test", - scope: "app", - initial: () => 1, - serialize: (v) => v, - deserialize: (v) => v as number, - }); - - let effectCount = 0; - const fx = effect(() => { - state.peek(); - effectCount++; - }); - - state.set(2); - state.set(3); - - expect(effectCount).toBe(1); - expect(state.peek()).toBe(3); - fx.dispose(); - }); -}); - -describe("StateRegistry", () => { - let registry: StateRegistry; - - beforeEach(() => { - registry = new StateRegistry(); - }); - - it("registers and retrieves state by id", () => { - const state = registry.register({ - id: "foo", - scope: "app", - initial: () => 0, - serialize: (v) => v, - deserialize: (v) => v as number, - }); - - expect(state.id).toBe("foo"); - expect(registry.get("foo")).toBeDefined(); - }); - - it("throws on duplicate id", () => { - registry.register({ - id: "foo", - scope: "app", - initial: () => 0, - serialize: (v) => v, - deserialize: (v) => v as number, - }); - - expect(() => - registry.register({ - id: "foo", - scope: "app", - initial: () => 0, - serialize: (v) => v, - deserialize: (v) => v as number, - }), - ).toThrow('State "foo" already registered'); - }); - - it("filters by scope", () => { - registry.register({ - id: "app-state", - scope: "app", - initial: () => null, - serialize: (v) => v, - deserialize: (v) => v, - }); - registry.register({ - id: "doc-state", - scope: "document", - initial: () => null, - serialize: (v) => v, - deserialize: (v) => v, - }); - - expect(registry.getByScope("app")).toHaveLength(1); - expect(registry.getByScope("document")).toHaveLength(1); - expect(registry.getByScope("app")[0].id).toBe("app-state"); - }); - - it("all returns every registered state", () => { - registry.register({ - id: "a", - scope: "app", - initial: () => 0, - serialize: (v) => v, - deserialize: (v) => v as number, - }); - registry.register({ - id: "b", - scope: "document", - initial: () => 0, - serialize: (v) => v, - deserialize: (v) => v as number, - }); - - expect(registry.all()).toHaveLength(2); - }); - - it("clear removes all states", () => { - registry.register({ - id: "a", - scope: "app", - initial: () => 0, - serialize: (v) => v, - deserialize: (v) => v as number, - }); - registry.clear(); - - expect(registry.all()).toHaveLength(0); - }); -}); diff --git a/apps/desktop/src/renderer/src/lib/state/ShiftState.ts b/apps/desktop/src/renderer/src/lib/state/ShiftState.ts deleted file mode 100644 index 7d5bfb04..00000000 --- a/apps/desktop/src/renderer/src/lib/state/ShiftState.ts +++ /dev/null @@ -1,120 +0,0 @@ -/** - * ShiftState — typed, persistent state primitive. - * - * Tools and subsystems register their own state via editor.registerState(). - * Each ShiftState is a reactive signal with persistence metadata. The - * persistence kernel captures/hydrates all registered states generically — - * no custom modules or Editor delegation methods needed. - * - * App-scoped states persist across documents (settings, preferences). - * Document-scoped states persist per font file (text runs, viewport). - */ -import { signal, type Signal, type WritableSignal } from "@/lib/signals/signal"; - -export type StateScope = "app" | "document"; - -export interface ShiftStateOptions { - /** Unique persistence key. */ - id: string; - /** Whether this state is per-app or per-document. */ - scope: StateScope; - /** Factory for the initial/default value. */ - initial: () => T; - /** Serialize value for localStorage. */ - serialize: (value: T) => unknown; - /** Deserialize and validate from localStorage. Returns the value or throws. */ - deserialize: (json: unknown) => T; -} - -export interface ShiftState { - readonly id: string; - readonly scope: StateScope; - /** Read the current value. Tracks dependency if inside computed/effect. */ - readonly value: T; - /** Read without tracking. */ - peek(): T; - /** Update the value. Triggers reactive subscribers and persistence. */ - set(value: T): void; - /** Reset to initial value. */ - reset(): void; -} - -export class ShiftStateImpl implements ShiftState { - readonly id: string; - readonly scope: StateScope; - readonly #signal: WritableSignal; - readonly #initial: () => T; - readonly #serialize: (value: T) => unknown; - readonly #deserialize: (json: unknown) => T; - - constructor(options: ShiftStateOptions) { - this.id = options.id; - this.scope = options.scope; - this.#initial = options.initial; - this.#serialize = options.serialize; - this.#deserialize = options.deserialize; - this.#signal = signal(options.initial()); - } - - get value(): T { - // oxlint-disable-next-line shift/no-reactive-value-outside-boundary -- ShiftState.value is the public reactive read for this state primitive. - return this.#signal.value; - } - - peek(): T { - return this.#signal.peek(); - } - - set(value: T): void { - this.#signal.set(value); - } - - reset(): void { - this.#signal.set(this.#initial()); - } - - /** @internal Used by persistence kernel. */ - capture(): unknown { - return this.#serialize(this.#signal.peek()); - } - - /** @internal Used by persistence kernel. */ - hydrate(json: unknown): void { - this.#signal.set(this.#deserialize(json)); - } - - /** @knipclassignore — used by persistence kernel via registry.all() */ - get signal(): Signal { - return this.#signal; - } -} - -export class StateRegistry { - #states = new Map>(); - - register(options: ShiftStateOptions): ShiftState { - if (this.#states.has(options.id)) { - throw new Error(`State "${options.id}" already registered`); - } - - const state = new ShiftStateImpl(options); - this.#states.set(options.id, state as ShiftStateImpl); - return state; - } - - getByScope(scope: StateScope): ShiftStateImpl[] { - return Array.from(this.#states.values()).filter((s) => s.scope === scope); - } - - all(): ShiftStateImpl[] { - return Array.from(this.#states.values()); - } - - get(id: string): ShiftStateImpl | undefined { - return this.#states.get(id); - } - - clear(): void { - this.#states.clear(); - } -} diff --git a/apps/desktop/src/renderer/src/persistence/index.ts b/apps/desktop/src/renderer/src/persistence/index.ts deleted file mode 100644 index 6e664519..00000000 --- a/apps/desktop/src/renderer/src/persistence/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { DocumentStatePersistence } from "./kernel"; - -export const documentPersistence = new DocumentStatePersistence(); diff --git a/apps/desktop/src/renderer/src/persistence/kernel.ts b/apps/desktop/src/renderer/src/persistence/kernel.ts deleted file mode 100644 index 32787822..00000000 --- a/apps/desktop/src/renderer/src/persistence/kernel.ts +++ /dev/null @@ -1,442 +0,0 @@ -import type { Editor } from "@/lib/editor/Editor"; -import { effect, type Effect } from "@/lib/signals/signal"; -import { PersistedRootSchema } from "@shift/validation"; -import type { PersistenceModule } from "./module"; -import { toolStateAppModule, toolStateDocumentModule } from "./modules/toolState"; -import { - PERSISTENCE_DOCUMENT_LIMIT, - PERSISTENCE_SCHEMA_VERSION, - type PersistedDocument, - type PersistedModuleEnvelope, - type PersistedRoot, -} from "./types"; - -const STORAGE_KEY = "shift:persisted-state"; -const SAVE_DEBOUNCE_MS = 300; - -function createEmptyState(): PersistedRoot { - return { - version: PERSISTENCE_SCHEMA_VERSION, - registry: { - nextDocId: 1, - pathToDocId: {}, - docIdToPath: {}, - lruDocIds: [], - }, - appModules: {}, - documents: {}, - }; -} - -function normalizeState(raw: unknown): PersistedRoot { - const parsed = PersistedRootSchema.safeParse(raw); - if (!parsed.success) return createEmptyState(); - if (parsed.data.version !== PERSISTENCE_SCHEMA_VERSION) return createEmptyState(); - return parsed.data as PersistedRoot; -} - -function toDocId(numericId: number): string { - return `doc-${numericId}`; -} - -export class DocumentStatePersistence { - #editor: Editor | null = null; - #state: PersistedRoot = createEmptyState(); - #appModules = new Map(); - #documentModules = new Map(); - #effects: Effect[] = []; - #saveTimer: ReturnType | null = null; - #currentDocId: string | null = null; - #currentPath: string | null = null; - #isHydrating = false; - - constructor(extraModules: PersistenceModule[] = []) { - this.registerModule(toolStateAppModule); - this.registerModule(toolStateDocumentModule); - for (const module of extraModules) { - this.registerModule(module); - } - } - - init(editor: Editor): void { - if (this.#editor === editor) return; - this.disposeWatchers(); - this.#editor = editor; - this.#state = this.readState(); - this.hydrateAppModules(); - this.installWatchers(editor); - } - - registerModule(module: PersistenceModule): void { - const target = module.scope === "app" ? this.#appModules : this.#documentModules; - if (target.has(module.id)) { - throw new Error(`Persistence module "${module.id}" already registered`); - } - target.set(module.id, module); - } - - getState(): PersistedRoot { - return this.#editor ? this.#state : this.readState(); - } - - openDocument(filePath: string): void { - if (!this.#editor) return; - this.flushNow(); - - const normalizedPath = this.normalizePath(filePath); - const docId = this.resolveDocId(normalizedPath); - this.#currentDocId = docId; - this.#currentPath = normalizedPath; - this.touchLru(docId); - - this.hydrateDocumentModules(docId); - this.scheduleSave(); - } - - openUntitledDocument(docId: string): void { - if (!this.#editor) return; - this.flushNow(); - - this.#currentDocId = docId; - this.#currentPath = null; - this.touchLru(docId); - - this.hydrateDocumentModules(docId); - this.scheduleSave(); - } - - closeDocument(): void { - this.flushNow(); - this.#currentDocId = null; - this.#currentPath = null; - } - - onDocumentPathChanged(filePath: string | null): void { - if (!filePath) { - this.#currentPath = null; - return; - } - - const normalizedPath = this.normalizePath(filePath); - if (!this.#currentDocId) { - this.#currentDocId = this.resolveDocId(normalizedPath); - this.#currentPath = normalizedPath; - this.touchLru(this.#currentDocId); - this.scheduleSave(); - return; - } - - if ( - this.#currentPath && - this.#state.registry.pathToDocId[this.#currentPath] === this.#currentDocId - ) { - delete this.#state.registry.pathToDocId[this.#currentPath]; - } - - this.#state.registry.pathToDocId[normalizedPath] = this.#currentDocId; - this.#state.registry.docIdToPath[this.#currentDocId] = normalizedPath; - this.#currentPath = normalizedPath; - this.touchLru(this.#currentDocId); - this.scheduleSave(); - } - - flushNow(): void { - if (this.#saveTimer) { - clearTimeout(this.#saveTimer); - this.#saveTimer = null; - } - this.flush(); - } - - async prunePaths(paths: Set): Promise { - const state = this.getState(); - for (const p of paths) { - const docId = state.registry.pathToDocId[p]; - if (!docId) continue; - state.registry.lruDocIds = state.registry.lruDocIds.filter((id) => id !== docId); - delete state.registry.docIdToPath[docId]; - delete state.documents[docId]; - delete state.registry.pathToDocId[p]; - } - this.writeState(state); - } - - async getRecentDocuments(): Promise<{ name: string; path: string }[]> { - const state = this.getState(); - - const paths = new Set( - state.registry.lruDocIds - .slice(0, 10) - .map((docId) => state.registry.docIdToPath[docId]) - .filter((path): path is string => Boolean(path)), - ); - - if (paths.size === 0) return []; - - const documents = Array.from(paths).map((p) => ({ - name: - p - .split("/") - .pop() - ?.replace(/\.(designspace|otf|ttf|ufo|glyphs|woff2?)$/i, "") ?? p, - path: p, - })); - - return documents; - } - - dispose(): void { - this.flushNow(); - this.disposeWatchers(); - this.#editor = null; - } - - private installWatchers(editor: Editor): void { - // Watch all registered ShiftState fields — generic, auto-subscribes - for (const state of editor.stateRegistry.all()) { - this.#effects.push( - effect(() => { - state.signal.value; - this.scheduleSave(); - }), - ); - } - - // Legacy watchers (will be removed as modules migrate to ShiftState) - let lastGlyphUnicode: number | null = null; - this.#effects.push( - effect(() => { - const glyph = editor.glyph.value; - const unicode = glyph?.unicode ?? null; - if (unicode === lastGlyphUnicode) return; - lastGlyphUnicode = unicode; - this.scheduleSave(); - }), - ); - - this.#effects.push( - effect(() => { - editor.toolStateVersionCell.value; - this.scheduleSave(); - }), - ); - } - - private disposeWatchers(): void { - for (const fx of this.#effects) { - fx.dispose(); - } - this.#effects = []; - } - - private hydrateAppModules(): void { - if (!this.#editor) return; - this.#isHydrating = true; - try { - for (const module of this.#appModules.values()) { - const envelope = this.#state.appModules[module.id]; - this.hydrateModule(module, envelope); - } - this.hydrateStates("app", this.#state.appModules); - } finally { - this.#isHydrating = false; - } - } - - private hydrateDocumentModules(docId: string): void { - if (!this.#editor) return; - const documentState = this.ensureDocumentState(docId); - this.#isHydrating = true; - try { - for (const module of this.#documentModules.values()) { - const envelope = documentState.modules[module.id]; - this.hydrateModule(module, envelope); - } - this.hydrateStates("document", documentState.modules); - } finally { - this.#isHydrating = false; - } - } - - private hydrateStates( - scope: "app" | "document", - envelopes: Record, - ): void { - if (!this.#editor) return; - for (const state of this.#editor.stateRegistry.getByScope(scope)) { - const envelope = envelopes[state.id]; - if (!envelope) { - state.reset(); - continue; - } - try { - state.hydrate(envelope.payload); - } catch { - state.reset(); - } - } - } - - private hydrateModule(module: PersistenceModule, envelope?: PersistedModuleEnvelope): void { - if (!this.#editor) return; - if (!envelope) { - if (module.clear) module.clear({ editor: this.#editor }); - return; - } - - let payload: unknown = envelope.payload; - if (envelope.moduleVersion !== module.version) { - if (!module.migrate) return; - payload = module.migrate(payload, envelope.moduleVersion, module.version); - } - - if (!module.validate(payload)) return; - module.hydrate({ editor: this.#editor }, payload); - } - - private captureAppModules(): void { - if (!this.#editor) return; - for (const module of this.#appModules.values()) { - const payload = module.capture({ editor: this.#editor }); - if (payload == null) { - delete this.#state.appModules[module.id]; - continue; - } - this.#state.appModules[module.id] = { - moduleVersion: module.version, - payload, - }; - } - - for (const state of this.#editor.stateRegistry.getByScope("app")) { - this.#state.appModules[state.id] = { - moduleVersion: 1, - payload: state.capture(), - }; - } - } - - private captureCurrentDocumentModules(): void { - if (!this.#editor || !this.#currentDocId) return; - const documentState = this.ensureDocumentState(this.#currentDocId); - documentState.updatedAt = Date.now(); - - for (const module of this.#documentModules.values()) { - const payload = module.capture({ editor: this.#editor }); - if (payload == null) { - delete documentState.modules[module.id]; - continue; - } - documentState.modules[module.id] = { - moduleVersion: module.version, - payload, - }; - } - - for (const state of this.#editor.stateRegistry.getByScope("document")) { - documentState.modules[state.id] = { - moduleVersion: 1, - payload: state.capture(), - }; - } - } - - private flush(): void { - if (!this.#editor) return; - this.captureAppModules(); - this.captureCurrentDocumentModules(); - this.pruneDocuments(); - this.writeState(this.#state); - } - - private scheduleSave(): void { - if (this.#isHydrating || !this.#editor) return; - if (this.#saveTimer) { - clearTimeout(this.#saveTimer); - } - this.#saveTimer = setTimeout(() => { - this.#saveTimer = null; - this.flush(); - }, SAVE_DEBOUNCE_MS); - } - - private ensureDocumentState(docId: string): PersistedDocument { - const existing = this.#state.documents[docId]; - if (existing) return existing; - - const next: PersistedDocument = { - docId, - updatedAt: Date.now(), - modules: {}, - }; - this.#state.documents[docId] = next; - return next; - } - - private resolveDocId(normalizedPath: string): string { - const fromPath = this.#state.registry.pathToDocId[normalizedPath]; - if (fromPath) { - this.#state.registry.docIdToPath[fromPath] = normalizedPath; - return fromPath; - } - - const nextId = toDocId(this.#state.registry.nextDocId); - this.#state.registry.nextDocId += 1; - this.#state.registry.pathToDocId[normalizedPath] = nextId; - this.#state.registry.docIdToPath[nextId] = normalizedPath; - return nextId; - } - - private touchLru(docId: string): void { - const lru = this.#state.registry.lruDocIds; - const existingIdx = lru.indexOf(docId); - if (existingIdx >= 0) { - lru.splice(existingIdx, 1); - } - lru.unshift(docId); - } - - private pruneDocuments(): void { - const lru = this.#state.registry.lruDocIds; - while (lru.length > PERSISTENCE_DOCUMENT_LIMIT) { - const removedDocId = lru.pop(); - if (!removedDocId) break; - if (removedDocId === this.#currentDocId) continue; - - delete this.#state.documents[removedDocId]; - delete this.#state.registry.docIdToPath[removedDocId]; - - for (const [path, docId] of Object.entries(this.#state.registry.pathToDocId)) { - if (docId === removedDocId) { - delete this.#state.registry.pathToDocId[path]; - } - } - } - } - - private readState(): PersistedRoot { - try { - const raw = globalThis.localStorage?.getItem(STORAGE_KEY); - if (!raw) return createEmptyState(); - return normalizeState(JSON.parse(raw)); - } catch { - return createEmptyState(); - } - } - - private writeState(state: PersistedRoot): void { - try { - globalThis.localStorage?.setItem(STORAGE_KEY, JSON.stringify(state)); - } catch { - // Ignore persistence errors (quota, unavailable storage). - } - } - - private normalizePath(filePath: string): string { - const unixLike = filePath.replace(/\\/g, "/"); - if (/^[A-Z]:\//.test(unixLike)) { - return `${unixLike[0].toLowerCase()}${unixLike.slice(1)}`; - } - return unixLike; - } -} diff --git a/apps/desktop/src/renderer/src/persistence/module.ts b/apps/desktop/src/renderer/src/persistence/module.ts deleted file mode 100644 index a3066017..00000000 --- a/apps/desktop/src/renderer/src/persistence/module.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { PersistenceScope } from "./types"; -import type { ToolStateScope } from "@/types/editor"; - -export interface PersistenceEditorAPI { - exportToolState(scope: ToolStateScope): Record; - hydrateToolState(scope: ToolStateScope, state: Record): void; - clearToolState(scope: ToolStateScope): void; -} - -export interface ModuleCaptureContext { - editor: PersistenceEditorAPI; -} - -export interface ModuleHydrateContext { - editor: PersistenceEditorAPI; -} - -export interface PersistenceModule { - id: string; - scope: PersistenceScope; - version: number; - capture(ctx: ModuleCaptureContext): TPayload | null; - hydrate(ctx: ModuleHydrateContext, payload: TPayload): void; - clear?(ctx: ModuleHydrateContext): void; - validate(payload: unknown): payload is TPayload; - migrate?(payload: unknown, fromVersion: number, toVersion: number): TPayload; -} diff --git a/apps/desktop/src/renderer/src/persistence/modules/toolState.ts b/apps/desktop/src/renderer/src/persistence/modules/toolState.ts deleted file mode 100644 index cad842eb..00000000 --- a/apps/desktop/src/renderer/src/persistence/modules/toolState.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { PersistenceModule } from "../module"; -import type { ToolStateScope } from "@/types/editor"; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function createToolStateModule( - id: string, - scope: ToolStateScope, -): PersistenceModule> { - return { - id, - scope, - version: 1, - capture: ({ editor }) => { - const snapshot = editor.exportToolState(scope); - return Object.keys(snapshot).length > 0 ? snapshot : null; - }, - hydrate: ({ editor }, payload) => { - editor.hydrateToolState(scope, payload); - }, - clear: ({ editor }) => { - editor.clearToolState(scope); - }, - validate: (payload: unknown): payload is Record => isRecord(payload), - }; -} - -export const toolStateAppModule = createToolStateModule("tool-state-app", "app"); -export const toolStateDocumentModule = createToolStateModule("tool-state-document", "document"); diff --git a/apps/desktop/src/renderer/src/persistence/types.ts b/apps/desktop/src/renderer/src/persistence/types.ts deleted file mode 100644 index cd4c33da..00000000 --- a/apps/desktop/src/renderer/src/persistence/types.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { - PersistedDocument, - PersistedModuleEnvelope, - PersistedRoot, - PersistenceRegistry, - PersistedTextRun, - TextRunModule, -} from "@shift/validation"; - -export const PERSISTENCE_SCHEMA_VERSION = 1; -export const PERSISTENCE_DOCUMENT_LIMIT = 100; - -export type PersistenceScope = "app" | "document"; -export type { - PersistedModuleEnvelope, - PersistedDocument, - PersistenceRegistry, - PersistedRoot, - TextRunModule, - PersistedTextRun, -}; diff --git a/apps/desktop/src/renderer/src/store/store.ts b/apps/desktop/src/renderer/src/store/store.ts index f483f4d9..6d02aab6 100644 --- a/apps/desktop/src/renderer/src/store/store.ts +++ b/apps/desktop/src/renderer/src/store/store.ts @@ -1,6 +1,5 @@ import { Editor } from "@/lib/editor/Editor"; import { Document } from "@/app/Document"; -import { documentPersistence } from "@/persistence"; import { electronSystemClipboard } from "@/lib/clipboard"; import { registerBuiltInTools } from "@/lib/tools/tools"; import { create } from "zustand"; @@ -72,7 +71,6 @@ const createStore = (set: StoreApi["setState"]): AppState => { }; const document = new Document(editor, { - persistence: documentPersistence, setFilePath, clearDirty, notifySaveCompleted: (path) => window.electronAPI?.saveCompleted(path), diff --git a/apps/desktop/src/renderer/src/views/RecentFiles.tsx b/apps/desktop/src/renderer/src/views/RecentFiles.tsx index 22070924..d808adb4 100644 --- a/apps/desktop/src/renderer/src/views/RecentFiles.tsx +++ b/apps/desktop/src/renderer/src/views/RecentFiles.tsx @@ -1,72 +1,7 @@ -import { useEffect, useState } from "react"; -import { documentPersistence } from "@/persistence"; -import { Button } from "@shift/ui"; - -const VISIBLE_COUNT = 5; - -function shortenPath(path: string): string { - const home = window.electronAPI?.homePath ?? ""; - if (home && path.startsWith(home)) { - return "~" + path.slice(home.length); - } - return path; -} - -function FileRow({ - file, - onOpenFile, -}: { - file: { name: string; path: string }; - onOpenFile: (path: string) => void; -}) { - return ( - - ); -} - interface RecentFilesProps { onOpenFile: (path: string) => void; } -export const RecentFiles = ({ onOpenFile }: RecentFilesProps) => { - const [recentFiles, setRecentFiles] = useState<{ name: string; path: string }[]>([]); - - useEffect(() => { - const fetchRecentFiles = async () => { - const documents = await documentPersistence.getRecentDocuments(); - if (window.electronAPI) { - const exists = await window.electronAPI.pathsExist(documents.map((d) => d.path)); - const prune = documents.filter((_, i) => !exists[i]); - await documentPersistence.prunePaths(new Set(prune.map((d) => d.path))); - } - - setRecentFiles(documents); - }; - - fetchRecentFiles(); - }, []); - - if (recentFiles.length === 0) return null; - - const visibleFiles = recentFiles.slice(0, VISIBLE_COUNT); - - return ( -
- Recent files -
- {visibleFiles.map((file) => ( - - ))} -
-
- ); +export const RecentFiles = (_props: RecentFilesProps) => { + return null; }; diff --git a/packages/validation/docs/DOCS.md b/packages/validation/docs/DOCS.md index ee422364..b37900ff 100644 --- a/packages/validation/docs/DOCS.md +++ b/packages/validation/docs/DOCS.md @@ -1,14 +1,13 @@ # Validation -Point sequence validation and persistence schema checking for the Shift font editor. +Point sequence and clipboard payload validation for the Shift font editor. ## Architecture Invariants - **Architecture Invariant:** Every validator returns a `ValidationResult` discriminated union -- callers branch on `valid` and access either `.value` (parsed payload) or `.errors` (structured `ValidationError[]`). Never throw from validators. - **Architecture Invariant:** Point sequences must start and end with `onCurve` points. At most 2 consecutive `offCurve` points are allowed (cubic bezier). 3+ consecutive off-curve points are always invalid. - **Architecture Invariant:** `Validate` methods come in pairs: a `ValidationResult`-returning variant for detailed errors (e.g. `canFormSegments`) and a boolean shortcut for hot paths (e.g. `canFormValidSegments`). The boolean variants must enforce identical rules without allocating error objects. -- **Architecture Invariant:** Clipboard and persistence validation check serialized boundary payloads only. Editor/runtime glyph state validation belongs with the source-aware glyph model, not snapshot-era DTOs. -- **Architecture Invariant:** Persistence schemas use Zod and are the single source of truth for on-disk format shape. The inferred types (e.g. `PersistedRoot`) are derived from schemas, never hand-written separately. +- **Architecture Invariant:** Clipboard validation checks serialized boundary payloads only. Editor/runtime glyph state validation belongs with the source-aware glyph model, not snapshot-era DTOs. ## Codemap @@ -17,7 +16,6 @@ validation/src/ types.ts -- ValidationResult, ValidationError, ValidationErrorCode, PointLike Validate.ts -- point sequence rules: ordering, segment formation, anchor checks ValidateClipboard.ts -- clipboard payload shape checks (contours, points, metadata) - persistence.ts -- Zod schemas for persisted document state and text runs index.ts -- public barrel export ``` @@ -29,9 +27,6 @@ validation/src/ - `PointLike` -- minimal `{ pointType: PointType }` interface accepted by all point-sequence validators. Full `Point` objects, snapshots, and test stubs all satisfy it. - `Validate` -- namespace object with point predicates (`isOnCurve`, `isOffCurve`), pattern matchers (`matchesLinePattern`, `matchesQuadPattern`, `matchesCubicPattern`), sequence validators (`sequence`, `canFormSegments`), boolean shortcuts (`isValidSequence`, `canFormValidSegments`, `hasValidAnchor`), and result constructors (`ok`, `fail`, `error`). - `ValidateClipboard` -- namespace object with `isClipboardContent` (validates contour array shape) and `isClipboardPayload` (validates full `shift/glyph-data` envelope with format, version, metadata, content). -- `PersistedRootSchema` -- top-level Zod schema for the entire persisted state file (registry, app modules, documents). -- `PersistedDocumentSchema` -- Zod schema for a single document's persisted state (docId, updatedAt, modules map). -- `TextRunModuleSchema` -- Zod schema for the text-run persistence module payload. ## How it works @@ -47,10 +42,6 @@ Boolean shortcuts (`isValidSequence`, `canFormValidSegments`, `hasValidAnchor`) `ValidateClipboard.isClipboardContent` validates the contour/point structure of clipboard data. `isClipboardPayload` checks the full envelope (format string `"shift/glyph-data"`, version number, metadata with timestamp). Used by `Clipboard` when parsing pasted content. -### Persistence schemas - -Zod schemas in `persistence.ts` validate the shape of data read from disk. `PersistedRootSchema` is the top-level schema parsed in the persistence kernel on app startup. Types like `PersistedRoot` are inferred from the schemas via `z.infer`, keeping the schema and type in lockstep. - ## Workflow recipes ### Add a new validation error code @@ -59,17 +50,11 @@ Zod schemas in `persistence.ts` validate the shape of data read from disk. `Pers 2. Use it in a validator via `Validate.error("YOUR_CODE", "message")` 3. Add test cases covering the new failure path -### Add a new persistence schema - -1. Define the Zod schema in `persistence.ts` -2. Export the schema and inferred type from `index.ts` -3. Add a test case in `persistence.test.ts` with valid and invalid payloads - ## Gotchas - `Validate.sequence` accepts a single `onCurve` point as valid, but `Validate.canFormSegments` requires at least 2 points. Use the right one depending on whether you need drawable segments or just a well-formed sequence. - `ValidateClipboard.isClipboardPayload` hardcodes the format string `"shift/glyph-data"`. If the clipboard format changes, this must be updated in sync. -- The `PointLike` interface only requires `pointType` -- validators do not check coordinates or IDs. Use clipboard or persistence validators at serialization boundaries when full structural validation is needed. +- The `PointLike` interface only requires `pointType` -- validators do not check coordinates or IDs. Use clipboard validators at serialization boundaries when full structural validation is needed. ## Verification @@ -85,6 +70,4 @@ cd packages/validation && npx tsc --noEmit - `Clipboard` -- uses `Validate.hasValidAnchor` for copy eligibility and `ValidateClipboard.isClipboardContent` for paste parsing - `Segments` / `Segment` -- uses `Validate.isOnCurve` / `Validate.isOffCurve` for segment decomposition -- `Editor` -- parses `TextRunModuleSchema` from persisted state -- `persistence/kernel` -- parses `PersistedRootSchema` on app startup - `PointType` from `@shift/types` -- the underlying union (`"onCurve" | "offCurve"`) that `PointLike` wraps diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts index 04716af9..130d6ff8 100644 --- a/packages/validation/src/index.ts +++ b/packages/validation/src/index.ts @@ -23,21 +23,5 @@ export { Validate } from "./Validate"; export { ValidateClipboard } from "./ValidateClipboard"; -export { - PersistedTextRunSchema, - TextRunModuleSchema, - PersistedModuleEnvelopeSchema, - PersistenceRegistrySchema, - PersistedDocumentSchema, - PersistedRootSchema, -} from "./persistence"; export type { ValidationResult, ValidationError, ValidationErrorCode, PointLike } from "./types"; -export type { - PersistedTextRun, - TextRunModule, - PersistedModuleEnvelope, - PersistenceRegistry, - PersistedDocument, - PersistedRoot, -} from "./persistence"; diff --git a/packages/validation/src/persistence.test.ts b/packages/validation/src/persistence.test.ts deleted file mode 100644 index 419ccd45..00000000 --- a/packages/validation/src/persistence.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { PersistedRootSchema, TextRunModuleSchema } from "./persistence"; - -describe("persistence schemas", () => { - it("accepts a valid persisted root payload", () => { - const payload = { - version: 1, - registry: { - nextDocId: 2, - pathToDocId: { "/tmp/a.ufo": "doc-1" }, - docIdToPath: { "doc-1": "/tmp/a.ufo" }, - lruDocIds: ["doc-1"], - }, - appModules: { - "user-preferences": { moduleVersion: 1, payload: {} }, - }, - documents: { - "doc-1": { - docId: "doc-1", - updatedAt: 123, - modules: { - "text-run": { - moduleVersion: 1, - payload: { - runsByGlyph: { - "65": { - buffer: { - items: [ - { - id: "a1", - kind: "glyph", - glyphName: "A", - codepoint: 65, - }, - { - id: "b1", - kind: "glyph", - glyphName: "B", - codepoint: 66, - }, - ], - cursor: 2, - anchor: 2, - originX: 100, - }, - }, - }, - }, - }, - }, - }, - }, - }; - - const result = PersistedRootSchema.safeParse(payload); - expect(result.success).toBe(true); - }); - - it("rejects invalid text-run payload", () => { - const result = TextRunModuleSchema.safeParse({ - runsByGlyph: { - "65": { - buffer: { - items: [{ id: "a1", kind: "glyph", glyphName: "A", codepoint: 65 }], - cursor: "1", - anchor: 0, - originX: 0, - }, - }, - }, - }); - - expect(result.success).toBe(false); - }); -}); diff --git a/packages/validation/src/persistence.ts b/packages/validation/src/persistence.ts deleted file mode 100644 index e5f2a9a6..00000000 --- a/packages/validation/src/persistence.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { z } from "zod"; - -export const GlyphTextItemSchema = z.object({ - id: z.string().min(1), - kind: z.literal("glyph"), - glyphName: z.string().min(1), - codepoint: z.number().int().nonnegative().nullable(), -}); - -export const LineBreakTextItemSchema = z.object({ - id: z.string().min(1), - kind: z.literal("linebreak"), -}); - -export const TextItemSchema = z.discriminatedUnion("kind", [ - GlyphTextItemSchema, - LineBreakTextItemSchema, -]); - -export const TextBufferSnapshotSchema = z.object({ - items: z.array(TextItemSchema), - cursor: z.number().int().nonnegative(), - anchor: z.number().int().nonnegative(), - originX: z.number().finite(), -}); - -export const PersistedTextRunSchema = z.object({ - buffer: TextBufferSnapshotSchema, -}); - -export const TextRunModuleSchema = z.object({ - runsByGlyph: z.record(z.string(), PersistedTextRunSchema), -}); - -export const PersistedModuleEnvelopeSchema = z.object({ - moduleVersion: z.number().int().nonnegative(), - payload: z.unknown(), -}); - -export const PersistenceRegistrySchema = z.object({ - nextDocId: z.number().int().positive(), - pathToDocId: z.record(z.string(), z.string()), - docIdToPath: z.record(z.string(), z.string()), - lruDocIds: z.array(z.string()), -}); - -export const PersistedDocumentSchema = z.object({ - docId: z.string(), - updatedAt: z.number().finite(), - modules: z.record(z.string(), PersistedModuleEnvelopeSchema), -}); - -export const PersistedRootSchema = z.object({ - version: z.number().int().positive(), - registry: PersistenceRegistrySchema, - appModules: z.record(z.string(), PersistedModuleEnvelopeSchema), - documents: z.record(z.string(), PersistedDocumentSchema), -}); - -export type PersistedTextRun = z.infer; -export type TextRunModule = z.infer; -export type PersistedModuleEnvelope = z.infer; -export type PersistenceRegistry = z.infer; -export type PersistedDocument = z.infer; -export type PersistedRoot = z.infer;