fix: the API emitted undefined optionals as null, and the UI couldn't decode them (v0.3.2)
`Schema.optional(X)` means `X | undefined`, and the plain Schema encoder omits a
present-but-undefined key — but the HttpApi RESPONSE path serialises it as
`null`, which `X | undefined` then refuses when the client decodes it. Two
user-visible failures shipped in 0.3.1:
- GET /api/status on a never-synced host returned `"lastSync":null`, so the
Overview page could not decode its own status — i.e. every fresh install
until the first successful sync.
- POST /api/detect on a box with any emulator installed returned
`"contested":null` / `"appId":null`, so the Emulators page's Detect button
could not decode the result. It only looked fine because a value that has
round-tripped through cache.json has those keys ABSENT rather than present
-and-undefined, and absent keys encode correctly.
Nothing on this wire is `undefined` any more. EngineStatus.{lastSync,lastReport,
detectedAt} are `Schema.NullOr` and the engine sends explicit nulls. The
DetectedEmulator fields use `Schema.optional(Schema.NullOr(...))` instead —
tolerant of absent, undefined and null — because they also live in cache.json,
and requiring them would fail the whole cache decode on upgrade and silently
discard every art verdict.
plugin/test/wire.test.ts pins it: it drives the real makeApi handler stack
through toWebHandler and decodes the bytes with the shared contract schemas.
Against the old schema all five of its assertions fail; a unit test of either
side in isolation passes while the product is broken.
Also: the SSE feed now emits the current status on subscribe rather than only on
the next engine transition, so a freshly-opened console page is correct
immediately instead of stale until something happens.
This commit is contained in:
+29
-8
@@ -1,7 +1,23 @@
|
||||
// Domain DTOs shared by the plugin server and the UI — Schemas are the source of truth,
|
||||
// the derived types keep the original domain names so the pure core reads unchanged.
|
||||
//
|
||||
// TRAP, measured (see plugin/test/wire.test.ts, which pins it): the HttpApi RESPONSE path
|
||||
// serialises a present-but-`undefined` key as `null`, where the plain Schema encoder omits
|
||||
// it. So a `Schema.optional(X)` field the server assembles as `{ k: undefined }` goes out
|
||||
// as `"k":null` — which `X | undefined` then REFUSES when the client decodes it. It fails
|
||||
// silently in the SSE feed (`sseAtom` drops schema-invalid frames, so the page just stops
|
||||
// updating) and loudly in the typed AtomHttpApi client.
|
||||
//
|
||||
// The rule here: **nothing on this wire is `undefined`.** Server-assembled fields are
|
||||
// `Schema.NullOr` with an explicit null; fields that also round-trip through cache.json use
|
||||
// `Schema.optional(Schema.NullOr(...))` so an older cache (absent keys) still decodes while
|
||||
// the wire's null is accepted too.
|
||||
import { Schema } from "effect";
|
||||
|
||||
/** Absent | undefined | null on the way in, `null` on the way out — see the trap above. */
|
||||
const nullish = <S extends Schema.Top>(schema: S) =>
|
||||
Schema.optional(Schema.NullOr(schema));
|
||||
|
||||
/** Host OS as the launch/quoting domain sees it (macOS hosts use the linux lane). */
|
||||
export const Os = Schema.Literals(["linux", "windows"]);
|
||||
export type Os = typeof Os.Type;
|
||||
@@ -51,13 +67,16 @@ export const DetectedEmulator = Schema.Struct({
|
||||
name: Schema.String,
|
||||
template: Schema.String,
|
||||
supportsArchives: Schema.Boolean,
|
||||
contested: Schema.optional(Schema.Boolean),
|
||||
// `nullish`, not plain NullOr: these also live in cache.json, and a cache written by
|
||||
// 0.3.1 has the keys ABSENT — requiring them would fail the whole cache decode and
|
||||
// silently discard every art verdict on upgrade.
|
||||
contested: nullish(Schema.Boolean),
|
||||
exeToken: Schema.String,
|
||||
exePath: Schema.optional(Schema.String),
|
||||
appId: Schema.optional(Schema.String),
|
||||
exePath: nullish(Schema.String),
|
||||
appId: nullish(Schema.String),
|
||||
via: Schema.Literals(["path", "file", "flatpak"]),
|
||||
coresDir: Schema.optional(Schema.String),
|
||||
cores: Schema.optional(Schema.Array(Schema.String)),
|
||||
coresDir: nullish(Schema.String),
|
||||
cores: nullish(Schema.Array(Schema.String)),
|
||||
});
|
||||
export type DetectedEmulator = typeof DetectedEmulator.Type;
|
||||
|
||||
@@ -97,9 +116,11 @@ export const EngineStatus = Schema.Struct({
|
||||
os: Os,
|
||||
artProvider: Schema.NullOr(Schema.String),
|
||||
syncing: Schema.Boolean,
|
||||
lastSync: Schema.optional(LastSync),
|
||||
lastReport: Schema.optional(SyncReport),
|
||||
detectedAt: Schema.optional(Schema.Number),
|
||||
// Assembled fresh by the engine on every read and never persisted, so these can be
|
||||
// strict: an explicit null on the wire, never an absent or undefined key.
|
||||
lastSync: Schema.NullOr(LastSync),
|
||||
lastReport: Schema.NullOr(SyncReport),
|
||||
detectedAt: Schema.NullOr(Schema.Number),
|
||||
paths: Schema.Struct({
|
||||
dir: Schema.String,
|
||||
config: Schema.String,
|
||||
|
||||
Reference in New Issue
Block a user