mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
refactor(app): port the settings store to plain codecs
Migration shapes use preserving structs so undeclared stored fields survive, as Effect's onExcessProperty preserve did at every level; explicit-but-invalid legacy values still decode to null rather than absent; persisted() encodes through encodeOrThrow so an invalid in-memory value fails instead of being written.
This commit is contained in:
@@ -46,6 +46,13 @@ export function decodeOrThrow<T>(codec: Of<T>, input: unknown): T {
|
||||
return value
|
||||
}
|
||||
|
||||
/** Encodes and checks the result decodes, so an invalid in-memory value fails loudly instead of persisting. */
|
||||
export function encodeOrThrow<T, E>(codec: Of<T, E>, value: T): E {
|
||||
const encoded = codec.encode(value)
|
||||
if (codec.decode(encoded) === INVALID) throw new Error("Value does not match its codec")
|
||||
return encoded
|
||||
}
|
||||
|
||||
const identity = <T>(value: T) => value
|
||||
|
||||
export const string: Of<string, string> = make((v) => (typeof v === "string" ? v : INVALID), identity)
|
||||
@@ -120,20 +127,23 @@ export interface Struct<F extends Fields> extends Of<StructType<F>, StructEncode
|
||||
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> = {}
|
||||
// `preserve` keeps keys the struct does not declare, for migration shapes that only describe the
|
||||
// fields they rewrite (Effect's `onExcessProperty: "preserve"`); the current schema then decides.
|
||||
export function struct<const F extends Fields>(fields: F, options?: { preserve?: boolean }): 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> = options?.preserve ? { ...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
|
||||
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
|
||||
else delete out[key]
|
||||
}
|
||||
return out as StructType<F>
|
||||
},
|
||||
@@ -317,10 +327,8 @@ export function withInitial<C extends Any>(definition: C | Migrated<C>, initial:
|
||||
const read = isMigrated(definition) ? definition.read : unknown
|
||||
return make(
|
||||
(input) => {
|
||||
const migrated = read.decode(input)
|
||||
if (migrated === INVALID) return INVALID
|
||||
// A migration only describes the fields it rewrites; everything else stored stays as it was.
|
||||
const stored = isObject(input) && isObject(migrated) ? { ...input, ...migrated } : migrated
|
||||
const stored = read.decode(input)
|
||||
if (stored === INVALID) return INVALID
|
||||
return merge(initial, recover(codec, stored, initial))
|
||||
},
|
||||
(value) => codec.encode(value),
|
||||
|
||||
@@ -490,8 +490,8 @@ function serializer<S extends Schema.ConstraintCodec<object, unknown> | Codec.An
|
||||
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),
|
||||
serialize: (value: S["Type"]) => Codec.encodeOrThrow(json, value),
|
||||
encode: (value: S["Type"]) => Codec.encodeOrThrow(codec, value),
|
||||
initial: Codec.decodeOrThrow(codec, codec.encode(initial)) as S["Type"],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ const State = Codec.struct({
|
||||
recentlyClosed: Codec.record(Codec.lenientArray(Codec.string)),
|
||||
})
|
||||
|
||||
const StoredState = Codec.struct({ projects: Projects, lastProject: LastProject })
|
||||
const StoredState = Codec.struct({ projects: Projects, lastProject: LastProject }, { preserve: true })
|
||||
|
||||
// Projects and last-opened entries recorded under the canonical local server's URL move under
|
||||
// "local" when that URL is known, so they survive the server changing address.
|
||||
@@ -124,3 +124,4 @@ export const ProjectState = Codec.struct({
|
||||
export const IconState = Codec.struct({
|
||||
value: Codec.optional(Codec.string),
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Schema } from "effect"
|
||||
import { timelinePresets } from "@opencode/session-ui/timeline/detail"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import { Codec } from "@/runtime/persistence/codec"
|
||||
import {
|
||||
settingsSchema,
|
||||
settingsPersistence,
|
||||
@@ -13,9 +12,9 @@ import {
|
||||
terminalFontFamily,
|
||||
} from "./model"
|
||||
|
||||
const schema = Persistence.withInitial(settingsPersistence, defaultSettings)
|
||||
const decode = Schema.decodeUnknownSync(schema)
|
||||
const encode = Schema.encodeSync(schema)
|
||||
const schema = Codec.withInitial(settingsPersistence, defaultSettings)
|
||||
const decode = (input: unknown) => Codec.decodeOrThrow(schema, input)
|
||||
const encode = (value: typeof settingsSchema.Type) => schema.encode(value)
|
||||
|
||||
describe("settings timeline detail migration", () => {
|
||||
test("migrates saved switches and round trips the current settings", () => {
|
||||
@@ -51,14 +50,14 @@ describe("settings schema", () => {
|
||||
general: { ...defaultSettings.general, timelineDetail: timelinePresets[4].value, autoSave: false },
|
||||
appearance: { ...defaultSettings.appearance, fontSize: 20 },
|
||||
}
|
||||
const restore = Schema.decodeUnknownSync(Persistence.withInitial(settingsPersistence, initial))
|
||||
const restore = (input: unknown) => Codec.decodeOrThrow(Codec.withInitial(settingsPersistence, initial), input)
|
||||
expect(restore({})).toEqual(initial)
|
||||
expect(restore({ general: { reasoningMode: "invalid", showReasoningSummaries: true } })).toEqual(initial)
|
||||
expect(restore({ general: { showReasoningSummaries: true } }).general.timelineDetail.thinking).toEqual({
|
||||
placement: "separate",
|
||||
details: "expanded",
|
||||
})
|
||||
expect(() => Schema.decodeUnknownSync(settingsSchema)({})).toThrow()
|
||||
expect(() => Codec.decodeOrThrow(settingsSchema, {})).toThrow()
|
||||
})
|
||||
|
||||
test("supplies the existing defaults for an empty document", () => {
|
||||
@@ -171,7 +170,7 @@ describe("settings schema", () => {
|
||||
|
||||
test("does not silently repair invalid values during encoding", () => {
|
||||
expect(() =>
|
||||
Schema.encodeUnknownSync(settingsSchema)({ ...decode({}), appearance: { fontSize: "large" } }),
|
||||
Codec.encodeOrThrow(settingsSchema, { ...decode({}), appearance: { fontSize: "large" } } as never),
|
||||
).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -203,3 +202,6 @@ describe("settings font families", () => {
|
||||
expect(terminalFontFamily(undefined)).toStartWith('"JetBrainsMono Nerd Font Mono", ')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
|
||||
|
||||
+507
-511
File diff suppressed because it is too large
Load Diff
@@ -188,7 +188,7 @@ const storedLayout = Codec.struct({
|
||||
),
|
||||
sessionTabs: layoutSchema.fields.sessionTabs,
|
||||
sessionView: layoutSchema.fields.sessionView,
|
||||
})
|
||||
}, { preserve: true })
|
||||
|
||||
export const layoutPersistence = Codec.migrate(
|
||||
layoutSchema,
|
||||
@@ -766,3 +766,4 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user