fix: the API emitted undefined optionals as null, and the UI couldn't decode them (v0.3.2)
CI / publish (push) Successful in 22s
CI / build (push) Successful in 34s

`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:
2026-07-20 23:36:01 +02:00
parent d830b7133d
commit 94f18e38b4
8 changed files with 233 additions and 22 deletions
+29 -8
View File
@@ -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,