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() }) }) + +