mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-20 07:37:35 +00:00
Compare commits
1
Commits
codec-layout
..
v2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25f35dcfb8 |
@@ -1,17 +0,0 @@
|
||||
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])
|
||||
})
|
||||
@@ -1,85 +0,0 @@
|
||||
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"] })
|
||||
})
|
||||
})
|
||||
@@ -1,325 +0,0 @@
|
||||
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<T, E = unknown> {
|
||||
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<any, any>
|
||||
export type Type<C extends Any> = C["Type"]
|
||||
|
||||
export function isCodec(value: unknown): value is Any {
|
||||
return typeof value === "object" && value !== null && tag in value
|
||||
}
|
||||
|
||||
export function make<T, E = unknown>(decode: (input: unknown) => T | Invalid, encode: (value: T) => E): Of<T, E> {
|
||||
return { [tag]: true, decode, encode } as Of<T, E>
|
||||
}
|
||||
|
||||
export function is<T>(codec: Of<T>, input: unknown): input is T {
|
||||
return codec.decode(input) !== INVALID
|
||||
}
|
||||
|
||||
export function decodeOption<T>(codec: Of<T>, input: unknown): T | undefined {
|
||||
const value = codec.decode(input)
|
||||
return value === INVALID ? undefined : value
|
||||
}
|
||||
|
||||
export function decodeOrThrow<T>(codec: Of<T>, input: unknown): T {
|
||||
const value = codec.decode(input)
|
||||
if (value === INVALID) throw new Error("Value does not match its codec")
|
||||
return value
|
||||
}
|
||||
|
||||
const identity = <T>(value: T) => value
|
||||
|
||||
export const string: Of<string, string> = make((v) => (typeof v === "string" ? v : INVALID), identity)
|
||||
export const boolean: Of<boolean, boolean> = make((v) => (typeof v === "boolean" ? v : INVALID), identity)
|
||||
export const unknown: Of<unknown, unknown> = make((v) => v, identity)
|
||||
/** Finite numbers only: NaN and infinities are not JSON and never valid state. */
|
||||
export const number: Of<number, number> = make((v) => (typeof v === "number" && Number.isFinite(v) ? v : INVALID), identity)
|
||||
export const int: Of<number, number> = make((v) => (typeof v === "number" && Number.isInteger(v) ? v : INVALID), identity)
|
||||
export const nonNegativeInt: Of<number, number> = make(
|
||||
(v) => (typeof v === "number" && Number.isInteger(v) && v >= 0 ? v : INVALID),
|
||||
identity,
|
||||
)
|
||||
|
||||
export function literal<const L extends string | number | boolean | null>(value: L): Of<L, L> {
|
||||
return make((v) => (v === value ? value : INVALID), identity)
|
||||
}
|
||||
|
||||
export function literals<const L extends ReadonlyArray<string | number | boolean | null>>(values: L): Of<L[number], L[number]> {
|
||||
const set = new Set<unknown>(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<B extends string>(): Of<string & { readonly [K in B]: B }, string> & {
|
||||
make(value: string): string & { readonly [K in B]: B }
|
||||
} {
|
||||
return Object.assign(make<string & { readonly [K in B]: B }, string>((v) => (typeof v === "string" ? (v as never) : INVALID), identity), {
|
||||
make: (value: string) => value as never,
|
||||
})
|
||||
}
|
||||
|
||||
export function nullOr<T, E>(codec: Of<T, E>): Of<T | null, E | null> {
|
||||
return make((v) => (v === null ? null : codec.decode(v)), (v) => (v === null ? null : codec.encode(v)))
|
||||
}
|
||||
|
||||
export function undefinedOr<T, E>(codec: Of<T, E>): Of<T | undefined, E | undefined> {
|
||||
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<T, E>(codec: Of<T, E>): Of<T | undefined, E | undefined> & { 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<T, E>(codec: Of<T, E>): Of<T | undefined, E | undefined> & { readonly optional: true } {
|
||||
return {
|
||||
...make<T | undefined, E | undefined>(
|
||||
(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<string, Any>
|
||||
type OptionalKeys<F extends Fields> = { [K in keyof F]: F[K] extends { optional: true } ? K : never }[keyof F]
|
||||
type RequiredKeys<F extends Fields> = Exclude<keyof F, OptionalKeys<F>>
|
||||
type Simplify<T> = { [K in keyof T]: T[K] } & {}
|
||||
export type StructType<F extends Fields> = Simplify<
|
||||
{ [K in RequiredKeys<F>]: F[K]["Type"] } & { [K in OptionalKeys<F>]?: F[K]["Type"] }
|
||||
>
|
||||
export type StructEncoded<F extends Fields> = Simplify<
|
||||
{ [K in RequiredKeys<F>]: F[K]["Encoded"] } & { [K in OptionalKeys<F>]?: F[K]["Encoded"] }
|
||||
>
|
||||
|
||||
export interface Struct<F extends Fields> extends Of<StructType<F>, StructEncoded<F>> {
|
||||
readonly fields: F
|
||||
}
|
||||
|
||||
export function struct<const F extends Fields>(fields: F): Struct<F> {
|
||||
const entries = Object.entries(fields)
|
||||
return {
|
||||
...make<StructType<F>, StructEncoded<F>>(
|
||||
(input) => {
|
||||
if (typeof input !== "object" || input === null || Array.isArray(input)) return INVALID
|
||||
const record = input as Record<string, unknown>
|
||||
const out: Record<string, unknown> = {}
|
||||
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<F>
|
||||
},
|
||||
(value) => {
|
||||
const out: Record<string, unknown> = {}
|
||||
for (const [key, codec] of entries) {
|
||||
const field = (value as Record<string, unknown>)[key]
|
||||
if (field === undefined && !Object.hasOwn(value as object, key)) continue
|
||||
out[key] = codec.encode(field)
|
||||
}
|
||||
return out as StructEncoded<F>
|
||||
},
|
||||
),
|
||||
fields,
|
||||
}
|
||||
}
|
||||
|
||||
export function array<T, E>(codec: Of<T, E>): Of<T[], E[]> {
|
||||
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<T, E>(codec: Of<T, E>): Of<T[], E[]> {
|
||||
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<T, E>(codec: Of<T, E>): Of<Record<string, T>, Record<string, E>> {
|
||||
return make(
|
||||
(input) => {
|
||||
if (typeof input !== "object" || input === null || Array.isArray(input)) return INVALID
|
||||
const out: Record<string, T> = {}
|
||||
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<T, E>(codec: Of<T, E>): Of<Record<string, T>, Record<string, E>> {
|
||||
const strict = record(codec)
|
||||
return make(
|
||||
(input) => {
|
||||
const value = strict.decode(input)
|
||||
return value === INVALID ? {} : value
|
||||
},
|
||||
strict.encode,
|
||||
)
|
||||
}
|
||||
|
||||
export function union<const C extends ReadonlyArray<Any>>(codecs: C): Of<C[number]["Type"], C[number]["Encoded"]> {
|
||||
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<T, E, T2>(
|
||||
codec: Of<T, E>,
|
||||
options: { decode: (value: T) => T2; encode: (value: T2) => T },
|
||||
): Of<T2, E> {
|
||||
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<T, E>(codec: Of<T, E>, value: () => NoInfer<T>): Of<T, E> {
|
||||
return make(
|
||||
(input) => {
|
||||
if (input === undefined) return value()
|
||||
const decoded = codec.decode(input)
|
||||
return decoded === INVALID ? value() : decoded
|
||||
},
|
||||
codec.encode,
|
||||
)
|
||||
}
|
||||
|
||||
export function fromJsonString<T, E>(codec: Of<T, E>): Of<T, string> {
|
||||
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 Decoder = Pick<Of<unknown>, "decode">
|
||||
export type Migrated<C extends Any> = { readonly current: C; readonly read: Decoder }
|
||||
|
||||
/** Older stored shapes go through `read` first; `current` describes what the store holds today. */
|
||||
export function migrate<C extends Any>(current: C, read: Decoder): Migrated<C> {
|
||||
return { current, read }
|
||||
}
|
||||
|
||||
function isMigrated<C extends Any>(definition: C | Migrated<C>): definition is Migrated<C> {
|
||||
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<C extends Any>(definition: C | Migrated<C>, initial: Type<C>): Of<Type<C>, 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>).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<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ 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"
|
||||
|
||||
@@ -473,57 +472,25 @@ export function removePersisted(
|
||||
}
|
||||
}
|
||||
|
||||
type Definition<S extends Schema.ConstraintCodec<object, unknown> | Codec.Any> =
|
||||
| S
|
||||
| Persistence.Migrated<Extract<S, Schema.ConstraintCodec<object, unknown>>>
|
||||
| Codec.Migrated<Extract<S, Codec.Any>>
|
||||
|
||||
// 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<S extends Schema.ConstraintCodec<object, unknown> | Codec.Any>(
|
||||
definition: Definition<S>,
|
||||
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<Codec.Any>, 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<object, unknown> | Persistence.Migrated<Schema.ConstraintCodec<object, unknown>>
|
||||
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<S extends Schema.ConstraintCodec<object, unknown> | Codec.Any>(
|
||||
export function persisted<S extends Schema.ConstraintCodec<object, unknown>>(
|
||||
target: string | PersistTarget,
|
||||
schema: Definition<S>,
|
||||
schema: S | Persistence.Migrated<S>,
|
||||
initial: NoInfer<S["Type"]>,
|
||||
platformOverride?: Platform,
|
||||
): PersistedWithReady<S["Type"]> {
|
||||
const platform = platformOverride ?? usePlatform()
|
||||
const config = resolveTarget(typeof target === "string" ? { key: target } : target, platform)
|
||||
|
||||
const codec = serializer<S>(schema, initial)
|
||||
const { encode, serialize } = codec
|
||||
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 normalize = (raw: string) => {
|
||||
const value = codec.decode(raw)
|
||||
if (value !== undefined) return serialize(value)
|
||||
const value = decode(raw)
|
||||
if (Option.isSome(value)) return serialize(value.value)
|
||||
}
|
||||
const store = createStore<S["Type"]>(codec.initial)
|
||||
const store = createStore<S["Type"]>(Schema.decodeUnknownSync(Schema.toType(initialized))(initial))
|
||||
const isDesktop = platform.platform === "desktop" && !!platform.storage
|
||||
const draft = config.draft ? platform.draftStore : undefined
|
||||
const prefix = `${config.storage ?? "default"}:`
|
||||
@@ -635,7 +602,7 @@ export function persisted<S extends Schema.ConstraintCodec<object, unknown> | Co
|
||||
name: config.key,
|
||||
storage,
|
||||
serialize,
|
||||
deserialize: codec.deserialize,
|
||||
deserialize: Schema.decodeUnknownSync(json),
|
||||
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.
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
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<ServerKey, string> & { make(value: string): ServerKey } = Object.assign(
|
||||
Codec.make<ServerKey, string>((v) => (typeof v === "string" ? (v as ServerKey) : Codec.INVALID), (v) => v),
|
||||
{ make: (value: string) => value as ServerKey },
|
||||
)
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createRoot, createSignal } from "solid-js"
|
||||
import { Schema } from "effect"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { Codec } from "@/runtime/persistence/codec"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import { currentRoute, initialLayout, layoutPersistence, layoutSchema } from "./layout"
|
||||
import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./helpers"
|
||||
|
||||
@@ -10,21 +11,21 @@ test("settings has its own layout route", () => {
|
||||
})
|
||||
|
||||
describe("layout persistence", () => {
|
||||
const schema = Codec.withInitial(layoutPersistence, initialLayout(ServerConnection.Key.make("local")))
|
||||
const decode = (input: unknown) => Codec.decodeOrThrow(schema, input)
|
||||
const schema = Persistence.withInitial(layoutPersistence, initialLayout(ServerConnection.Key.make("local")))
|
||||
const decode = Schema.decodeUnknownSync(schema)
|
||||
|
||||
test("uses supplied initial preferences after legacy migration", () => {
|
||||
const initial = initialLayout(ServerConnection.Key.make("remote"))
|
||||
initial.sidebar.width = 420
|
||||
initial.fileTree.width = 300
|
||||
initial.review.panelOpened = true
|
||||
const restore = (input: unknown) => Codec.decodeOrThrow(Codec.withInitial(layoutPersistence, initial), input)
|
||||
const restore = Schema.decodeUnknownSync(Persistence.withInitial(layoutPersistence, initial))
|
||||
expect(restore({})).toEqual(initial)
|
||||
expect(restore({ sidebar: { width: "bad" } }).sidebar.width).toBe(420)
|
||||
expect(restore({ fileTree: { width: 260 } }).fileTree.width).toBe(200)
|
||||
expect(restore({ fileTree: {} }).fileTree.width).toBe(300)
|
||||
expect(restore({ review: {}, fileTree: { opened: false } }).review.panelOpened).toBe(false)
|
||||
expect(() => Codec.decodeOrThrow(layoutSchema, {})).toThrow()
|
||||
expect(() => Schema.decodeUnknownSync(layoutSchema)({})).toThrow()
|
||||
})
|
||||
|
||||
test("restores shipped defaults for missing and invalid fields", () => {
|
||||
@@ -55,8 +56,8 @@ describe("layout persistence", () => {
|
||||
expect(value.sidebar).toEqual({ opened: false, width: 344, workspaces: {}, workspacesDefault: true })
|
||||
expect(value.review).toEqual({ diffStyle: "split", panelOpened: true })
|
||||
expect(value.fileTree).toEqual({ opened: true, width: 200, tab: "changes" })
|
||||
expect(schema.encode(value)).toEqual(value)
|
||||
expect(decode(schema.encode(value))).toEqual(value)
|
||||
expect(Schema.encodeSync(schema)(value)).toEqual(value)
|
||||
expect(decode(Schema.encodeSync(schema)(value))).toEqual(value)
|
||||
expect(decode({ fileTree: { opened: true } }).review.panelOpened).toBe(false)
|
||||
})
|
||||
|
||||
@@ -168,4 +169,3 @@ describe("pruneSessionKeys", () => {
|
||||
expect(drop).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,58 +1,63 @@
|
||||
export * as TabStorage from "./schema"
|
||||
|
||||
import { Codec } from "@/runtime/persistence/codec"
|
||||
import { ServerKey } from "@/runtime/server/key"
|
||||
import { Schema, SchemaGetter } from "effect"
|
||||
import { ServerKey } from "@/runtime/server/persistence"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
|
||||
export { ServerKey }
|
||||
|
||||
export const Session = Codec.struct({
|
||||
type: Codec.literal("session"),
|
||||
export const Session = Persistence.struct({
|
||||
type: Schema.Literal("session"),
|
||||
server: ServerKey,
|
||||
sessionId: Codec.string,
|
||||
routeSessionId: Codec.lenientOptional(Codec.string),
|
||||
routeParentId: Codec.lenientOptional(Codec.string),
|
||||
sessionId: Schema.String,
|
||||
routeSessionId: Persistence.optional(Schema.String),
|
||||
routeParentId: Persistence.optional(Schema.String),
|
||||
})
|
||||
|
||||
export const Draft = Codec.struct({
|
||||
type: Codec.literal("draft"),
|
||||
draftID: Codec.string,
|
||||
export const Draft = Persistence.struct({
|
||||
type: Schema.Literal("draft"),
|
||||
draftID: Schema.String,
|
||||
server: ServerKey,
|
||||
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) })),
|
||||
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) })),
|
||||
})
|
||||
|
||||
// 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),
|
||||
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),
|
||||
}),
|
||||
)
|
||||
export const ClosedTab = Codec.struct({ tab: SessionCodec, index: Codec.nonNegativeInt })
|
||||
export const Closed = Codec.lenientArray(ClosedTab)
|
||||
|
||||
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)
|
||||
|
||||
@@ -3,12 +3,13 @@ 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 { Codec } from "@/runtime/persistence/codec"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
|
||||
const server = "local\nhttp://localhost:4096" as ServerConnection.Key
|
||||
const decodeTabs = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(TabStorage.Tabs, []), input))
|
||||
const decodeTabs = Schema.decodeUnknownSync(Persistence.withInitial(TabStorage.Tabs, []))
|
||||
|
||||
function sessionTab(sessionId: string): SessionTab {
|
||||
return { type: "session", server, sessionId }
|
||||
@@ -25,7 +26,7 @@ describe("tab migration", () => {
|
||||
}
|
||||
const restored = decodeTabs([legacy, draft])
|
||||
expect(restored).toEqual([legacy, draft])
|
||||
expect(decodeTabs(TabStorage.Tabs.encode(restored))).toEqual([legacy, draft])
|
||||
expect(decodeTabs(Schema.encodeSync(TabStorage.Tabs)(restored))).toEqual([legacy, draft])
|
||||
})
|
||||
|
||||
test("drops null and malformed persisted tabs", () => {
|
||||
@@ -63,13 +64,13 @@ describe("tab migration", () => {
|
||||
draft,
|
||||
])
|
||||
expect(tabs).toEqual([sessionTab("root"), draft])
|
||||
expect(TabStorage.Tabs.encode(tabs)).toEqual(tabs)
|
||||
expect(decodeTabs(TabStorage.Tabs.encode(tabs))).toEqual(tabs)
|
||||
expect(Schema.encodeSync(TabStorage.Tabs)(tabs)).toEqual(tabs)
|
||||
expect(decodeTabs(Schema.encodeSync(TabStorage.Tabs)(tabs))).toEqual(tabs)
|
||||
})
|
||||
|
||||
test("salvages valid closed session tabs", () => {
|
||||
expect(
|
||||
((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(TabStorage.Closed, []), input))([
|
||||
Schema.decodeUnknownSync(Persistence.withInitial(TabStorage.Closed, []))([
|
||||
{ tab: sessionTab("a"), index: 1 },
|
||||
{ tab: sessionTab("b"), index: -1 },
|
||||
{ tab: { type: "draft", server, draftID: "d", directory: "/project" }, index: 0 },
|
||||
@@ -80,16 +81,16 @@ describe("tab migration", () => {
|
||||
|
||||
test("validates auxiliary tab state", () => {
|
||||
expect(
|
||||
((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(TabStorage.Recent, { key: undefined }), input))({ key: 1 }),
|
||||
Schema.decodeUnknownSync(Persistence.withInitial(TabStorage.Recent, { key: undefined }))({ key: 1 }),
|
||||
).toEqual({ key: undefined })
|
||||
expect(Codec.decodeOrThrow(TabStorage.Infos, {})).toEqual({})
|
||||
expect(Codec.decodeOrThrow(TabStorage.Panes, {})).toEqual({})
|
||||
expect(Codec.decodeOrThrow(TabStorage.Infos, { tab: { title: "Title", directory: "/project" } })).toEqual({
|
||||
expect(Schema.decodeUnknownSync(TabStorage.Infos)({})).toEqual({})
|
||||
expect(Schema.decodeUnknownSync(TabStorage.Panes)({})).toEqual({})
|
||||
expect(Schema.decodeUnknownSync(TabStorage.Infos)({ tab: { title: "Title", directory: "/project" } })).toEqual({
|
||||
tab: { title: "Title", directory: "/project" },
|
||||
})
|
||||
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()
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -208,5 +209,3 @@ describe("closed tab stack", () => {
|
||||
expect(nextTabAfterClose([sessionTab("a")], 0, true)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user