From 3858b11bf97af65a5c970777fee51a63589d84db Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Sat, 19 Sep 2026 21:26:53 +1000 Subject: [PATCH] refactor(app): plain codecs for persisted state, starting with tabs Adds an Effect-free codec module for persisted stores with the same recovery semantics as the Persistence helpers, lets persisted() accept either a Schema or a codec while stores migrate, and ports the tab storage schemas as the first store. ServerKey keeps its brand type on both sides so ServerConnection.Key stays one type. Effect leaves the renderer's initial module graph once the remaining stores are ported. --- .../runtime/persistence/codec-types.test.ts | 17 + .../app/src/runtime/persistence/codec.test.ts | 85 +++++ packages/app/src/runtime/persistence/codec.ts | 324 ++++++++++++++++++ .../app/src/runtime/persistence/storage.ts | 55 ++- packages/app/src/runtime/server/key.ts | 11 + packages/app/src/shell/state/layout.tsx | 4 +- packages/app/src/shell/tabs/schema.ts | 95 +++-- packages/app/src/shell/tabs/tabs.test.ts | 29 +- 8 files changed, 543 insertions(+), 77 deletions(-) create mode 100644 packages/app/src/runtime/persistence/codec-types.test.ts create mode 100644 packages/app/src/runtime/persistence/codec.test.ts create mode 100644 packages/app/src/runtime/persistence/codec.ts create mode 100644 packages/app/src/runtime/server/key.ts diff --git a/packages/app/src/runtime/persistence/codec-types.test.ts b/packages/app/src/runtime/persistence/codec-types.test.ts new file mode 100644 index 00000000000..19e24bd636d --- /dev/null +++ b/packages/app/src/runtime/persistence/codec-types.test.ts @@ -0,0 +1,17 @@ +import { expect, test } from "bun:test" +import { Codec } from "./codec" + +// Type-level checks: these compile only if inference matches what `typeof schema.Type` gave callers. +test("struct types infer optional and required fields", () => { + const s = Codec.struct({ id: Codec.string, tab: Codec.optional(Codec.string), n: Codec.lenientOptional(Codec.number) }) + const value: typeof s.Type = { id: "x" } + const tab: string | undefined = value.tab + const n: number | undefined = value.n + const t = Codec.transform(s, { decode: (old) => old.tab ?? old.id, encode: (v) => ({ id: v }) }) + const out: string | Codec.Invalid = t.decode({ id: "a" }) + const onlyOptional = Codec.struct({ tab: Codec.optional(Codec.string) }) + const empty: typeof onlyOptional.Type = {} + const maybe: string | undefined = empty.tab + const viaTransform = Codec.transform(onlyOptional, { decode: (old) => old.tab, encode: (tab) => ({ tab }) }) + expect([tab, n, out, maybe, viaTransform.decode({})]).toEqual([undefined, undefined, "a", undefined, undefined]) +}) diff --git a/packages/app/src/runtime/persistence/codec.test.ts b/packages/app/src/runtime/persistence/codec.test.ts new file mode 100644 index 00000000000..077dc9425bf --- /dev/null +++ b/packages/app/src/runtime/persistence/codec.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test } from "bun:test" +import { Codec } from "./codec" + +describe("Codec", () => { + test("primitives reject the wrong shape and finite numbers only", () => { + expect(Codec.string.decode("a")).toBe("a") + expect(Codec.string.decode(1)).toBe(Codec.INVALID) + expect(Codec.number.decode(1.5)).toBe(1.5) + expect(Codec.number.decode(Number.NaN)).toBe(Codec.INVALID) + expect(Codec.nonNegativeInt.decode(-1)).toBe(Codec.INVALID) + expect(Codec.literals(["a", "b"]).decode("c")).toBe(Codec.INVALID) + expect(Codec.literal("x").decode("x")).toBe("x") + }) + + test("struct keeps optional fields absent and rejects invalid required ones", () => { + const codec = Codec.struct({ id: Codec.string, title: Codec.optional(Codec.string), n: Codec.lenientOptional(Codec.number) }) + expect(codec.decode({ id: "1" })).toEqual({ id: "1" }) + expect(codec.decode({ id: "1", title: "t", n: "bad" })).toEqual({ id: "1", title: "t" }) + expect(codec.decode({ id: "1", title: 3 })).toBe(Codec.INVALID) + expect(codec.decode({ title: "t" })).toBe(Codec.INVALID) + expect(codec.decode([])).toBe(Codec.INVALID) + expect(codec.encode({ id: "1" })).toEqual({ id: "1" }) + const value: typeof codec.Type = { id: "1", title: undefined } + expect(value.id).toBe("1") + }) + + test("lenient collections recover what they can", () => { + const items = Codec.lenientArray(Codec.struct({ id: Codec.string })) + expect(items.decode([{ id: "a" }, { id: 1 }, "x", { id: "b" }])).toEqual([{ id: "a" }, { id: "b" }]) + expect(items.decode("nope")).toEqual([]) + expect(Codec.array(Codec.string).decode(["a", 1])).toBe(Codec.INVALID) + const map = Codec.lenientRecord(Codec.boolean) + expect(map.decode({ a: true, b: "x" })).toEqual({}) + expect(map.decode({ a: true })).toEqual({ a: true }) + }) + + test("union, transform and fallback compose", () => { + const session = Codec.struct({ type: Codec.literal("session"), id: Codec.string }) + const draft = Codec.struct({ type: Codec.literal("draft"), directory: Codec.string }) + const tab = Codec.union([session, draft]) + expect(tab.decode({ type: "draft", directory: "/x" })).toEqual({ type: "draft", directory: "/x" }) + expect(tab.decode({ type: "other" })).toBe(Codec.INVALID) + const upper = Codec.transform(Codec.string, { decode: (s) => s.toUpperCase(), encode: (s) => s.toLowerCase() }) + expect(upper.decode("ab")).toBe("AB") + expect(upper.encode("AB")).toBe("ab") + const safe = Codec.fallback(Codec.number, () => 7) + expect(safe.decode("x")).toBe(7) + expect(safe.decode(undefined)).toBe(7) + expect(safe.decode(2)).toBe(2) + }) + + test("brand constructs and decodes as its base", () => { + const Key = Codec.brand<"ServerConnection.Key">() + const key = Key.make("http://a") + expect(Key.decode(key)).toBe(key) + expect(Key.decode(3)).toBe(Codec.INVALID) + }) + + test("withInitial recovers field by field and merges new defaults", () => { + const layout = Codec.struct({ + sidebar: Codec.struct({ opened: Codec.boolean, width: Codec.number }), + theme: Codec.lenientOptional(Codec.literals(["light", "dark"])), + }) + const initial: typeof layout.Type = { sidebar: { opened: true, width: 240 } } + const codec = Codec.fromJsonString(Codec.withInitial(layout, initial)) + expect(codec.decode(JSON.stringify({ sidebar: { opened: false, width: "wide" }, theme: "dark" }))).toEqual({ + sidebar: { opened: false, width: 240 }, + theme: "dark", + }) + expect(codec.decode(JSON.stringify({ sidebar: 5 }))).toEqual(initial) + expect(codec.decode("{not json")).toBe(Codec.INVALID) + expect(JSON.parse(codec.encode({ sidebar: { opened: true, width: 1 } }))).toEqual({ sidebar: { opened: true, width: 1 } }) + }) + + test("migrate reads the old shape first", () => { + const current = Codec.struct({ tabs: Codec.array(Codec.string) }) + const previous = Codec.struct({ tab: Codec.optional(Codec.string) }) + const read = Codec.transform(previous, { + decode: (old) => ({ tabs: old.tab ? [old.tab] : [] }), + encode: (value) => ({ tab: value.tabs[0] }), + }) + const codec = Codec.withInitial(Codec.migrate(current, read), { tabs: [] }) + expect(codec.decode({ tab: "a" })).toEqual({ tabs: ["a"] }) + }) +}) diff --git a/packages/app/src/runtime/persistence/codec.ts b/packages/app/src/runtime/persistence/codec.ts new file mode 100644 index 00000000000..ce0a0df7353 --- /dev/null +++ b/packages/app/src/runtime/persistence/codec.ts @@ -0,0 +1,324 @@ +export * as Codec from "./codec" + +// Plain codecs for persisted state. They replace Effect Schema in the renderer's initial module +// graph, where Effect's own module initialisation was the single largest startup cost that was not +// rendering. Semantics mirror the Persistence helpers: decoding never throws, `INVALID` marks a +// value that cannot be recovered, and the lenient combinators recover what they can. + +export const INVALID: unique symbol = Symbol.for("opencode/persistence/codec/invalid") +export type Invalid = typeof INVALID + +const tag: unique symbol = Symbol.for("opencode/persistence/codec") + +export interface Of { + readonly [tag]: true + /** Phantom: `typeof codec.Type` is the decoded type, as with Effect schemas. */ + readonly Type: T + readonly Encoded: E + readonly optional?: boolean + decode(input: unknown): T | Invalid + encode(value: T): E +} + +export type Any = Of +export type Type = C["Type"] + +export function isCodec(value: unknown): value is Any { + return typeof value === "object" && value !== null && tag in value +} + +export function make(decode: (input: unknown) => T | Invalid, encode: (value: T) => E): Of { + return { [tag]: true, decode, encode } as Of +} + +export function is(codec: Of, input: unknown): input is T { + return codec.decode(input) !== INVALID +} + +export function decodeOption(codec: Of, input: unknown): T | undefined { + const value = codec.decode(input) + return value === INVALID ? undefined : value +} + +export function decodeOrThrow(codec: Of, input: unknown): T { + const value = codec.decode(input) + if (value === INVALID) throw new Error("Value does not match its codec") + return value +} + +const identity = (value: T) => value + +export const string: Of = make((v) => (typeof v === "string" ? v : INVALID), identity) +export const boolean: Of = make((v) => (typeof v === "boolean" ? v : INVALID), identity) +export const unknown: Of = make((v) => v, identity) +/** Finite numbers only: NaN and infinities are not JSON and never valid state. */ +export const number: Of = make((v) => (typeof v === "number" && Number.isFinite(v) ? v : INVALID), identity) +export const int: Of = make((v) => (typeof v === "number" && Number.isInteger(v) ? v : INVALID), identity) +export const nonNegativeInt: Of = make( + (v) => (typeof v === "number" && Number.isInteger(v) && v >= 0 ? v : INVALID), + identity, +) + +export function literal(value: L): Of { + return make((v) => (v === value ? value : INVALID), identity) +} + +export function literals>(values: L): Of { + const set = new Set(values) + return make((v) => (set.has(v) ? (v as L[number]) : INVALID), identity) +} + +/** A string carrying a nominal brand, with the constructor Effect's `Schema.brand` gave callers. */ +export function brand(): Of & { + make(value: string): string & { readonly [K in B]: B } +} { + return Object.assign(make((v) => (typeof v === "string" ? (v as never) : INVALID), identity), { + make: (value: string) => value as never, + }) +} + +export function nullOr(codec: Of): Of { + return make((v) => (v === null ? null : codec.decode(v)), (v) => (v === null ? null : codec.encode(v))) +} + +export function undefinedOr(codec: Of): Of { + return make((v) => (v === undefined ? undefined : codec.decode(v)), (v) => (v === undefined ? undefined : codec.encode(v))) +} + +/** A struct field that may be absent. Present but invalid values make the struct invalid. */ +export function optional(codec: Of): Of & { readonly optional: true } { + return { ...undefinedOr(codec), optional: true } as never +} + +/** A struct field that may be absent, and whose invalid values are dropped rather than rejected. */ +export function lenientOptional(codec: Of): Of & { readonly optional: true } { + return { + ...make( + (v) => { + if (v === undefined) return undefined + const value = codec.decode(v) + return value === INVALID ? undefined : value + }, + (v) => (v === undefined ? undefined : codec.encode(v)), + ), + optional: true, + } as never +} + +type Fields = Record +type OptionalKeys = { [K in keyof F]: F[K] extends { optional: true } ? K : never }[keyof F] +type RequiredKeys = Exclude> +type Simplify = { [K in keyof T]: T[K] } & {} +export type StructType = Simplify< + { [K in RequiredKeys]: F[K]["Type"] } & { [K in OptionalKeys]?: F[K]["Type"] } +> +export type StructEncoded = Simplify< + { [K in RequiredKeys]: F[K]["Encoded"] } & { [K in OptionalKeys]?: F[K]["Encoded"] } +> + +export interface Struct extends Of, StructEncoded> { + readonly fields: F +} + +export function struct(fields: F): Struct { + const entries = Object.entries(fields) + return { + ...make, StructEncoded>( + (input) => { + if (typeof input !== "object" || input === null || Array.isArray(input)) return INVALID + const record = input as Record + const out: Record = {} + for (const [key, codec] of entries) { + const present = Object.hasOwn(record, key) + if (!present && codec.optional) continue + const value = codec.decode(record[key]) + if (value === INVALID) return INVALID + if (value !== undefined || present) out[key] = value + } + return out as StructType + }, + (value) => { + const out: Record = {} + for (const [key, codec] of entries) { + const field = (value as Record)[key] + if (field === undefined && !Object.hasOwn(value as object, key)) continue + out[key] = codec.encode(field) + } + return out as StructEncoded + }, + ), + fields, + } +} + +export function array(codec: Of): Of { + return make( + (input) => { + if (!Array.isArray(input)) return INVALID + const out: T[] = [] + for (const item of input) { + const value = codec.decode(item) + if (value === INVALID) return INVALID + out.push(value) + } + return out + }, + (value) => value.map((item) => codec.encode(item)), + ) +} + +/** Keeps the items that decode and drops the rest, like `Persistence.array`. */ +export function lenientArray(codec: Of): Of { + return make( + (input) => { + if (!Array.isArray(input)) return [] + return input.flatMap((item) => { + const value = codec.decode(item) + return value === INVALID ? [] : [value] + }) + }, + (value) => value.map((item) => codec.encode(item)), + ) +} + +export function record(codec: Of): Of, Record> { + return make( + (input) => { + if (typeof input !== "object" || input === null || Array.isArray(input)) return INVALID + const out: Record = {} + for (const [key, item] of Object.entries(input)) { + const value = codec.decode(item) + if (value === INVALID) return INVALID + out[key] = value + } + return out + }, + (value) => Object.fromEntries(Object.entries(value).map(([key, item]) => [key, codec.encode(item)])), + ) +} + +/** An invalid record becomes empty rather than failing the whole store, like `Persistence.record`. */ +export function lenientRecord(codec: Of): Of, Record> { + const strict = record(codec) + return make( + (input) => { + const value = strict.decode(input) + return value === INVALID ? {} : value + }, + strict.encode, + ) +} + +export function union>(codecs: C): Of { + return make( + (input) => { + for (const codec of codecs) { + const value = codec.decode(input) + if (value !== INVALID) return value + } + return INVALID + }, + (value) => { + // Encode with the first member that accepts the value's shape; members are disjoint in practice. + for (const codec of codecs) if (codec.decode(value) !== INVALID) return codec.encode(value) + return value as C[number]["Encoded"] + }, + ) +} + +/** Maps a decoded value into another shape, the replacement for `decodeTo` + `SchemaGetter.transform`. */ +export function transform( + codec: Of, + options: { decode: (value: T) => T2; encode: (value: T2) => T }, +): Of { + return make( + (input) => { + const value = codec.decode(input) + return value === INVALID ? INVALID : options.decode(value) + }, + (value) => codec.encode(options.encode(value)), + ) +} + +/** Invalid and missing values become `value()`, like `Persistence.fallback`. */ +export function fallback(codec: Of, value: () => T): Of { + return make( + (input) => { + if (input === undefined) return value() + const decoded = codec.decode(input) + return decoded === INVALID ? value() : decoded + }, + codec.encode, + ) +} + +export function fromJsonString(codec: Of): Of { + return make( + (input) => { + if (typeof input !== "string") return INVALID + try { + return codec.decode(JSON.parse(input)) + } catch { + return INVALID + } + }, + (value) => JSON.stringify(codec.encode(value)), + ) +} + +export type Migrated = { readonly current: C; readonly read: Of } + +/** Older stored shapes go through `read` first; `current` describes what the store holds today. */ +export function migrate(current: C, read: Of): Migrated { + return { current, read } +} + +function isMigrated(definition: C | Migrated): definition is Migrated { + return !isCodec(definition) && "current" in definition +} + +// Stored values recover field by field against the initial value: an object's valid fields are +// kept, invalid or missing ones take their initial counterpart, and the result is merged over the +// initial so new fields appear with their defaults. Mirrors `Persistence.withInitial`. +export function withInitial(definition: C | Migrated, initial: Type): Of, unknown> { + const codec = isMigrated(definition) ? definition.current : definition + const read = isMigrated(definition) ? definition.read : unknown + return make( + (input) => { + const stored = read.decode(input) + if (stored === INVALID) return INVALID + return merge(initial, recover(codec, stored, initial)) + }, + (value) => codec.encode(value), + ) +} + +function recover(codec: Any, value: unknown, initial: unknown): unknown { + if (value === undefined) return initial + if ("fields" in codec && isObject(value)) { + const fields = (codec as Struct).fields + return Object.fromEntries( + Object.entries(fields).flatMap(([name, field]) => { + const defaults = isObject(initial) ? initial[name] : undefined + const next = recover(field, value[name], defaults) + if (next === undefined && !Object.hasOwn(value, name) && defaults === undefined) return [] + return [[name, next]] + }), + ) + } + const decoded = codec.decode(value) + return decoded === INVALID ? initial : decoded +} + +function merge(initial: unknown, value: unknown): unknown { + if (value === undefined) return initial + if (!isObject(initial) || !isObject(value)) return value + return Object.fromEntries( + [...new Set([...Object.keys(initial), ...Object.keys(value)])].map((key) => [key, merge(initial[key], value[key])]), + ) +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + diff --git a/packages/app/src/runtime/persistence/storage.ts b/packages/app/src/runtime/persistence/storage.ts index 6984466788e..3553f1a0299 100644 --- a/packages/app/src/runtime/persistence/storage.ts +++ b/packages/app/src/runtime/persistence/storage.ts @@ -6,6 +6,7 @@ import { createStore, type SetStoreFunction, type Store } from "solid-js/store" import { Option, Schema } from "effect" import { pathKey } from "@/workspaces/path-key" import { ScopedKey, ServerScope } from "@/runtime/server/scope" +import { Codec } from "./codec" import { persistStore } from "./persist" import { Persistence } from "./schema" @@ -472,25 +473,57 @@ export function removePersisted( } } -export function persisted>( +type Definition | Codec.Any> = + | S + | Persistence.Migrated>> + | Codec.Migrated> + +// Persisted stores are moving from Effect Schema to the plain codecs in ./codec so the renderer +// stops paying for Effect at startup; both are accepted while the migration is underway. +function serializer | Codec.Any>( + definition: Definition, + initial: S["Type"], +) { + if (Codec.isCodec(definition) || (!("current" in definition) ? false : Codec.isCodec(definition.current))) { + const codec = Codec.withInitial(definition as Codec.Any | Codec.Migrated, initial) + const json = Codec.fromJsonString(codec) + return { + decode: (raw: string) => Codec.decodeOption(json, raw) as S["Type"] | undefined, + deserialize: (raw: unknown) => Codec.decodeOrThrow(json, raw) as S["Type"], + serialize: (value: S["Type"]) => json.encode(value), + encode: (value: S["Type"]) => codec.encode(value), + initial: Codec.decodeOrThrow(codec, codec.encode(initial)) as S["Type"], + } + } + const schema = definition as Schema.ConstraintCodec | Persistence.Migrated> + const initialized = Persistence.withInitial(schema, initial as object) + const json = Schema.fromJsonString(initialized) + const decode = Schema.decodeUnknownOption(json) + return { + decode: (raw: string) => Option.getOrUndefined(decode(raw)) as S["Type"] | undefined, + deserialize: Schema.decodeUnknownSync(json) as (raw: unknown) => S["Type"], + serialize: Schema.encodeSync(json) as (value: S["Type"]) => string, + encode: Schema.encodeSync(initialized) as (value: S["Type"]) => unknown, + initial: Schema.decodeUnknownSync(Schema.toType(initialized))(initial as object) as S["Type"], + } +} + +export function persisted | Codec.Any>( target: string | PersistTarget, - schema: S | Persistence.Migrated, + schema: Definition, initial: NoInfer, platformOverride?: Platform, ): PersistedWithReady { const platform = platformOverride ?? usePlatform() const config = resolveTarget(typeof target === "string" ? { key: target } : target, platform) - const initialized = Persistence.withInitial(schema, initial) - const json = Schema.fromJsonString(initialized) - const decode = Schema.decodeUnknownOption(json) - const encode = Schema.encodeSync(initialized) - const serialize = Schema.encodeSync(json) + const codec = serializer(schema, initial) + const { encode, serialize } = codec const normalize = (raw: string) => { - const value = decode(raw) - if (Option.isSome(value)) return serialize(value.value) + const value = codec.decode(raw) + if (value !== undefined) return serialize(value) } - const store = createStore(Schema.decodeUnknownSync(Schema.toType(initialized))(initial)) + const store = createStore(codec.initial) const isDesktop = platform.platform === "desktop" && !!platform.storage const draft = config.draft ? platform.draftStore : undefined const prefix = `${config.storage ?? "default"}:` @@ -602,7 +635,7 @@ export function persisted>( name: config.key, storage, serialize, - deserialize: Schema.decodeUnknownSync(json), + deserialize: codec.deserialize, sync: channel ? messageSync(channel) : undefined, // Drafts take the encoded document itself so large text is externalized without the store // re-parsing the serialized form on every save. diff --git a/packages/app/src/runtime/server/key.ts b/packages/app/src/runtime/server/key.ts new file mode 100644 index 00000000000..4d4b7683bc9 --- /dev/null +++ b/packages/app/src/runtime/server/key.ts @@ -0,0 +1,11 @@ +import type { Brand } from "effect" +import { Codec } from "@/runtime/persistence/codec" + +// The server key's brand is shared with the Effect schema in ./persistence.ts (type only, so this +// module loads nothing of Effect), letting stores port to plain codecs one at a time. +export type ServerKey = string & Brand.Brand<"ServerConnection.Key"> + +export const ServerKey: Codec.Of & { make(value: string): ServerKey } = Object.assign( + Codec.make((v) => (typeof v === "string" ? (v as ServerKey) : Codec.INVALID), (v) => v), + { make: (value: string) => value as ServerKey }, +) diff --git a/packages/app/src/shell/state/layout.tsx b/packages/app/src/shell/state/layout.tsx index 4cf523b55c7..93ee3bfbe6d 100644 --- a/packages/app/src/shell/state/layout.tsx +++ b/packages/app/src/shell/state/layout.tsx @@ -9,7 +9,7 @@ import { usePlatform } from "@/runtime/platform/platform" import type { Project } from "@/runtime/server/types" import { Persist, persisted, removePersisted } from "@/runtime/persistence/storage" import { Persistence } from "@/runtime/persistence/schema" -import { TabStorage } from "@/shell/tabs/schema" +import { ServerKey } from "@/runtime/server/persistence" import { decode64 } from "@/runtime/persistence/base64" import { same } from "@/runtime/persistence/equality" import { createScrollPersistence, type SessionScroll } from "./scroll" @@ -165,7 +165,7 @@ export const layoutSchema = Persistence.struct({ sessionView: Persistence.record(Persistence.fallback(sessionViewSchema, () => ({ scroll: {} }))), home: Persistence.struct({ selection: Persistence.struct({ - server: Schema.optional(TabStorage.ServerKey), + server: Schema.optional(ServerKey), directory: Schema.optional(Schema.String), }), }), diff --git a/packages/app/src/shell/tabs/schema.ts b/packages/app/src/shell/tabs/schema.ts index c8f3e8b582d..4570dbb4565 100644 --- a/packages/app/src/shell/tabs/schema.ts +++ b/packages/app/src/shell/tabs/schema.ts @@ -1,63 +1,58 @@ export * as TabStorage from "./schema" -import { Schema, SchemaGetter } from "effect" -import { ServerKey } from "@/runtime/server/persistence" -import { Persistence } from "@/runtime/persistence/schema" +import { Codec } from "@/runtime/persistence/codec" +import { ServerKey } from "@/runtime/server/key" export { ServerKey } -export const Session = Persistence.struct({ - type: Schema.Literal("session"), +export const Session = Codec.struct({ + type: Codec.literal("session"), server: ServerKey, - sessionId: Schema.String, - routeSessionId: Persistence.optional(Schema.String), - routeParentId: Persistence.optional(Schema.String), + sessionId: Codec.string, + routeSessionId: Codec.lenientOptional(Codec.string), + routeParentId: Codec.lenientOptional(Codec.string), }) -export const Draft = Persistence.struct({ - type: Schema.Literal("draft"), - draftID: Schema.String, +export const Draft = Codec.struct({ + type: Codec.literal("draft"), + draftID: Codec.string, server: ServerKey, - directory: Schema.String, - worktree: Persistence.optional(Schema.String), - branch: Persistence.optional(Schema.String), - mcp: Persistence.optional(Persistence.struct({ target: Schema.String, states: Persistence.record(Schema.Boolean) })), + directory: Codec.string, + worktree: Codec.lenientOptional(Codec.string), + branch: Codec.lenientOptional(Codec.string), + mcp: Codec.lenientOptional(Codec.struct({ target: Codec.string, states: Codec.lenientRecord(Codec.boolean) })), }) -const SessionCodec = Session.pipe( - Schema.decodeTo(Schema.toType(Session), { - decode: SchemaGetter.transform((tab) => ({ - type: tab.type, - server: tab.server, - sessionId: tab.sessionId, - ...(tab.routeSessionId && tab.routeSessionId !== tab.sessionId - ? { routeSessionId: tab.routeSessionId, ...(tab.routeParentId ? { routeParentId: tab.routeParentId } : {}) } - : {}), - })), - encode: SchemaGetter.transform((tab) => tab), +// A stored route that only repeats the session id carries nothing; drop it and its parent. +const SessionCodec = Codec.transform(Session, { + decode: (tab) => ({ + type: tab.type, + server: tab.server, + sessionId: tab.sessionId, + ...(tab.routeSessionId && tab.routeSessionId !== tab.sessionId + ? { routeSessionId: tab.routeSessionId, ...(tab.routeParentId ? { routeParentId: tab.routeParentId } : {}) } + : {}), + }), + encode: (tab) => tab, +}) + +export const Tab = Codec.union([Session, Draft]) +export const Tabs = Codec.lenientArray(Codec.union([SessionCodec, Draft])) +export const Recent = Codec.struct({ + key: Codec.optional(Codec.string), +}) +export const Info = Codec.struct({ + title: Codec.optional(Codec.string), + directory: Codec.optional(Codec.string), +}) +export const Infos = Codec.record(Info) +export const Panes = Codec.record( + Codec.struct({ + terminal: Codec.optional(Codec.boolean), + review: Codec.optional(Codec.boolean), + terminalHeight: Codec.optional(Codec.number), + sessionWidth: Codec.optional(Codec.number), }), ) - -export const Tab = Schema.Union([Session, Draft]) -export const Tabs = Persistence.array(Schema.Union([SessionCodec, Draft])) -export const Recent = Persistence.struct({ - key: Schema.optional(Schema.String), -}) -export const Info = Persistence.struct({ - title: Schema.optional(Schema.String), - directory: Schema.optional(Schema.String), -}) -export const Infos = Schema.Record(Schema.String, Schema.mutableKey(Info)) -export const Panes = Schema.Record( - Schema.String, - Schema.mutableKey( - Persistence.struct({ - terminal: Schema.optional(Schema.Boolean), - review: Schema.optional(Schema.Boolean), - terminalHeight: Schema.optional(Schema.Finite), - sessionWidth: Schema.optional(Schema.Finite), - }), - ), -) -export const ClosedTab = Schema.Struct({ tab: SessionCodec, index: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)) }) -export const Closed = Persistence.array(ClosedTab) +export const ClosedTab = Codec.struct({ tab: SessionCodec, index: Codec.nonNegativeInt }) +export const Closed = Codec.lenientArray(ClosedTab) diff --git a/packages/app/src/shell/tabs/tabs.test.ts b/packages/app/src/shell/tabs/tabs.test.ts index 15cfd30c276..f482dc5f301 100644 --- a/packages/app/src/shell/tabs/tabs.test.ts +++ b/packages/app/src/shell/tabs/tabs.test.ts @@ -3,13 +3,12 @@ import { createRoot, getOwner, onCleanup } from "solid-js" import { createTabMemory } from "./memory" import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed" import { findSessionTab, sessionIDHasOpenTab, tabHref, tabKey, type SessionTab, type Tab } from "./tabs" -import { Schema } from "effect" import { TabStorage } from "./schema" import type { ServerConnection } from "@/runtime/server/registry" -import { Persistence } from "@/runtime/persistence/schema" +import { Codec } from "@/runtime/persistence/codec" const server = "local\nhttp://localhost:4096" as ServerConnection.Key -const decodeTabs = Schema.decodeUnknownSync(Persistence.withInitial(TabStorage.Tabs, [])) +const decodeTabs = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(TabStorage.Tabs, []), input)) function sessionTab(sessionId: string): SessionTab { return { type: "session", server, sessionId } @@ -26,7 +25,7 @@ describe("tab migration", () => { } const restored = decodeTabs([legacy, draft]) expect(restored).toEqual([legacy, draft]) - expect(decodeTabs(Schema.encodeSync(TabStorage.Tabs)(restored))).toEqual([legacy, draft]) + expect(decodeTabs(TabStorage.Tabs.encode(restored))).toEqual([legacy, draft]) }) test("drops null and malformed persisted tabs", () => { @@ -64,13 +63,13 @@ describe("tab migration", () => { draft, ]) expect(tabs).toEqual([sessionTab("root"), draft]) - expect(Schema.encodeSync(TabStorage.Tabs)(tabs)).toEqual(tabs) - expect(decodeTabs(Schema.encodeSync(TabStorage.Tabs)(tabs))).toEqual(tabs) + expect(TabStorage.Tabs.encode(tabs)).toEqual(tabs) + expect(decodeTabs(TabStorage.Tabs.encode(tabs))).toEqual(tabs) }) test("salvages valid closed session tabs", () => { expect( - Schema.decodeUnknownSync(Persistence.withInitial(TabStorage.Closed, []))([ + ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(TabStorage.Closed, []), input))([ { tab: sessionTab("a"), index: 1 }, { tab: sessionTab("b"), index: -1 }, { tab: { type: "draft", server, draftID: "d", directory: "/project" }, index: 0 }, @@ -81,16 +80,16 @@ describe("tab migration", () => { test("validates auxiliary tab state", () => { expect( - Schema.decodeUnknownSync(Persistence.withInitial(TabStorage.Recent, { key: undefined }))({ key: 1 }), + ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(TabStorage.Recent, { key: undefined }), input))({ key: 1 }), ).toEqual({ key: undefined }) - expect(Schema.decodeUnknownSync(TabStorage.Infos)({})).toEqual({}) - expect(Schema.decodeUnknownSync(TabStorage.Panes)({})).toEqual({}) - expect(Schema.decodeUnknownSync(TabStorage.Infos)({ tab: { title: "Title", directory: "/project" } })).toEqual({ + expect(Codec.decodeOrThrow(TabStorage.Infos, {})).toEqual({}) + expect(Codec.decodeOrThrow(TabStorage.Panes, {})).toEqual({}) + expect(Codec.decodeOrThrow(TabStorage.Infos, { tab: { title: "Title", directory: "/project" } })).toEqual({ tab: { title: "Title", directory: "/project" }, }) - const panes = Schema.decodeUnknownSync(TabStorage.Panes)({ tab: { terminal: true, terminalHeight: 300 } }) - expect(Schema.encodeSync(TabStorage.Panes)(panes)).toEqual({ tab: { terminal: true, terminalHeight: 300 } }) - expect(() => Schema.decodeUnknownSync(TabStorage.Panes)({ tab: { terminal: "yes" } })).toThrow() + const panes = Codec.decodeOrThrow(TabStorage.Panes, { tab: { terminal: true, terminalHeight: 300 } }) + expect(TabStorage.Panes.encode(panes)).toEqual({ tab: { terminal: true, terminalHeight: 300 } }) + expect(() => Codec.decodeOrThrow(TabStorage.Panes, { tab: { terminal: "yes" } })).toThrow() }) }) @@ -209,3 +208,5 @@ describe("closed tab stack", () => { expect(nextTabAfterClose([sessionTab("a")], 0, true)).toBeNull() }) }) + +