refactor(app): declare open legacy persistence shapes

This commit is contained in:
Kit Langton
2026-09-20 20:15:59 -07:00
parent 12e1821cc7
commit 8ba230e2fb
6 changed files with 46 additions and 51 deletions
@@ -45,7 +45,7 @@ const Current = Persistence.struct({ session: SessionsSchema })
export const ModelSelectionSchema = Persistence.migrate(
Current,
Schema.Struct({
Persistence.legacy({
session: Persistence.optional(Schema.Record(Schema.String, Schema.Unknown)),
pick: Persistence.optional(Schema.Record(Schema.String, Schema.Unknown)),
}).pipe(
@@ -32,7 +32,7 @@ describe("persistence schemas", () => {
test("legacy migration observes missing fields before initial defaults are applied", () => {
const current = Persistence.struct({ mode: Schema.Literals(["compact", "full"]), enabled: Schema.Boolean })
const stored = Schema.Struct({
const stored = Persistence.legacy({
mode: Schema.optional(Schema.Unknown),
expanded: Schema.optional(Schema.Boolean),
}).pipe(
@@ -53,6 +53,38 @@ describe("persistence schemas", () => {
expect(Schema.encodeSync(schema)(decode({ expanded: true }))).toEqual({ mode: "full", enabled: true })
})
test("legacy read shapes carry unknown nested fields through to the current schema", () => {
const current = Persistence.struct({
general: Persistence.struct({
mode: Schema.Literals(["compact", "full"]),
theme: Schema.String,
nested: Persistence.struct({ keep: Schema.Boolean }),
}),
other: Persistence.struct({ flag: Schema.Boolean }),
})
const stored = Persistence.legacy({
general: Persistence.optional(Persistence.legacy({ expanded: Persistence.optional(Schema.Boolean) })),
}).pipe(
Schema.decode({
decode: SchemaGetter.transform((value) => {
const general = value.general
if (!general || general.expanded === undefined) return value
return { ...value, general: { ...general, mode: general.expanded ? "full" : "compact" } }
}),
encode: SchemaGetter.passthrough(),
}),
)
const schema = Persistence.withInitial(Persistence.migrate(current, stored), {
general: { mode: "compact", theme: "light", nested: { keep: false } },
other: { flag: false },
})
const decode = Schema.decodeUnknownSync(schema)
expect(decode({ general: { expanded: true, theme: "dark", nested: { keep: true } }, other: { flag: true } })).toEqual({
general: { mode: "full", theme: "dark", nested: { keep: true } },
other: { flag: true },
})
})
test("initial merging preserves field codecs and replaces arrays rather than merging indexes", () => {
const current = Persistence.struct({
amount: Schema.NumberFromString.check(Schema.isFinite()),
+5 -42
View File
@@ -23,11 +23,7 @@ export function withInitial<S extends Schema.ConstraintCodec<object, unknown>>(
initial: NoInfer<S["Type"]>,
) {
const schema = isMigrated(definition) ? definition.current : definition
const read = isMigrated(definition)
? SchemaParser.decodeUnknownResult(
Schema.make<Schema.ConstraintDecoder<unknown>>(preserveExcess(definition.read.ast)),
)
: Result.succeed<unknown>
const read = isMigrated(definition) ? SchemaParser.decodeUnknownResult(definition.read) : Result.succeed<unknown>
const encode = Schema.encodeUnknownSync(schema)
return Schema.Unknown.pipe(
Schema.decode<Schema.Unknown>({
@@ -40,43 +36,10 @@ export function withInitial<S extends Schema.ConstraintCodec<object, unknown>>(
)
}
// Migration decoders must carry fields they do not know yet into the current schema. Effect 4.0
// no longer exposes the parser-level `preserve` option, so make closed structs open recursively.
function preserveExcess(ast: SchemaAST.AST): SchemaAST.AST {
const encoding = ast.encoding && preserveEncoding(ast.encoding)
const recurred = "recur" in ast && typeof ast.recur === "function" ? ast.recur(preserveExcess) : ast
const next = withEncoding(recurred, encoding)
if (next._tag !== "Objects") return next
const indexSignatures =
next.propertySignatures.length === 0 || next.indexSignatures.length > 0
? next.indexSignatures
: [
new SchemaAST.IndexSignature(Schema.String.ast, Schema.Unknown.ast),
new SchemaAST.IndexSignature(Schema.Symbol.ast, Schema.Unknown.ast),
]
return new SchemaAST.Objects(
next.propertySignatures,
indexSignatures,
next.annotations,
next.checks,
encoding,
next.context,
next.encodingChecks,
)
}
function preserveEncoding(encoding: SchemaAST.Encoding): SchemaAST.Encoding {
return [
new SchemaAST.Link(preserveExcess(encoding[0].to), encoding[0].transformation),
...encoding.slice(1).map((link) => new SchemaAST.Link(preserveExcess(link.to), link.transformation)),
]
}
function withEncoding(ast: SchemaAST.AST, encoding: SchemaAST.Encoding | undefined): SchemaAST.AST {
if (ast.encoding === encoding) return ast
const descriptors = Object.getOwnPropertyDescriptors(ast)
descriptors.encoding.value = encoding
return Object.create(Object.getPrototypeOf(ast), descriptors)
// A legacy read shape declares only the fields it migrates. Decoding strips unknown keys, so every
// struct level in a `migrate` read schema must stay open for the current fields it does not name.
export function legacy<const Fields extends Schema.Struct.Fields>(fields: Fields) {
return Schema.StructWithRest(Schema.Struct(fields), [Schema.Record(Schema.String, Schema.Unknown)])
}
// Object-level codecs own their recovery. Plain structs can recover fields independently.
@@ -53,7 +53,7 @@ const State = Persistence.struct({
export function serverState(canonicalLocalServer: () => string | undefined = () => undefined) {
return Persistence.migrate(
State,
Schema.Struct({ projects: Projects, lastProject: LastProject }).pipe(
Persistence.legacy({ projects: Projects, lastProject: LastProject }).pipe(
Schema.decode({
decode: SchemaGetter.transform((value) => {
const canonical = canonicalLocalServer()
+2 -2
View File
@@ -185,9 +185,9 @@ function legacyTimelineActivity(value: boolean | "hidden" | "compact" | "full" |
export const settingsPersistence = Persistence.migrate(
settingsSchema,
Schema.Struct({
Persistence.legacy({
general: Persistence.optional(
Schema.Struct({
Persistence.legacy({
// Keep invalid explicit values distinct from absent values so legacy preferences cannot replace them.
timelineDetail: Schema.optional(
Schema.NullOr(
+4 -4
View File
@@ -176,16 +176,16 @@ export const layoutSchema = Persistence.struct({
export const layoutPersistence = Persistence.migrate(
layoutSchema,
Schema.Struct({
Persistence.legacy({
sidebar: Persistence.optional(
Schema.Struct({
Persistence.legacy({
workspaces: Persistence.optional(Schema.Union([Schema.Boolean, Schema.Record(Schema.String, Schema.Boolean)])),
workspacesDefault: Persistence.optional(Schema.Boolean),
}),
),
review: Persistence.optional(Schema.Struct({ panelOpened: Persistence.optional(Schema.Boolean) })),
review: Persistence.optional(Persistence.legacy({ panelOpened: Persistence.optional(Schema.Boolean) })),
fileTree: Persistence.optional(
Schema.Struct({
Persistence.legacy({
opened: Persistence.optional(Schema.Boolean),
width: Persistence.optional(Schema.Finite),
tab: Persistence.optional(Schema.Literals(["changes", "all"])),