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:
@@ -120,7 +120,7 @@ export const statusEventsAtom: Atom.Atom<ReadonlyArray<StatusEvent>> =
|
||||
if (!prev.syncing && frame.syncing) push("Sync started");
|
||||
if (prev.syncing && !frame.syncing) {
|
||||
const report = frame.lastReport;
|
||||
if (report === undefined) {
|
||||
if (report === null) {
|
||||
push("Sync finished");
|
||||
} else {
|
||||
push(
|
||||
@@ -136,7 +136,7 @@ export const statusEventsAtom: Atom.Atom<ReadonlyArray<StatusEvent>> =
|
||||
}
|
||||
}
|
||||
if (
|
||||
frame.detectedAt !== undefined &&
|
||||
frame.detectedAt !== null &&
|
||||
frame.detectedAt !== prev.detectedAt
|
||||
) {
|
||||
push("Emulator detection finished");
|
||||
|
||||
@@ -295,11 +295,16 @@ export const statusFor = (scenario: Scenario): EngineStatus => {
|
||||
const report = reportFor(scenario, configFor(scenario));
|
||||
switch (scenario) {
|
||||
case "firstRun":
|
||||
// Explicit nulls, exactly as the server sends them — a fixture that omitted
|
||||
// these would no longer resemble the real wire shape.
|
||||
return {
|
||||
rootsConfigured: 0,
|
||||
os: "linux",
|
||||
artProvider: null,
|
||||
syncing: false,
|
||||
lastSync: null,
|
||||
lastReport: null,
|
||||
detectedAt: null,
|
||||
paths: PATHS,
|
||||
};
|
||||
case "syncing":
|
||||
|
||||
@@ -174,14 +174,13 @@ const DetectedList = ({
|
||||
contested
|
||||
</Badge>
|
||||
) : null}
|
||||
{row.detected?.cores !== undefined &&
|
||||
row.detected.cores.length > 0 ? (
|
||||
{(row.detected?.cores?.length ?? 0) > 0 ? (
|
||||
<Badge variant="outline" size="sm">
|
||||
{row.detected.cores.length} cores
|
||||
{row.detected?.cores?.length} cores
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
{row.detected?.exePath !== undefined ? (
|
||||
{row.detected?.exePath ? (
|
||||
<p className="mt-0.5 truncate font-mono text-xs text-muted-foreground">
|
||||
{row.detected.exePath}
|
||||
</p>
|
||||
|
||||
@@ -72,7 +72,7 @@ const FirstRun = ({ navigate }: PageProps) => (
|
||||
);
|
||||
|
||||
const lastSyncHint = (status: EngineStatus): string => {
|
||||
if (status.lastSync === undefined) return "never synced";
|
||||
if (status.lastSync === null) return "never synced";
|
||||
const minutes = Math.max(
|
||||
0,
|
||||
Math.round((Date.now() - status.lastSync.at) / 60_000),
|
||||
@@ -117,7 +117,7 @@ const SyncButton = ({ status }: { status: EngineStatus }) => {
|
||||
|
||||
const Warnings = ({ status }: { status: EngineStatus }) => {
|
||||
const report = status.lastReport;
|
||||
if (report === undefined) return null;
|
||||
if (report === null) return null;
|
||||
const count = report.warnings.length + (report.overWarn ? 1 : 0);
|
||||
if (count === 0) return null;
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user