From d646dc1db5afc4dd5c1b5e80b66148cb51f90d2e Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 7 Nov 2026 12:33:02 +0100 Subject: [PATCH 1/7] fix(web): unreadable synced rows drop instead of crashing the page; stale tabs reload onto a new build Electric row mappers decoded literals with decodeUnknownSync inside live-query callbacks, so a value a newer server added (e.g. a new alert signal type) threw outside Effect: an uncaught SchemaError on /services and a crash page on /alerts. Each mapper now decodes its whole document once through a shared non-throwing liveRowDecoder that returns Option and reports the dropped row. A lint rule keeps throwing decodes out of web-data collections. A failed chunk load, or a dropped row, now probes /version.json; when a newer build is deployed the tab reloads once per deployed commit (chunk failures always, unreadable rows only while hidden) and otherwise raises the banner. Co-Authored-By: Claude Opus 5.5 --- .oxlintrc.json | 14 +- .../web/src/page/alerts/editor/derive-rows.ts | 10 +- apps/web/src/page/alerts/editor/draft.ts | 3 +- apps/web/src/page/alerts/editor/plan.ts | 7 +- apps/web/src/page/alerts/hub/derive.ts | 17 +- apps/web/src/page/alerts/hub/update.ts | 2 +- apps/web/src/page/alerts/incident/derive.ts | 30 ++- .../alerts/incident/incident.scene.test.ts | 4 +- apps/web/src/page/alerts/rule/derive.ts | 11 +- apps/web/src/page/overview/subscription.ts | 4 +- .../src/page/services/list/subscription.ts | 4 +- apps/web/src/page/settings/update.ts | 4 +- apps/web/src/shell/version.test.ts | 133 ++++++++++++ apps/web/src/shell/version.ts | 140 ++++++++++++- apps/web/src/subscription.ts | 13 ++ .../web-data/src/collections/alerts.test.ts | 98 +++++++-- packages/web-data/src/collections/alerts.ts | 193 +++++++++--------- .../web-data/src/collections/api-keys.test.ts | 40 ++++ packages/web-data/src/collections/api-keys.ts | 53 ++--- packages/web-data/src/collections/live-row.ts | 69 +++++++ .../web-data/src/lib/chunk-reload.test.ts | 11 + packages/web-data/src/lib/chunk-reload.ts | 22 +- scripts/oxlint-plugins/maple.mjs | 34 +++ 23 files changed, 707 insertions(+), 209 deletions(-) create mode 100644 apps/web/src/shell/version.test.ts create mode 100644 packages/web-data/src/collections/api-keys.test.ts create mode 100644 packages/web-data/src/collections/live-row.ts diff --git a/.oxlintrc.json b/.oxlintrc.json index a504dea922..3b3cc2c86f 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -74,7 +74,12 @@ "overrides": [ // The dashboard's design rules (docs/design-rules.md), on the apps and their kit. { - "files": ["apps/web/src/**", "apps/local-ui/src/**", "packages/views/src/**", "packages/ui/src/**"], + "files": [ + "apps/web/src/**", + "apps/local-ui/src/**", + "packages/views/src/**", + "packages/ui/src/**" + ], "rules": { "maple/couldnt-copy": "error", "maple/hover-needs-active": "error", @@ -194,6 +199,13 @@ "typescript/no-non-null-assertion": "off" } }, + // Electric row mappers run inside live-query callbacks: a throwing decode there takes the + // page down when a newer server syncs a value this build does not know. + { + "files": ["packages/web-data/src/collections/**/*.ts"], + "excludeFiles": ["packages/web-data/src/collections/**/*.test.ts"], + "rules": { "maple/no-throwing-live-row-decode": "error" } + }, // `Date.parse` burndown: the server side is clean and held to the rule. These still parse // with it and move to `parseWarehouseDateTime` / `DateTime` in their own audit; the // implementation of `parseWarehouseDateTime` itself is the one permanent entry. diff --git a/apps/web/src/page/alerts/editor/derive-rows.ts b/apps/web/src/page/alerts/editor/derive-rows.ts index 5a9f83ce25..1d00b3fe31 100644 --- a/apps/web/src/page/alerts/editor/derive-rows.ts +++ b/apps/web/src/page/alerts/editor/derive-rows.ts @@ -4,8 +4,8 @@ import type { AlertRuleRow } from "@maple/web-data/collections/alerts" import { buildRuleStatesByRuleId, rowsToAlertDestinationDocuments, - rowToAlertIncidentDocument, - rowToAlertRuleDocument, + rowsToAlertIncidentDocuments, + rowsToAlertRuleDocuments, } from "@maple/web-data/collections/alerts" import { normalizeTimestampInput } from "@maple/web-data/lib/timezone-format" import { AsyncData } from "foldkit" @@ -28,7 +28,9 @@ export const tagSuggestionsOf = ( rows: ReadonlyArray, previous: ReadonlyArray, ): ReadonlyArray => { - const tags = [...new Set(Arr.flatMap(rows, (row) => rowToAlertRuleDocument(row, NO_STATES).tags))].sort() + const tags = [ + ...new Set(Arr.flatMap(rowsToAlertRuleDocuments(rows, NO_STATES), (rule) => rule.tags)), + ].sort() return tags.length === previous.length && Arr.every(tags, (tag, index) => tag === previous[index]) ? previous : tags @@ -41,7 +43,7 @@ export const openIncidentsOf = ( model.editingRuleId === null ? [] : Arr.filter( - Arr.map(model.incidents ?? [], rowToAlertIncidentDocument), + rowsToAlertIncidentDocuments(model.incidents ?? []), (incident) => incident.ruleId === model.editingRuleId && incident.status === "open", ) diff --git a/apps/web/src/page/alerts/editor/draft.ts b/apps/web/src/page/alerts/editor/draft.ts index b5e3ea59a2..4ddb296961 100644 --- a/apps/web/src/page/alerts/editor/draft.ts +++ b/apps/web/src/page/alerts/editor/draft.ts @@ -63,7 +63,8 @@ export const editingRuleOf = (model: Model, ruleId: string): AlertRuleDocument | Arr.findFirst(model.rules ?? [], (candidate) => candidate.id === ruleId), { onNone: () => null, - onSome: (row) => rowToAlertRuleDocument(row, buildRuleStatesByRuleId(model.states ?? [])), + onSome: (row) => + Option.getOrNull(rowToAlertRuleDocument(row, buildRuleStatesByRuleId(model.states ?? []))), }, ) } diff --git a/apps/web/src/page/alerts/editor/plan.ts b/apps/web/src/page/alerts/editor/plan.ts index 28e5cb487e..493c6344d3 100644 --- a/apps/web/src/page/alerts/editor/plan.ts +++ b/apps/web/src/page/alerts/editor/plan.ts @@ -1,5 +1,5 @@ import { pickDefaultDestination } from "@maple/web-data/alerts/form-utils" -import { buildRuleStatesByRuleId, rowToAlertRuleDocument } from "@maple/web-data/collections/alerts" +import { buildRuleStatesByRuleId, rowsToAlertRuleDocuments } from "@maple/web-data/collections/alerts" import { Option, Array as Arr } from "effect" import type { Command } from "foldkit" import * as Keyed from "@maple/views/data/keyed" @@ -110,10 +110,7 @@ export const applyDefaultDestination = (model: Model): Model => { ) return model const states = buildRuleStatesByRuleId(model.states ?? []) - const fallback = pickDefaultDestination( - destinations, - Arr.map(model.rules, (row) => rowToAlertRuleDocument(row, states)), - ) + const fallback = pickDefaultDestination(destinations, rowsToAlertRuleDocuments(model.rules, states)) const form = model.form // A tier with a chain is routed already; it gets no default destination on top. const isApplied = fallback !== null && form.destinationIds.length === 0 && form.escalationChainId === null diff --git a/apps/web/src/page/alerts/hub/derive.ts b/apps/web/src/page/alerts/hub/derive.ts index 4f25a883d2..493a3b3f2e 100644 --- a/apps/web/src/page/alerts/hub/derive.ts +++ b/apps/web/src/page/alerts/hub/derive.ts @@ -3,8 +3,8 @@ import { type AlertsOverviewReady, deriveOverview } from "@maple/web-data/alerts import { buildRuleStatesByRuleId, rowsToAlertDestinationDocuments, - rowToAlertIncidentDocument, - rowToAlertRuleDocument, + rowsToAlertIncidentDocuments, + rowsToAlertRuleDocuments, } from "@maple/web-data/collections/alerts" import { Option, Array as Arr, String as Str } from "effect" import { AsyncData } from "foldkit" @@ -27,8 +27,8 @@ const overviewFor = lastBy( const statesByRule = buildRuleStatesByRuleId(stateRows) return Option.some( deriveOverview({ - rules: Arr.map(rules, (row) => rowToAlertRuleDocument(row, statesByRule)), - incidents: Arr.map(incidents, rowToAlertIncidentDocument), + rules: rowsToAlertRuleDocuments(rules, statesByRule), + incidents: rowsToAlertIncidentDocuments(incidents), states: stateRows, deliveryEvents: Option.getOrElse(AsyncData.getData(deliveries), () => []), now: nowMs, @@ -66,17 +66,14 @@ const anomalyOverviewFor = lastBy( const stateRows = states ?? [] const statesByRule = buildRuleStatesByRuleId(stateRows) const anomalyIncidents = Arr.filter( - Arr.map( - Arr.filter(incidents, (row) => row.signal_type === "anomaly"), - rowToAlertIncidentDocument, - ), + rowsToAlertIncidentDocuments(Arr.filter(incidents, (row) => row.signal_type === "anomaly")), (incident) => serviceName === undefined || isAboutService(incident, serviceName), ) return Option.some( deriveOverview({ - rules: Arr.map( + rules: rowsToAlertRuleDocuments( Arr.filter(rules, (row) => row.signal_type === "anomaly"), - (row) => rowToAlertRuleDocument(row, statesByRule), + statesByRule, ), incidents: anomalyIncidents, states: stateRows, diff --git a/apps/web/src/page/alerts/hub/update.ts b/apps/web/src/page/alerts/hub/update.ts index 4bc9ba85eb..918b146eba 100644 --- a/apps/web/src/page/alerts/hub/update.ts +++ b/apps/web/src/page/alerts/hub/update.ts @@ -822,7 +822,7 @@ const pickedFromMenu = (model: Model, context: PageContext, menuId: string, valu if (kind === "schedule") return pickedScheduleAction(model, target, value) if (kind === "rule") { const id = Str.split("@")(target)[0] - const rule = Option.map( + const rule = Option.flatMap( Array.findFirst(model.rules ?? [], (row) => row.id === id), (row) => rowToAlertRuleDocument(row, new Map()), ) diff --git a/apps/web/src/page/alerts/incident/derive.ts b/apps/web/src/page/alerts/incident/derive.ts index 8a7171bcd2..b0b99090d2 100644 --- a/apps/web/src/page/alerts/incident/derive.ts +++ b/apps/web/src/page/alerts/incident/derive.ts @@ -10,8 +10,10 @@ import type { V2AlertRulePreviewParams } from "@maple/domain/http/v2" import { formatWarehouseDateTime } from "@maple/query-engine" import { ruleToFormState } from "@maple/web-data/alerts/form-utils" import { + type AlertIncidentRow, type AlertRuleStateRow, buildRuleStatesByRuleId, + rowsToAlertIncidentDocuments, rowToAlertIncidentDocument, rowToAlertRuleDocument, } from "@maple/web-data/collections/alerts" @@ -62,20 +64,33 @@ const deriveIncidentView = (model: ViewKey): Option.Option => { const foundIncident = Arr.findFirst(model.incidents, (row) => row.id === model.incidentId) if (Option.isNone(foundIncident)) return Option.none() const incidentRow = foundIncident.value + const now = model.nowMs // Only this incident's rule is decoded: the org's other rules and incidents are never shown here. - const incident = rowToAlertIncidentDocument(incidentRow) + // An incident this build cannot read renders as not found rather than crashing the page. + return Option.flatMap(rowToAlertIncidentDocument(incidentRow), (incident) => + incidentView(model, incidentRow, incident, now), + ) +} + +const incidentView = ( + model: ViewKey, + incidentRow: AlertIncidentRow, + incident: AlertIncidentDocument, + now: number, +): Option.Option => { const rule = Option.match( Arr.findFirst(model.rules ?? [], (row) => row.id === incidentRow.rule_id), { onNone: () => null, onSome: (ruleRow) => - rowToAlertRuleDocument( - ruleRow, - buildRuleStatesByRuleId(ruleStatesOf(model, incidentRow.rule_id)), + Option.getOrNull( + rowToAlertRuleDocument( + ruleRow, + buildRuleStatesByRuleId(ruleStatesOf(model, incidentRow.rule_id)), + ), ), }, ) - const now = model.nowMs const start = isoMs(incident.firstTriggeredAt) const pad = Math.max(30, (rule?.windowMinutes ?? 5) * 4) * MINUTE_MS const end = incident.resolvedAt != null ? Math.min(isoMs(incident.resolvedAt) + pad, now) : now @@ -83,9 +98,8 @@ const deriveIncidentView = (model: ViewKey): Option.Option => { return Option.some({ incident, rule, - ruleIncidents: Arr.map( - Arr.filter(model.incidents, (row) => row.rule_id === incidentRow.rule_id), - rowToAlertIncidentDocument, + ruleIncidents: rowsToAlertIncidentDocuments( + Arr.filter(model.incidents ?? [], (row) => row.rule_id === incidentRow.rule_id), ), range, // An anomaly rule records only severity codes: the detector's own series is the evidence. diff --git a/apps/web/src/page/alerts/incident/incident.scene.test.ts b/apps/web/src/page/alerts/incident/incident.scene.test.ts index 6a2f97599d..54fba0d52d 100644 --- a/apps/web/src/page/alerts/incident/incident.scene.test.ts +++ b/apps/web/src/page/alerts/incident/incident.scene.test.ts @@ -3,7 +3,7 @@ import { OrgId } from "@maple/domain" import * as Policy from "@maple/domain/policy" import { AlertRuleId, AlertRulePreviewResponse, AlertSilenceId } from "@maple/domain/http" import { sloRuleName } from "@maple/domain/slo" -import { Result, Schema, Array as Arr } from "effect" +import { Option, Result, Schema, Array as Arr } from "effect" import { click, Command, @@ -518,7 +518,7 @@ describe("alert incident", () => { created_at: at, }) - const synced = rowToAlertIncidentDocument(incidentRow()) + const synced = Option.getOrThrow(rowToAlertIncidentDocument(incidentRow())) const activityKey = (acknowledgedAt: string | null) => requestKey(ORG, "alertIncidents.listEvents", { id: INCIDENT_ID, diff --git a/apps/web/src/page/alerts/rule/derive.ts b/apps/web/src/page/alerts/rule/derive.ts index 30a17876fe..59a35e0506 100644 --- a/apps/web/src/page/alerts/rule/derive.ts +++ b/apps/web/src/page/alerts/rule/derive.ts @@ -11,7 +11,7 @@ import { ruleToFormState } from "@maple/web-data/alerts/form-utils" import { buildRuleStatesByRuleId, rowsToAlertDestinationDocuments, - rowToAlertIncidentDocument, + rowsToAlertIncidentDocuments, rowToAlertRuleDocument, } from "@maple/web-data/collections/alerts" import { normalizeTimestampInput } from "@maple/web-data/lib/timezone-format" @@ -46,7 +46,7 @@ const ruleFor = lastBy( const row = Arr.findFirst(rules ?? [], (candidate) => candidate.id === id) if (Option.isNone(row)) return null const own = Arr.filter(states ?? [], (state) => state.rule_id === id) - return rowToAlertRuleDocument(row.value, buildRuleStatesByRuleId(own)) + return Option.getOrNull(rowToAlertRuleDocument(row.value, buildRuleStatesByRuleId(own))) }, ) @@ -56,10 +56,9 @@ export const ruleOf = (model: Model): AlertRuleDocument | null => const ruleIncidentsFor = lastBy( ([incidents, id]: readonly [Model["incidents"], Model["ruleId"]]): ReadonlyArray => - Arr.map( - Arr.filter(incidents ?? [], (row) => row.rule_id === id), - rowToAlertIncidentDocument, - ).sort((a, b) => isoTime(b.lastTriggeredAt) - isoTime(a.lastTriggeredAt)), + [...rowsToAlertIncidentDocuments(Arr.filter(incidents ?? [], (row) => row.rule_id === id))].sort( + (a, b) => isoTime(b.lastTriggeredAt) - isoTime(a.lastTriggeredAt), + ), ) const isoTime = (iso: string | null): number => (iso ? new Date(iso).getTime() : 0) diff --git a/apps/web/src/page/overview/subscription.ts b/apps/web/src/page/overview/subscription.ts index 04a761d748..8edb395833 100644 --- a/apps/web/src/page/overview/subscription.ts +++ b/apps/web/src/page/overview/subscription.ts @@ -1,4 +1,4 @@ -import { rowToAlertIncidentDocument } from "@maple/web-data/collections/alerts" +import { rowsToAlertIncidentDocuments } from "@maple/web-data/collections/alerts" import { Schema, Stream, Array as Arr } from "effect" import { AsyncData, Subscription } from "foldkit" import { type OrgCollectionSet, type OrgCollections, orgLive } from "../../data/collections/org-collections" @@ -28,7 +28,7 @@ const incidents = (orgId: string) => (rows) => Message.UpdatedIncidents({ orgId, - incidents: Arr.map(Arr.map(rows, rowToAlertIncidentDocument), (incident) => ({ + incidents: Arr.map(rowsToAlertIncidentDocuments(rows), (incident) => ({ status: incident.status, groupKey: incident.groupKey, severity: incident.severity, diff --git a/apps/web/src/page/services/list/subscription.ts b/apps/web/src/page/services/list/subscription.ts index b854255e5a..4e6c4f5d84 100644 --- a/apps/web/src/page/services/list/subscription.ts +++ b/apps/web/src/page/services/list/subscription.ts @@ -1,4 +1,4 @@ -import { rowToAlertIncidentDocument } from "@maple/web-data/collections/alerts" +import { rowsToAlertIncidentDocuments } from "@maple/web-data/collections/alerts" import { Schema, Stream, Array as Arr } from "effect" import { AsyncData, Subscription } from "foldkit" import { type OrgCollections, orgLive } from "../../../data/collections/org-collections" @@ -17,7 +17,7 @@ const incidents = (orgId: string): Stream.Stream (rows) => Message.UpdatedIncidents({ orgId, - incidents: Arr.map(Arr.map(rows, rowToAlertIncidentDocument), (incident) => ({ + incidents: Arr.map(rowsToAlertIncidentDocuments(rows), (incident) => ({ status: incident.status, groupKey: incident.groupKey, severity: incident.severity, diff --git a/apps/web/src/page/settings/update.ts b/apps/web/src/page/settings/update.ts index ffb450cf65..89fd34b479 100644 --- a/apps/web/src/page/settings/update.ts +++ b/apps/web/src/page/settings/update.ts @@ -2,7 +2,7 @@ import * as Policy from "@maple/domain/policy" import { withoutCheckoutReturn } from "../../lib/billing/checkout-return" import { hasBringYourOwnCloudAddOn } from "../../lib/billing/plan-gating" import { isClerkAuthEnabled } from "../../lib/services/common/auth-mode" -import { rowToV2ApiKey } from "@maple/web-data/collections/api-keys" +import { rowsToV2ApiKeys } from "@maple/web-data/collections/api-keys" import { Array, Option } from "effect" import { AsyncData, Command } from "foldkit" import { AppRoute, hrefOf } from "../../route" @@ -755,7 +755,7 @@ const updateMessage = (model: Model, message: Message, context: PageContext): Re }, UpdatedApiKeys: ({ orgId, rows }) => { if (orgId !== context.orgId) return { model } - const apiKeys = Array.map(rows, rowToV2ApiKey) + const apiKeys = rowsToV2ApiKeys(rows) const next = { ...model, apiKeys, isApiKeysLoadFailed: false } return next.tab !== null && next.tab._tag === "Keys" ? liftKeys(next, next.tab.model, KeysMessageValue.UpdatedApiKeys({ rows: apiKeys })) diff --git a/apps/web/src/shell/version.test.ts b/apps/web/src/shell/version.test.ts new file mode 100644 index 0000000000..de0ca14258 --- /dev/null +++ b/apps/web/src/shell/version.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from "@effect/vitest" +import { Effect, Layer, Option } from "effect" +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http" +import { BUILT_COMMIT, probeStaleBuild, type StaleSignal } from "./version" + +const memoryStorage = () => { + const values = new Map() + return { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => { + values.set(key, value) + }, + } +} + +// The tab fetches a relative `/version.json`; Node needs an origin to parse it. +const answering = (body: string) => + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, new Response(body))), + ).pipe(HttpClient.mapRequest(HttpClientRequest.prependUrl("https://app.test"))), + ) + +const serving = (commit: string) => answering(JSON.stringify({ commit })) + +const visible = Effect.succeed(false) +const hidden = Effect.succeed(true) + +const signal: StaleSignal = { trigger: "chunk_load", detail: "Failed to fetch dynamically imported module" } + +describe("probeStaleBuild", () => { + it.effect("reloads once onto a newer deployed build, then leaves it to the banner", () => + Effect.gen(function* () { + const storage = memoryStorage() + let reloads = 0 + const reload = Effect.sync(() => { + reloads += 1 + }) + const first = yield* probeStaleBuild(signal, storage, visible, reload) + const second = yield* probeStaleBuild(signal, storage, visible, reload) + expect(reloads).toBe(1) + // Both surface the newer commit so the banner shows if the reload did not land. + expect(Option.map(first, (message) => message.maybeCommit)).toEqual( + Option.some(Option.some("next-build")), + ) + expect(Option.isSome(second)).toBe(true) + }).pipe(Effect.provide(serving("next-build"))), + ) + + it.effect("reloads again for a later deploy", () => + Effect.gen(function* () { + const storage = memoryStorage() + let reloads = 0 + const reload = Effect.sync(() => { + reloads += 1 + }) + yield* probeStaleBuild(signal, storage, visible, reload).pipe(Effect.provide(serving("build-a"))) + yield* probeStaleBuild(signal, storage, visible, reload).pipe(Effect.provide(serving("build-b"))) + expect(reloads).toBe(2) + }), + ) + + it.effect("does nothing when the server runs this build", () => + Effect.gen(function* () { + let reloads = 0 + const result = yield* probeStaleBuild( + signal, + memoryStorage(), + visible, + Effect.sync(() => { + reloads += 1 + }), + ) + expect(reloads).toBe(0) + expect(Option.isNone(result)).toBe(true) + }).pipe(Effect.provide(serving(BUILT_COMMIT))), + ) + + it.effect("does not reload when the deployed commit cannot be read", () => + Effect.gen(function* () { + let reloads = 0 + const result = yield* probeStaleBuild( + signal, + memoryStorage(), + visible, + Effect.sync(() => { + reloads += 1 + }), + ) + expect(reloads).toBe(0) + expect(Option.isNone(result)).toBe(true) + }).pipe( + // A stale CDN answers an unknown path with index.html. + Effect.provide(answering("")), + ), + ) + + it.effect("does not reload when session storage is unavailable", () => + Effect.gen(function* () { + let reloads = 0 + const throwing = { + getItem: () => { + throw new DOMException("denied", "SecurityError") + }, + setItem: () => {}, + } + yield* probeStaleBuild( + signal, + throwing, + visible, + Effect.sync(() => { + reloads += 1 + }), + ) + expect(reloads).toBe(0) + }).pipe(Effect.provide(serving("next-build"))), + ) + it.effect("an unreadable row raises the banner on a visible tab but does not reload it", () => + Effect.gen(function* () { + const row: StaleSignal = { trigger: "undecodable_row", detail: "alert_incidents x" } + let reloads = 0 + const reload = Effect.sync(() => { + reloads += 1 + }) + const shown = yield* probeStaleBuild(row, memoryStorage(), visible, reload) + expect(reloads).toBe(0) + expect(Option.isSome(shown)).toBe(true) + yield* probeStaleBuild(row, memoryStorage(), hidden, reload) + expect(reloads).toBe(1) + }).pipe(Effect.provide(serving("next-build"))), + ) +}) diff --git a/apps/web/src/shell/version.ts b/apps/web/src/shell/version.ts index 77fe626014..7bb29abbb6 100644 --- a/apps/web/src/shell/version.ts +++ b/apps/web/src/shell/version.ts @@ -1,4 +1,6 @@ -import { Clock, Duration, Effect, Option, Schedule, Schema, Stream } from "effect" +import { onUndecodableRow } from "@maple/web-data/collections/live-row" +import { isChunkLoadError } from "@maple/web-data/lib/chunk-reload" +import { Clock, Duration, Effect, Option, Queue, Result, Schedule, Schema, Stream } from "effect" import { HttpClient, HttpClientResponse } from "effect/http" import { Command, Dom } from "foldkit" import { Message } from "./message" @@ -12,20 +14,25 @@ const POLL_INTERVAL = Duration.minutes(15) const VersionFile = Schema.Struct({ commit: Schema.String }) -/** `fetchDeployedCommit`: `/version.json`, uncached; a hidden tab or any failure reports nothing. */ -export const CheckDeployedCommit = Command.define("CheckDeployedCommit", { - messages: [Message.CheckedDeployedCommit], - execute: Effect.gen(function* () { - if (document.hidden) return Message.CheckedDeployedCommit({ maybeCommit: Option.none() }) +/** The deployed commit from `/version.json`, uncached; any failure reports nothing. */ +const deployedCommit: Effect.Effect, never, HttpClient.HttpClient> = Effect.gen( + function* () { const now = yield* Clock.currentTimeMillis const client = yield* HttpClient.HttpClient // The timestamp busts every cache. const response = yield* client.get(`/version.json?t=${now}`) const { commit } = yield* HttpClientResponse.schemaBodyJson(VersionFile)(response) - return Message.CheckedDeployedCommit({ - maybeCommit: commit.length > 0 ? Option.some(commit) : Option.none(), - }) - }).pipe(Effect.orElseSucceed(() => Message.CheckedDeployedCommit({ maybeCommit: Option.none() }))), + return commit.length > 0 ? Option.some(commit) : Option.none() + }, +).pipe(Effect.orElseSucceed(() => Option.none())) + +/** `fetchDeployedCommit`: a hidden tab reports nothing. */ +export const CheckDeployedCommit = Command.define("CheckDeployedCommit", { + messages: [Message.CheckedDeployedCommit], + execute: Effect.gen(function* () { + if (document.hidden) return Message.CheckedDeployedCommit({ maybeCommit: Option.none() }) + return Message.CheckedDeployedCommit({ maybeCommit: yield* deployedCommit }) + }), }) /** The banner's Reload button. */ @@ -48,3 +55,116 @@ export const versionChecks: Stream.Stream = Stream.merge( filterMapEvent: () => (document.hidden ? Option.none() : Option.some(Message.BecameTabVisible())), }), ) + +// Stale-build recovery: a failure that a superseded build explains (a removed chunk, a synced +// row with a value this build does not know) probes the deployed commit at once instead of +// waiting for the next poll, and reloads onto the new build. + +export type StaleSignal = Readonly<{ trigger: "chunk_load" | "undecodable_row"; detail: string }> + +const RELOAD_KEY = "maple:stale-build-reload" + +class ReloadGuardUnavailableError extends Schema.TaggedError()( + "@maple/web/shell/ReloadGuardUnavailableError", + { message: Schema.String, cause: Schema.Defect() }, +) {} + +/** + * At most one automatic reload per deployed commit per tab session: if the reload does not + * land on the new build (a cached index.html), the next signal shows the banner instead. + */ +export const claimReload = (storage: Pick, commit: string) => + Effect.try({ + try: () => { + if (storage.getItem(RELOAD_KEY) === commit) return false + storage.setItem(RELOAD_KEY, commit) + return true + }, + catch: (cause) => + new ReloadGuardUnavailableError({ message: "session storage is unavailable", cause }), + }).pipe( + // Storage blocked: without the guard a reload could loop, so leave it to the banner. + Effect.orElseSucceed(() => false), + ) + +/** + * `None` when the server runs this build (the failure is something else). Otherwise the + * `CheckedDeployedCommit` raises the banner, after one reload when it is safe: a missing chunk + * has already broken the page, but an unreadable row has not, so it reloads only a hidden tab + * and never throws away a visible tab's unsaved edits. + */ +export const probeStaleBuild = ( + signal: StaleSignal, + storage: Pick, + isTabHidden: Effect.Effect, + reload: Effect.Effect, +) => + Effect.gen(function* () { + yield* Effect.logWarning("Stale-build signal").pipe( + Effect.annotateLogs({ trigger: signal.trigger, detail: signal.detail }), + ) + const deployed = yield* deployedCommit + const isNewer = Option.exists(deployed, (commit) => commit !== BUILT_COMMIT) + yield* Effect.annotateCurrentSpan({ "maple.stale_build.newer": isNewer }) + if (!isNewer) return Option.none() + const mayReload = signal.trigger === "chunk_load" || (yield* isTabHidden) + const isReloading = + mayReload && Option.isSome(deployed) && (yield* claimReload(storage, deployed.value)) + yield* Effect.annotateCurrentSpan({ "maple.stale_build.reloading": isReloading }) + if (isReloading) yield* reload + return Option.some(Message.CheckedDeployedCommit({ maybeCommit: deployed })) + }).pipe( + Effect.withSpan("StaleBuildProbe", { + attributes: { + "maple.stale_build.trigger": signal.trigger, + "maple.stale_build.detail": signal.detail, + }, + }), + ) + +const errorMessage = (error: unknown): string => + error instanceof Error ? error.message : typeof error === "string" ? error : "unknown" + +const staleSignals: Stream.Stream = Stream.mergeAll( + [ + // Vite reports every failed dynamic import (and its CSS/dep preloads) here. + Dom.streamFromEventFilterMap({ + target: () => window, + type: "vite:preloadError", + filterMapEvent: (event) => + Option.some({ trigger: "chunk_load", detail: errorMessage(event.payload) }), + }), + Dom.streamFromEventFilterMap({ + target: () => window, + type: "unhandledrejection", + filterMapEvent: (event) => + isChunkLoadError(event.reason) + ? Option.some({ trigger: "chunk_load", detail: errorMessage(event.reason) }) + : Option.none(), + }), + Stream.callback((queue) => + Effect.acquireRelease( + Effect.sync(() => + onUndecodableRow((row) => { + Queue.offerUnsafe(queue, { + trigger: "undecodable_row", + detail: `${row.collection} ${row.rowId}: ${row.message}`, + }) + }), + ), + (unsubscribe) => Effect.sync(unsubscribe), + ), + ), + ], + { concurrency: "unbounded" }, +) + +const reloadPage = Effect.sync(() => window.location.reload()) +const isDocumentHidden = Effect.sync(() => document.hidden) + +/** Probes on a stale-build signal; a burst (many bad rows at once) probes once. */ +export const staleBuildRecovery: Stream.Stream = staleSignals.pipe( + Stream.debounce(Duration.seconds(1)), + Stream.mapEffect((signal) => probeStaleBuild(signal, sessionStorage, isDocumentHidden, reloadPage)), + Stream.filterMap((message) => Result.fromOption(message, () => undefined)), +) diff --git a/apps/web/src/subscription.ts b/apps/web/src/subscription.ts index 3121c80fcb..2191a3edce 100644 --- a/apps/web/src/subscription.ts +++ b/apps/web/src/subscription.ts @@ -17,6 +17,7 @@ import { connectSubscriptions } from "./page/onboarding/quick-start-connect" import * as SourcesFlow from "./page/sources/new/embed" import { clerkSnapshots } from "./platform/clerk" import * as Shell from "./shell" +import { staleBuildRecovery } from "./shell/version" import { pollTicks } from "./data/poll" import { collectionLoadFailed } from "./page/alerts/shared/collection-load" @@ -240,6 +241,18 @@ const root = Subscription.make()((entry) => ({ ), }, ), + /** A removed chunk or an unreadable synced row probes for a newer build and reloads onto it. */ + shellStaleBuild: entry( + { isWatching: Schema.Boolean }, + { + modelToDependencies: () => ({ isWatching: Shell.BUILT_COMMIT.length > 0 }), + dependenciesToStream: ({ isWatching }) => + Stream.when( + Stream.map(staleBuildRecovery, (message) => Message.GotShellMessage({ message })), + Effect.sync(() => isWatching), + ), + }, + ), /** `defaultPreload: "intent"`: link hover, focus and touch, while a shell page is open. */ linkIntent: entry( { isShellOpen: Schema.Boolean }, diff --git a/packages/web-data/src/collections/alerts.test.ts b/packages/web-data/src/collections/alerts.test.ts index e2599d175a..fa6fd73022 100644 --- a/packages/web-data/src/collections/alerts.test.ts +++ b/packages/web-data/src/collections/alerts.test.ts @@ -11,7 +11,10 @@ import { rowToAlertIncidentDocument, rowToAlertRuleDocument, rowsToAlertDestinationDocuments, + rowsToAlertIncidentDocuments, + rowsToAlertRuleDocuments, } from "./alerts" +import { onUndecodableRow, type UndecodableRow } from "./live-row" const RULE_ID = "99999999-8888-4777-8666-555544443333" const INCIDENT_ID = "aaaa1111-2222-4333-8444-555566667777" @@ -55,6 +58,10 @@ const ruleRow: AlertRuleRow = { updated_by: "user_2", } +const rule = (row: AlertRuleRow, states: Map) => + Option.getOrThrow(rowToAlertRuleDocument(row, states)) +const incident = (row: AlertIncidentRow) => Option.getOrThrow(rowToAlertIncidentDocument(row)) + describe("buildRuleStatesByRuleId", () => { const base: AlertRuleStateRow = { org_id: "org_1", @@ -99,7 +106,7 @@ describe("rowToAlertRuleDocument", () => { updated_at: "2026-07-04T01:00:00.000Z", }, ]) - const doc = rowToAlertRuleDocument(ruleRow, states) + const doc = rule(ruleRow, states) assert.strictEqual(doc.id, RULE_ID) assert.strictEqual(doc.name, "High error rate") assert.strictEqual(doc.enabled, true) @@ -123,22 +130,19 @@ describe("rowToAlertRuleDocument", () => { }) it("leaves evaluation fields null when the rule has no state row", () => { - const doc = rowToAlertRuleDocument(ruleRow, new Map()) + const doc = rule(ruleRow, new Map()) assert.strictEqual(doc.lastEvaluationError, null) assert.strictEqual(doc.lastEvaluatedAt, null) }) it("decodes the raw_query reducer only for raw_query rules", () => { - const doc = rowToAlertRuleDocument( - { ...ruleRow, signal_type: "raw_query", reducer: "sum" }, - new Map(), - ) + const doc = rule({ ...ruleRow, signal_type: "raw_query", reducer: "sum" }, new Map()) assert.strictEqual(doc.signalType, "raw_query") assert.strictEqual(doc.rawQueryReducer, "sum") }) it("reads a row written before severity tiers as one tier from its legacy columns", () => { - const doc = rowToAlertRuleDocument(ruleRow, new Map()) + const doc = rule(ruleRow, new Map()) assert.deepStrictEqual(doc.thresholds, [ { severity: "warning", value: 0.05, upper: null, recoveryValue: null, destinationIds: [DEST_ID] }, ]) @@ -146,7 +150,7 @@ describe("rowToAlertRuleDocument", () => { }) it("reads stored tiers lowest first and derives the single-threshold fields from them", () => { - const doc = rowToAlertRuleDocument( + const doc = rule( { ...ruleRow, thresholds_json: [ @@ -181,6 +185,34 @@ describe("rowToAlertRuleDocument", () => { assert.deepStrictEqual([...doc.destinationIds], [DEST_ID, PAGER_ID]) assert.strictEqual(doc.breachesOutOf, 5) }) + + it.each([ + ["signal type", { signal_type: "a_future_signal" }], + ["comparator", { comparator: "approx" }], + ["legacy severity", { severity: "page" }], + ["window", { window_minutes: 0 }], + ["group by", { group_by: "not json" }], + ])("drops a row with an unknown %s instead of throwing", (_field, patch) => { + assert.isTrue(Option.isNone(rowToAlertRuleDocument({ ...ruleRow, ...patch }, new Map()))) + }) + + it("drops a rule whose stored tiers it cannot read rather than misreading the legacy columns", () => { + const stored = [ + { severity: "page", value: 0.2, upper: null, recoveryValue: null, destinationIds: [] }, + ] + assert.isTrue( + Option.isNone(rowToAlertRuleDocument({ ...ruleRow, thresholds_json: stored }, new Map())), + ) + }) + + it("keeps the readable rules when one is not", () => { + const future = { ...ruleRow, id: "future-rule", signal_type: "a_future_signal" } + const docs = rowsToAlertRuleDocuments([future, ruleRow], new Map()) + assert.deepStrictEqual( + Arr.map(docs, (doc) => doc.id), + [RULE_ID], + ) + }) }) describe("rowToAlertIncidentDocument", () => { @@ -214,7 +246,7 @@ describe("rowToAlertIncidentDocument", () => { } it("maps a raw alert_incidents row", () => { - const doc = rowToAlertIncidentDocument(incidentRow) + const doc = incident(incidentRow) assert.strictEqual(doc.id, INCIDENT_ID) assert.strictEqual(doc.ruleId, RULE_ID) assert.strictEqual(doc.status, "open") @@ -228,19 +260,19 @@ describe("rowToAlertIncidentDocument", () => { }) it("carries an incident's labels and drops ones it cannot read", () => { - const labelled = rowToAlertIncidentDocument({ + const labelled = incident({ ...incidentRow, labels_json: { service: "checkout", environment: "production" }, }) assert.deepStrictEqual(labelled.labels, { service: "checkout", environment: "production" }) - const unreadable = rowToAlertIncidentDocument({ ...incidentRow, labels_json: { service: 42 } }) + const unreadable = incident({ ...incidentRow, labels_json: { service: 42 } }) assert.strictEqual(unreadable.status, "open") assert.isUndefined(unreadable.labels) - assert.isUndefined(rowToAlertIncidentDocument(incidentRow).labels) + assert.isUndefined(incident(incidentRow).labels) }) it("maps a held row's hold reason and timestamp", () => { - const doc = rowToAlertIncidentDocument({ + const doc = incident({ ...incidentRow, hold_reason: "volume_collapsed", held_since: "2026-07-04T00:40:00.000Z", @@ -249,6 +281,46 @@ describe("rowToAlertIncidentDocument", () => { assert.strictEqual(doc.holdReason, "volume_collapsed") assert.strictEqual(doc.heldSince, "2026-07-04T00:40:00.000Z") }) + + it("drops a row carrying a value a newer server added, and reports it once", () => { + const reports: Array = [] + const unsubscribe = onUndecodableRow((row) => reports.push(row)) + const future = { ...incidentRow, id: "future-signal-incident", signal_type: "a_future_signal" } + assert.isTrue(Option.isNone(rowToAlertIncidentDocument(future))) + // Live queries re-map on every change: the same row is reported once. + const docs = rowsToAlertIncidentDocuments([future, incidentRow]) + unsubscribe() + assert.deepStrictEqual( + Arr.map(docs, (doc) => doc.id), + [INCIDENT_ID], + ) + assert.deepStrictEqual( + Arr.map(reports, (row) => [row.collection, row.rowId]), + [["alert_incidents", "future-signal-incident"]], + ) + }) + + it("keeps an unheard report for a listener that subscribes later", () => { + const early = { ...incidentRow, id: "early-incident", signal_type: "a_future_signal" } + assert.isTrue(Option.isNone(rowToAlertIncidentDocument(early))) + const reports: Array = [] + const unsubscribe = onUndecodableRow((row) => reports.push(row.rowId)) + rowToAlertIncidentDocument(early) + unsubscribe() + assert.deepStrictEqual(reports, ["early-incident"]) + }) + + it.each([ + ["severity", { severity: "page" }], + ["peak severity", { peak_severity: "page" }], + ["status", { status: "snoozed" }], + ["comparator", { comparator: "approx" }], + ["event type", { last_delivered_event_type: "escalate_again" }], + ["hold reason", { hold_reason: "maintenance" }], + ["timestamp", { first_triggered_at: "yesterday" }], + ])("drops a row with an unknown %s instead of throwing", (_field, patch) => { + assert.isTrue(Option.isNone(rowToAlertIncidentDocument({ ...incidentRow, ...patch }))) + }) }) describe("rowToAlertDestinationDocument", () => { diff --git a/packages/web-data/src/collections/alerts.ts b/packages/web-data/src/collections/alerts.ts index 1a6d242992..8e70e9c156 100644 --- a/packages/web-data/src/collections/alerts.ts +++ b/packages/web-data/src/collections/alerts.ts @@ -1,75 +1,49 @@ import { - AlertComparator, AlertDestinationDocument, - AlertDestinationId, AlertDestinationType, AlertEvaluationErrorKind, - AlertEvaluationIntervalMinutes, - AlertEventType, - AlertGroupBy, AlertIncidentDocument, AlertIncidentAckSource, - AlertIncidentHoldReason, AlertIncidentLabels, - AlertIncidentStatus, AlertNotificationTemplate, AlertRuleDocument, - AlertRuleId, - AlertSeverity, - AlertSignalType, AlertThresholds, AnomalyIncidentId, AnomalySignalType, ChatConnectorId, ChatWorkspaceId, - ErrorIssueId, - AlertSilenceId, - IsoDateTimeString, QueryBuilderQueryDraftSchema, UserId, alertThresholdLegacyFields, - legacyAlertThreshold, sortAlertThresholds, } from "@maple/domain/http" -import { QueryEngineAlertReducer, QueryEngineNoDataBehavior } from "@maple/domain/query-engine" +import { QueryEngineNoDataBehavior } from "@maple/domain/query-engine" import { Array as Arr, Option, Schema } from "effect" +import { liveRowDecoder, liveValueDecoder } from "./live-row" -const decodeIso = Schema.decodeUnknownSync(IsoDateTimeString) -const asAlertRuleId = Schema.decodeUnknownSync(AlertRuleId) -const asAlertIncidentId = Schema.decodeUnknownSync(AlertIncidentDocument.fields.id) -const asUserId = Schema.decodeUnknownSync(UserId) -const asErrorIssueId = Schema.decodeUnknownSync(ErrorIssueId) -const asAlertSilenceId = Schema.decodeUnknownSync(AlertSilenceId) -const decodeDestinationId = Schema.decodeUnknownSync(AlertDestinationId) +// Each mapper hands its row to one whole-document decode (`live-row.ts`): a value this build +// does not know (a signal type, severity or status added by a newer server) drops that row +// and reports it, instead of throwing inside the live query and taking the page down. +const decodeRule = liveRowDecoder("alert_rules", AlertRuleDocument) +const decodeRuleThresholds = liveValueDecoder("alert_rules", AlertThresholds) +const decodeIncident = liveRowDecoder("alert_incidents", AlertIncidentDocument) +const decodeDestination = liveRowDecoder("alert_destinations", AlertDestinationDocument) const decodeDestinationType = Schema.decodeUnknownOption(AlertDestinationType) -const asSeverity = Schema.decodeUnknownSync(AlertSeverity) -const asSignalType = Schema.decodeUnknownSync(AlertSignalType) -const asComparator = Schema.decodeUnknownSync(AlertComparator) -const asEvaluationInterval = Schema.decodeUnknownSync(Schema.NullOr(AlertEvaluationIntervalMinutes)) +// Lenient fields: these degrade to a default instead of dropping the row. const decodeErrorKind = Schema.decodeUnknownOption(AlertEvaluationErrorKind) -const asIncidentStatus = Schema.decodeUnknownSync(AlertIncidentStatus) -const asHoldReason = Schema.decodeUnknownSync(AlertIncidentHoldReason) -// Lenient: a source added by a newer server reads as unknown rather than breaking the alerts list. const decodeAckSource = Schema.decodeUnknownOption(AlertIncidentAckSource) const decodeUserId = Schema.decodeUnknownOption(UserId) -const asEventType = Schema.decodeUnknownSync(AlertEventType) -const asReducer = Schema.decodeUnknownSync(QueryEngineAlertReducer) -// Lenient: a behavior added by a newer server reads as skip rather than breaking the alerts list. const decodeNoDataBehavior = Schema.decodeUnknownOption(QueryEngineNoDataBehavior) - const decodeNotificationTemplate = Schema.decodeUnknownOption(AlertNotificationTemplate) const decodeQueryBuilderDraft = Schema.decodeUnknownOption(QueryBuilderQueryDraftSchema) -const decodeThresholds = Schema.decodeUnknownOption(AlertThresholds) const decodeStringArray = Schema.decodeUnknownOption(Schema.Array(Schema.String)) -// Lenient: a signal added by a newer server drops the list rather than breaking the alerts list. const decodeAnomalySignals = Schema.decodeUnknownOption(Schema.Array(AnomalySignalType)) const decodeAnomalyIncidentId = Schema.decodeUnknownOption(AnomalyIncidentId) const decodeIncidentLabels = Schema.decodeUnknownOption(AlertIncidentLabels) -// The stored `group_by` column is a JSON-encoded string array (mirrors the -// server's `Schema.fromJsonString(AlertGroupBy)`). -const decodeGroupByFromJson = Schema.decodeUnknownSync(Schema.fromJsonString(AlertGroupBy)) +// `group_by` is a JSON-encoded array; an unparseable one stays a string and fails the rule decode. +const parseJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)) /** * `safeParseStringArray` mirrors AlertsService: use the value when it decodes to @@ -218,29 +192,47 @@ export const buildRuleStatesByRuleId = ( * Decodes a raw `alert_rules` row into the domain {@link AlertRuleDocument}, * mirroring `rowToRuleDocument` in AlertsService.ts. The joined evaluation state * (from the `alert_rule_states` collection) supplies `lastEvaluationError` / - * `lastEvaluatedAt`. + * `lastEvaluatedAt`. `None` for a row this build cannot read. */ export const rowToAlertRuleDocument = ( row: AlertRuleRow, statesByRuleId: Map, -): AlertRuleDocument => { - const state = statesByRuleId.get(row.id) - const destinationIds = Arr.map(safeParseStringArray(row.destination_ids_json), (id) => - decodeDestinationId(id), - ) - // Rows written before tiers carry none: they are one tier from the legacy columns. - const legacyTier = legacyAlertThreshold({ - severity: asSeverity(row.severity), - threshold: row.threshold, - thresholdUpper: row.threshold_upper, - destinationIds, - }) - const thresholds = sortAlertThresholds( - Option.getOrElse(decodeThresholds(row.thresholds_json), () => Arr.of(legacyTier)), +): Option.Option => + Option.flatMap(ruleThresholds(row), (thresholds) => + ruleDocument(row, statesByRuleId.get(row.id), sortAlertThresholds(thresholds)), ) + +/** The live rules as documents, without rows this build cannot read. */ +export const rowsToAlertRuleDocuments = ( + rows: ReadonlyArray, + statesByRuleId: Map, +): ReadonlyArray => + Arr.getSomes(Arr.map(rows, (row) => rowToAlertRuleDocument(row, statesByRuleId))) + +// Rows written before tiers carry none: they are one tier from the legacy columns +// (`legacyAlertThreshold`, before its severity is known to be readable). Stored tiers this build +// cannot read drop the rule: a legacy stand-in would overwrite the real tiers on save. +const ruleThresholds = (row: AlertRuleRow): Option.Option => + row.thresholds_json != null + ? decodeRuleThresholds(row.id, row.thresholds_json) + : decodeRuleThresholds(row.id, [ + { + severity: row.severity, + value: row.threshold, + upper: row.threshold_upper, + recoveryValue: null, + destinationIds: safeParseStringArray(row.destination_ids_json), + }, + ]) + +const ruleDocument = ( + row: AlertRuleRow, + state: AlertRuleStateRow | undefined, + thresholds: AlertThresholds, +): Option.Option => { const legacy = alertThresholdLegacyFields(thresholds) - return new AlertRuleDocument({ - id: asAlertRuleId(row.id), + return decodeRule(row.id, { + id: row.id, name: row.name, notes: row.notes ?? null, notificationTemplate: @@ -261,13 +253,13 @@ export const rowToAlertRuleDocument = ( } : undefined), tags: [...safeParseStringArray(row.tags_json)], - groupBy: row.group_by == null ? null : decodeGroupByFromJson(row.group_by), - signalType: asSignalType(row.signal_type), - comparator: asComparator(row.comparator), + groupBy: row.group_by == null ? null : Option.getOrElse(parseJson(row.group_by), () => row.group_by), + signalType: row.signal_type, + comparator: row.comparator, threshold: legacy.threshold, thresholdUpper: legacy.thresholdUpper, windowMinutes: row.window_minutes, - evaluationIntervalMinutes: asEvaluationInterval(row.evaluation_interval_minutes ?? null), + evaluationIntervalMinutes: row.evaluation_interval_minutes ?? null, minimumSampleCount: row.minimum_sample_count, consecutiveBreachesRequired: row.consecutive_breaches_required, breachesOutOf: row.breaches_out_of ?? null, @@ -279,51 +271,51 @@ export const rowToAlertRuleDocument = ( ? null : Option.getOrElse(decodeQueryBuilderDraft(row.query_builder_draft_json), () => null), rawQuerySql: row.raw_query_sql ?? null, - rawQueryReducer: row.signal_type === "raw_query" ? asReducer(row.reducer) : null, + rawQueryReducer: row.signal_type === "raw_query" ? row.reducer : null, destinationIds: legacy.destinationIds, noDataBehavior: Option.getOrElse(decodeNoDataBehavior(row.no_data_behavior), () => "skip" as const), lastEvaluationError: state?.last_error ?? null, lastEvaluationErrorKind: Option.getOrNull(decodeErrorKind(state?.last_error_kind)), - slowedIntervalMinutes: asEvaluationInterval(row.slowed_interval_minutes ?? null), - slowedAt: row.slowed_at != null ? decodeIso(row.slowed_at) : null, - lastEvaluatedAt: state?.last_evaluated_at != null ? decodeIso(state.last_evaluated_at) : null, - lastScheduledAt: row.last_scheduled_at != null ? decodeIso(row.last_scheduled_at) : null, - createdAt: decodeIso(row.created_at), - updatedAt: decodeIso(row.updated_at), - createdBy: asUserId(row.created_by), - updatedBy: asUserId(row.updated_by), + slowedIntervalMinutes: row.slowed_interval_minutes ?? null, + slowedAt: row.slowed_at ?? null, + lastEvaluatedAt: state?.last_evaluated_at ?? null, + lastScheduledAt: row.last_scheduled_at, + createdAt: row.created_at, + updatedAt: row.updated_at, + createdBy: row.created_by, + updatedBy: row.updated_by, }) } /** * Decodes a raw `alert_incidents` row into the domain {@link AlertIncidentDocument}, - * mirroring `rowToIncidentDocument` in AlertsService.ts. + * mirroring `rowToIncidentDocument` in AlertsService.ts. `None` for a row this build + * cannot read (e.g. a signal type added after it was built). */ -export const rowToAlertIncidentDocument = (row: AlertIncidentRow): AlertIncidentDocument => - new AlertIncidentDocument({ - id: asAlertIncidentId(row.id), - ruleId: asAlertRuleId(row.rule_id), +export const rowToAlertIncidentDocument = (row: AlertIncidentRow): Option.Option => + decodeIncident(row.id, { + id: row.id, + ruleId: row.rule_id, ruleName: row.rule_name, groupKey: row.group_key, - signalType: asSignalType(row.signal_type), - severity: asSeverity(row.severity), - peakSeverity: row.peak_severity == null ? null : asSeverity(row.peak_severity), - status: asIncidentStatus(row.status), - comparator: asComparator(row.comparator), + signalType: row.signal_type, + severity: row.severity, + peakSeverity: row.peak_severity ?? null, + status: row.status, + comparator: row.comparator, threshold: row.threshold, thresholdUpper: row.threshold_upper, - firstTriggeredAt: decodeIso(row.first_triggered_at), - lastTriggeredAt: decodeIso(row.last_triggered_at), - resolvedAt: row.resolved_at != null ? decodeIso(row.resolved_at) : null, + firstTriggeredAt: row.first_triggered_at, + lastTriggeredAt: row.last_triggered_at, + resolvedAt: row.resolved_at, lastObservedValue: row.last_observed_value, lastSampleCount: row.last_sample_count, dedupeKey: row.dedupe_key, - lastDeliveredEventType: - row.last_delivered_event_type != null ? asEventType(row.last_delivered_event_type) : null, - lastNotifiedAt: row.last_notified_at != null ? decodeIso(row.last_notified_at) : null, - holdReason: row.hold_reason != null ? asHoldReason(row.hold_reason) : null, - heldSince: row.held_since != null ? decodeIso(row.held_since) : null, - acknowledgedAt: row.acknowledged_at != null ? decodeIso(row.acknowledged_at) : null, + lastDeliveredEventType: row.last_delivered_event_type, + lastNotifiedAt: row.last_notified_at, + holdReason: row.hold_reason, + heldSince: row.held_since, + acknowledgedAt: row.acknowledged_at ?? null, acknowledgedByUserId: row.acknowledged_by_user_id != null ? Option.getOrNull(decodeUserId(row.acknowledged_by_user_id)) @@ -331,7 +323,7 @@ export const rowToAlertIncidentDocument = (row: AlertIncidentRow): AlertIncident acknowledgedByName: row.acknowledged_by_name ?? null, acknowledgedVia: row.acknowledged_via != null ? Option.getOrNull(decodeAckSource(row.acknowledged_via)) : null, - errorIssueId: row.error_issue_id != null ? asErrorIssueId(row.error_issue_id) : null, + errorIssueId: row.error_issue_id, ...(row.signal_type === "anomaly" ? { anomalyIncidentId: @@ -346,10 +338,15 @@ export const rowToAlertIncidentDocument = (row: AlertIncidentRow): AlertIncident onNone: () => undefined, onSome: (labels) => ({ labels }), }), - silenceId: row.silence_id == null ? null : asAlertSilenceId(row.silence_id), - silencedAt: row.silenced_at == null ? null : decodeIso(row.silenced_at), + silenceId: row.silence_id ?? null, + silencedAt: row.silenced_at ?? null, }) +/** The live incidents as documents, without rows this build cannot read. */ +export const rowsToAlertIncidentDocuments = ( + rows: ReadonlyArray, +): ReadonlyArray => Arr.getSomes(Arr.map(rows, rowToAlertIncidentDocument)) + // alert_destinations /** @@ -402,7 +399,7 @@ const decodeDestinationPublicConfig = Schema.decodeUnknownOption(AlertDestinatio export const rowToAlertDestinationDocument = ( row: AlertDestinationRow, ): Option.Option => - Option.map(decodeDestinationType(row.type), (type) => destinationDocument(row, type)) + Option.flatMap(decodeDestinationType(row.type), (type) => destinationDocument(row, type)) /** The live rows as documents, without rows of an unknown type. */ export const rowsToAlertDestinationDocuments = ( @@ -412,7 +409,7 @@ export const rowsToAlertDestinationDocuments = ( const destinationDocument = ( row: AlertDestinationRow, type: AlertDestinationType, -): AlertDestinationDocument => { +): Option.Option => { const publicConfig = Option.getOrElse( decodeDestinationPublicConfig(row.config_json), (): Schema.Schema.Type => ({ @@ -420,8 +417,8 @@ const destinationDocument = ( channelLabel: null, }), ) - return new AlertDestinationDocument({ - id: decodeDestinationId(row.id), + return decodeDestination(row.id, { + id: row.id, name: row.name, type, enabled: row.enabled, @@ -437,11 +434,11 @@ const destinationDocument = ( ...(publicConfig.linearTeamId === undefined ? undefined : { linearTeamId: publicConfig.linearTeamId }), - lastTestedAt: row.last_tested_at != null ? decodeIso(row.last_tested_at) : null, + lastTestedAt: row.last_tested_at, lastTestError: row.last_test_error, - disabledAt: row.disabled_at != null ? decodeIso(row.disabled_at) : null, + disabledAt: row.disabled_at, disabledReason: row.disabled_reason, - createdAt: decodeIso(row.created_at), - updatedAt: decodeIso(row.updated_at), + createdAt: row.created_at, + updatedAt: row.updated_at, }) } diff --git a/packages/web-data/src/collections/api-keys.test.ts b/packages/web-data/src/collections/api-keys.test.ts new file mode 100644 index 0000000000..2d0a57ec85 --- /dev/null +++ b/packages/web-data/src/collections/api-keys.test.ts @@ -0,0 +1,40 @@ +import { assert, describe, it } from "@effect/vitest" +import { Array as Arr, Option } from "effect" + +import { type ApiKeyRow, rowToV2ApiKey, rowsToV2ApiKeys } from "./api-keys" + +const keyRow: ApiKeyRow = { + id: "0b5f4c2e-3a1d-4e8f-9c7b-1a2b3c4d5e6f", + org_id: "org_1", + name: "ci-pipeline", + description: null, + key_prefix: "maple_ak_9f2c", + revoked: false, + revoked_at: null, + last_used_at: null, + expires_at: null, + scopes: ["dashboards:read"], + kind: "standard", + created_at: "2026-07-01T12:00:00.000Z", + created_by: "user_1", + created_by_email: "ci@acme.com", +} + +describe("rowToV2ApiKey", () => { + it("maps a synced row into the v2 model", () => { + const key = Option.getOrThrow(rowToV2ApiKey(keyRow)) + assert.strictEqual(key.id, keyRow.id) + assert.strictEqual(key.object, "api_key") + assert.strictEqual(key.kind, "standard") + assert.strictEqual(key.created_by, "user_1") + }) + + it("drops a key whose kind a newer server added, keeping the rest", () => { + const future = { ...keyRow, id: "1c6f4c2e-3a1d-4e8f-9c7b-1a2b3c4d5e6f", kind: "service_account" } + assert.isTrue(Option.isNone(rowToV2ApiKey(future))) + assert.deepStrictEqual( + Arr.map(rowsToV2ApiKeys([future, keyRow]), (key) => key.id), + [keyRow.id], + ) + }) +}) diff --git a/packages/web-data/src/collections/api-keys.ts b/packages/web-data/src/collections/api-keys.ts index d36c464306..8dabf03e1d 100644 --- a/packages/web-data/src/collections/api-keys.ts +++ b/packages/web-data/src/collections/api-keys.ts @@ -1,6 +1,6 @@ -import { ApiKeyId, ApiKeyKind, UserId } from "@maple/domain/http" -import type { V2ApiKey } from "@maple/domain/http/v2" -import { Schema } from "effect" +import { V2ApiKey } from "@maple/domain/http/v2" +import { Array as Arr, type Option, Schema } from "effect" +import { liveRowDecoder } from "./live-row" /** * Safe browser projection of `api_keys`. This mirrors the pinned column list in @@ -26,24 +26,31 @@ export const ApiKeyRowSchema = Schema.Struct({ export type ApiKeyRow = typeof ApiKeyRowSchema.Type -const asApiKeyId = Schema.decodeUnknownSync(ApiKeyId) -const asApiKeyKind = Schema.decodeUnknownSync(ApiKeyKind) -const asUserId = Schema.decodeUnknownSync(UserId) +// The type side: ids arrive raw, the `key_…` public id is the wire's encoding. +const decodeApiKey = liveRowDecoder("api_keys", Schema.toType(V2ApiKey)) -/** Maps the Electric row into the same decoded model returned by the v2 client. */ -export const rowToV2ApiKey = (row: ApiKeyRow): V2ApiKey => ({ - id: asApiKeyId(row.id), - object: "api_key", - name: row.name, - description: row.description, - key_prefix: row.key_prefix, - kind: asApiKeyKind(row.kind), - scopes: row.scopes, - revoked: row.revoked, - revoked_at: row.revoked_at, - last_used_at: row.last_used_at, - expires_at: row.expires_at, - created_at: row.created_at, - created_by: asUserId(row.created_by), - created_by_email: row.created_by_email, -}) +/** + * Maps the Electric row into the same decoded model returned by the v2 client; `None` for a + * row this build cannot read (e.g. a key kind added after it was built). + */ +export const rowToV2ApiKey = (row: ApiKeyRow): Option.Option => + decodeApiKey(row.id, { + id: row.id, + object: "api_key", + name: row.name, + description: row.description, + key_prefix: row.key_prefix, + kind: row.kind, + scopes: row.scopes, + revoked: row.revoked, + revoked_at: row.revoked_at, + last_used_at: row.last_used_at, + expires_at: row.expires_at, + created_at: row.created_at, + created_by: row.created_by, + created_by_email: row.created_by_email, + }) + +/** The live keys, without rows this build cannot read. */ +export const rowsToV2ApiKeys = (rows: ReadonlyArray): ReadonlyArray => + Arr.getSomes(Arr.map(rows, rowToV2ApiKey)) diff --git a/packages/web-data/src/collections/live-row.ts b/packages/web-data/src/collections/live-row.ts new file mode 100644 index 0000000000..67984a07a0 --- /dev/null +++ b/packages/web-data/src/collections/live-row.ts @@ -0,0 +1,69 @@ +import { Option, Result, Schema } from "effect" + +/** + * Live-row decoding that cannot throw. + * + * Mappers run inside live-query callbacks, outside any Effect, so a throw there takes the + * whole page down. A newer server can sync a value this build has never seen (a new + * signal type, a new kind), so a row that fails to decode is dropped and reported, never + * thrown. `maple/no-throwing-live-row-decode` keeps sync decodes out of this directory. + */ + +/** A schema's encoded fields, each still unchecked: what a mapper assembles from a synced row. */ +export type UncheckedFields = { + readonly [K in keyof S["Encoded"]]: unknown +} + +export interface UndecodableRow { + readonly collection: string + readonly rowId: string + readonly message: string +} + +type UndecodableRowListener = (row: UndecodableRow) => void + +const listeners = new Set() +// Live queries re-map every row on each change; report a bad row once, not on every tick. +const reported = new Set() + +/** Subscribe to dropped rows (the shell logs them and probes for a newer build). */ +export const onUndecodableRow = (listener: UndecodableRowListener): (() => void) => { + listeners.add(listener) + return () => { + listeners.delete(listener) + } +} + +const report = (row: UndecodableRow): void => { + const key = `${row.collection}:${row.rowId}` + // Unheard reports are not spent: a listener that subscribes later still hears the row. + if (listeners.size === 0 || reported.has(key)) return + reported.add(key) + for (const listener of listeners) listener(row) +} + +/** Decodes one value of a row; `None` (reported) drops the row it belongs to. */ +export const liveValueDecoder = >( + collection: string, + schema: S, +) => { + const decode = Schema.decodeUnknownResult(schema) + return (rowId: string, value: unknown): Option.Option => + Result.match(decode(value), { + onSuccess: Option.some, + onFailure: (error) => { + report({ collection, rowId, message: error.message }) + return Option.none() + }, + }) +} + +/** + * One decode per row, against the whole domain schema: `Some` when every field is readable, + * `None` (reported) when any is not. + */ +export const liveRowDecoder = >( + collection: string, + schema: S, +): ((rowId: string, fields: UncheckedFields) => Option.Option) => + liveValueDecoder(collection, schema) diff --git a/packages/web-data/src/lib/chunk-reload.test.ts b/packages/web-data/src/lib/chunk-reload.test.ts index 7d36227b52..92ceb7eb46 100644 --- a/packages/web-data/src/lib/chunk-reload.test.ts +++ b/packages/web-data/src/lib/chunk-reload.test.ts @@ -8,6 +8,17 @@ describe("isChunkLoadError", () => { ) }) + it("matches a removed chunk the server answers with index.html, and a failed CSS preload", () => { + expect( + isChunkLoadError( + 'Failed to load module script: Expected a JavaScript module script but the server responded with a MIME type of "text/html".', + ), + ).toBe(true) + expect(isChunkLoadError(new Error("Unable to preload CSS for /assets/replay-engines-3f2a.css"))).toBe( + true, + ) + }) + it("matches a stale route table on a superseded build (Chrome and Safari wording)", () => { expect( isChunkLoadError(new TypeError("Cannot read properties of undefined (reading 'component')")), diff --git a/packages/web-data/src/lib/chunk-reload.ts b/packages/web-data/src/lib/chunk-reload.ts index d19e26717c..e783f919f7 100644 --- a/packages/web-data/src/lib/chunk-reload.ts +++ b/packages/web-data/src/lib/chunk-reload.ts @@ -1,6 +1,3 @@ -const RELOAD_GUARD_KEY = "maple:chunk-reload-attempt" -const RELOAD_GUARD_WINDOW_MS = 60_000 - // The `.component` alternatives (Chrome and Safari wording) are TanStack Router // rendering a route whose module came from a build that has since been // replaced: the route table in the stale tab no longer lines up with the chunks @@ -8,7 +5,7 @@ const RELOAD_GUARD_WINDOW_MS = 60_000 // only on superseded `vcs.ref.head.revision`s, and a reload fixes it the same // way a failed chunk import does. const CHUNK_ERROR_PATTERN = - /Failed to fetch dynamically imported module|Importing a module script failed|error loading dynamically imported module|Cannot read properties of undefined \(reading 'component'\)|undefined is not an object \(evaluating '[^']*\.component'\)/i + /Failed to fetch dynamically imported module|Failed to load module script|Unable to preload CSS|Importing a module script failed|error loading dynamically imported module|Cannot read properties of undefined \(reading 'component'\)|undefined is not an object \(evaluating '[^']*\.component'\)/i export function isChunkLoadError(error: unknown): boolean { if (error instanceof Error) return CHUNK_ERROR_PATTERN.test(error.message) @@ -19,20 +16,3 @@ export function isChunkLoadError(error: unknown): boolean { } return false } - -export function shouldAttemptChunkReload(): boolean { - try { - const last = sessionStorage.getItem(RELOAD_GUARD_KEY) - if (last && Date.now() - Number(last) < RELOAD_GUARD_WINDOW_MS) return false - sessionStorage.setItem(RELOAD_GUARD_KEY, String(Date.now())) - return true - } catch { - return true - } -} - -export function clearChunkReloadGuard(): void { - try { - sessionStorage.removeItem(RELOAD_GUARD_KEY) - } catch {} -} diff --git a/scripts/oxlint-plugins/maple.mjs b/scripts/oxlint-plugins/maple.mjs index d7cd09feb4..7e1fab2932 100644 --- a/scripts/oxlint-plugins/maple.mjs +++ b/scripts/oxlint-plugins/maple.mjs @@ -470,6 +470,39 @@ const mcpTracerAnticipatedErrors = { }, } +const NO_THROWING_LIVE_ROW_DECODE_MESSAGE = + "Do not decode a synced row with a call that throws. Row mappers run inside live-query callbacks, outside any Effect, so one value this build has never seen (a signal type or kind a newer server added) takes the whole page down. Decode the row with `liveRowDecoder` / `liveValueDecoder` from `collections/live-row.ts` (returns `Option` and reports the dropped row), or `Schema.decodeUnknownOption` for a field that degrades to a default." + +const THROWING_DECODES = new Set(["decodeUnknownSync", "decodeSync", "decodeUnknownPromise", "decodePromise"]) + +/** + * `Schema.decode*Sync` / `Schema.decode*Promise`, and `new Document(...)` (a `Schema.Class` + * constructor validates and throws), in the Electric row mappers. + */ +const noThrowingLiveRowDecode = { + meta: { + type: "problem", + docs: { + description: "Disallow throwing decodes in live-row mappers.", + }, + messages: { noThrowingLiveRowDecode: NO_THROWING_LIVE_ROW_DECODE_MESSAGE }, + }, + create(context) { + return { + MemberExpression(node) { + if (node.computed) return + if (node.object.type !== "Identifier" || node.object.name !== "Schema") return + if (node.property.type !== "Identifier" || !THROWING_DECODES.has(node.property.name)) return + context.report({ node, messageId: "noThrowingLiveRowDecode" }) + }, + NewExpression(node) { + if (node.callee.type !== "Identifier" || !node.callee.name.endsWith("Document")) return + context.report({ node, messageId: "noThrowingLiveRowDecode" }) + }, + } + }, +} + import { designRules } from "./design-rules.mjs" import { uiKitRules } from "./ui-kit-rules.mjs" @@ -486,6 +519,7 @@ export default { "no-raw-tool-layer": noRawToolLayer, "no-react-use-effect": noReactUseEffect, "no-record-string-any": noRecordStringAny, + "no-throwing-live-row-decode": noThrowingLiveRowDecode, "no-try-catch": noTryCatch, }, } From 944a2fd5e517fdb87c2219d5f2c4c0018922d730 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 7 Nov 2026 12:39:40 +0100 Subject: [PATCH 2/7] fix(web): editing a rule this build cannot read stops with a reload instead of a blank create An unreadable rule fell through to the missing-rule path, so Save created a duplicate. The editor now has an Unreadable terminal state that offers a reload. Also covers the readable group_by path in the rule mapper tests. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/page/alerts/editor/draft.ts | 22 ++++++++++-------- .../page/alerts/editor/editor.scene.test.ts | 18 +++++++++++++++ apps/web/src/page/alerts/editor/model.ts | 7 ++++-- apps/web/src/page/alerts/editor/steps.ts | 3 ++- apps/web/src/page/alerts/editor/view.ts | 23 ++++++++++++++++++- .../web-data/src/collections/alerts.test.ts | 5 ++++ 6 files changed, 64 insertions(+), 14 deletions(-) diff --git a/apps/web/src/page/alerts/editor/draft.ts b/apps/web/src/page/alerts/editor/draft.ts index 4ddb296961..4c790872f7 100644 --- a/apps/web/src/page/alerts/editor/draft.ts +++ b/apps/web/src/page/alerts/editor/draft.ts @@ -15,6 +15,7 @@ import type { Model, Notice } from "./model" export type Initialization = | { readonly _tag: "Loading" } | { readonly _tag: "Failed" } + | { readonly _tag: "Unreadable" } | { readonly _tag: "Ready" readonly key: string @@ -57,17 +58,15 @@ const fromBase64Url = (input: string): Option.Option => Result.getSuccess(Base64Url.decodeString(input)) export const decodeChartParam = (raw: string) => Option.flatMap(fromBase64Url(raw), decodeChart) -/** The rule being edited, from the live rows. */ -export const editingRuleOf = (model: Model, ruleId: string): AlertRuleDocument | null => { - return Option.match( +/** The rule being edited, from the live rows: `None` when no row has its id. */ +export const editingRuleOf = ( + model: Model, + ruleId: string, +): Option.Option> => + Option.map( Arr.findFirst(model.rules ?? [], (candidate) => candidate.id === ruleId), - { - onNone: () => null, - onSome: (row) => - Option.getOrNull(rowToAlertRuleDocument(row, buildRuleStatesByRuleId(model.states ?? []))), - }, + (row) => rowToAlertRuleDocument(row, buildRuleStatesByRuleId(model.states ?? [])), ) -} export const initialization = (model: Model, draftId: string): Initialization => { const search = model.search @@ -75,7 +74,10 @@ export const initialization = (model: Model, draftId: string): Initialization => if (search.ruleId !== undefined) { // Terminal: an edit must not silently turn into a create, so the page stops here. if (model.rules === null) return model.isRulesLoadFailed ? { _tag: "Failed" } : { _tag: "Loading" } - const rule = editingRuleOf(model, search.ruleId) + const found = editingRuleOf(model, search.ruleId) + // Terminal too: a rule a newer build saved must not open as a blank create that duplicates it. + if (Option.exists(found, Option.isNone)) return { _tag: "Unreadable" } + const rule = Option.getOrNull(Option.flatten(found)) if (rule !== null) { const form = fromFormState(ruleToFormState(rule)) return ready( diff --git a/apps/web/src/page/alerts/editor/editor.scene.test.ts b/apps/web/src/page/alerts/editor/editor.scene.test.ts index fa4465ea81..65302fcf05 100644 --- a/apps/web/src/page/alerts/editor/editor.scene.test.ts +++ b/apps/web/src/page/alerts/editor/editor.scene.test.ts @@ -476,6 +476,24 @@ describe("alert rule editor", () => { expect(answered.editingRuleId).toBe(ruleId(2)) }) + test("editing a rule a newer build saved stops with a reload instead of a blank create", () => { + const model = run(entered({ ruleId: ruleId(2) }), [ + Message.UpdatedDestinations({ orgId: ORG, rows: [destinationRow()] }), + Message.UpdatedRules({ orgId: ORG, rows: [ruleRow(2, { signal_type: "a_future_signal" })] }), + ]) + expect(model.form).toBeNull() + expect(model.status).toBe("Unreadable") + scene( + { update: pageUpdate, view }, + given(model), + themeResolved(), + expectView(text("This rule needs a newer version of Maple")).toExist(), + click(role("button", { name: "Try again" })), + Command.expectHas(ReloadPage()), + Command.resolve(ReloadPage, Message.ReloadedPage()), + ) + }) + test("a widget link without its snapshot prefills from the dashboard, or says it could not load", () => { const search = { dashboardId: "dash-1", widgetId: "w-1" } expect(withLists(entered(search)).form).toBeNull() diff --git a/apps/web/src/page/alerts/editor/model.ts b/apps/web/src/page/alerts/editor/model.ts index b91eb07e6e..21047964dc 100644 --- a/apps/web/src/page/alerts/editor/model.ts +++ b/apps/web/src/page/alerts/editor/model.ts @@ -39,8 +39,11 @@ export const Notice = Schema.Struct({ }) export type Notice = typeof Notice.Type -/** `RuleInitialization`: the draft exists only once ready; `Failed` is the rules sync that never answered. */ -export const Status = Schema.Literals(["Loading", "Ready", "Failed"]) +/** + * `RuleInitialization`: the draft exists only once ready; `Failed` is the rules sync that never + * answered, `Unreadable` a rule this build cannot read (a newer build saved it). + */ +export const Status = Schema.Literals(["Loading", "Ready", "Failed", "Unreadable"]) export type Status = typeof Status.Type /** A dry run's verdict, tagged with the rule it was produced from (`previewIdentityKey`). */ diff --git a/apps/web/src/page/alerts/editor/steps.ts b/apps/web/src/page/alerts/editor/steps.ts index 2705a16793..b0ac879926 100644 --- a/apps/web/src/page/alerts/editor/steps.ts +++ b/apps/web/src/page/alerts/editor/steps.ts @@ -26,7 +26,8 @@ export const initialize = (model: Model, context: PageContext): Return => { if (model.form !== null || model.draftId === null) return { model } const draft = initialization(model, model.draftId) if (draft._tag === "Loading") return { model } - if (draft._tag === "Failed") return { model: { ...model, status: "Failed" } } + if (draft._tag === "Failed" || draft._tag === "Unreadable") + return { model: { ...model, status: draft._tag } } const form = draft.form const ready: Model = { ...model, diff --git a/apps/web/src/page/alerts/editor/view.ts b/apps/web/src/page/alerts/editor/view.ts index 421e039618..3bf1dc9c4f 100644 --- a/apps/web/src/page/alerts/editor/view.ts +++ b/apps/web/src/page/alerts/editor/view.ts @@ -66,6 +66,21 @@ const loadError = (h: H): Html => ], ) +/** A rule this build cannot read: editing it here would save over fields it does not know. */ +const unreadableRule = (h: H): Html => + h.div( + [h.Class(cn("mx-auto w-full", RULE_FORM_MAX_WIDTH))], + [ + errorState(h, { + failure: toLoadFailure(null), + title: "This rule needs a newer version of Maple", + message: "It was saved by a newer version than this tab runs. Reload to edit it.", + onRetry: Message.ClickedReloadPage(), + className: "py-12", + }), + ], + ) + /** `WidgetPrefillNoticeBanner`. */ const noticeBanner = (h: H, notices: ReadonlyArray): ReadonlyArray => notices.length === 0 @@ -220,7 +235,13 @@ export const view = Submodel.defineView((model, {}, form !== null ? surface(h, model, form, inputs.context.timeZone) - : [model.status === "Failed" ? loadError(h) : formSkeleton(h)], + : [ + model.status === "Failed" + ? loadError(h) + : model.status === "Unreadable" + ? unreadableRule(h) + : formSkeleton(h), + ], ), dialogSlot(destinationDialog, [h, model.destinationDialog]), ], diff --git a/packages/web-data/src/collections/alerts.test.ts b/packages/web-data/src/collections/alerts.test.ts index fa6fd73022..9e5294aac2 100644 --- a/packages/web-data/src/collections/alerts.test.ts +++ b/packages/web-data/src/collections/alerts.test.ts @@ -205,6 +205,11 @@ describe("rowToAlertRuleDocument", () => { ) }) + it("reads a stored group_by", () => { + const doc = rule({ ...ruleRow, group_by: '["service.name"]' }, new Map()) + assert.deepStrictEqual(doc.groupBy, ["service.name"]) + }) + it("keeps the readable rules when one is not", () => { const future = { ...ruleRow, id: "future-rule", signal_type: "a_future_signal" } const docs = rowsToAlertRuleDocuments([future, ruleRow], new Map()) From 8cf0e531fce163f0f4f3cd29ab60edef57e991b5 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 7 Nov 2026 12:49:37 +0100 Subject: [PATCH 3/7] fix(web): reload onto a new build only while the tab is hidden A visible tab may hold unsaved edits (the rule editor keeps them in page state), so any stale-build signal there raises the new-build banner at once instead of reloading; a hidden tab still reloads once per deployed commit. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/shell/version.test.ts | 20 ++++++++++---------- apps/web/src/shell/version.ts | 9 ++++----- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/apps/web/src/shell/version.test.ts b/apps/web/src/shell/version.test.ts index de0ca14258..911179b391 100644 --- a/apps/web/src/shell/version.test.ts +++ b/apps/web/src/shell/version.test.ts @@ -37,8 +37,8 @@ describe("probeStaleBuild", () => { const reload = Effect.sync(() => { reloads += 1 }) - const first = yield* probeStaleBuild(signal, storage, visible, reload) - const second = yield* probeStaleBuild(signal, storage, visible, reload) + const first = yield* probeStaleBuild(signal, storage, hidden, reload) + const second = yield* probeStaleBuild(signal, storage, hidden, reload) expect(reloads).toBe(1) // Both surface the newer commit so the banner shows if the reload did not land. expect(Option.map(first, (message) => message.maybeCommit)).toEqual( @@ -55,8 +55,8 @@ describe("probeStaleBuild", () => { const reload = Effect.sync(() => { reloads += 1 }) - yield* probeStaleBuild(signal, storage, visible, reload).pipe(Effect.provide(serving("build-a"))) - yield* probeStaleBuild(signal, storage, visible, reload).pipe(Effect.provide(serving("build-b"))) + yield* probeStaleBuild(signal, storage, hidden, reload).pipe(Effect.provide(serving("build-a"))) + yield* probeStaleBuild(signal, storage, hidden, reload).pipe(Effect.provide(serving("build-b"))) expect(reloads).toBe(2) }), ) @@ -108,7 +108,7 @@ describe("probeStaleBuild", () => { yield* probeStaleBuild( signal, throwing, - visible, + hidden, Effect.sync(() => { reloads += 1 }), @@ -116,18 +116,18 @@ describe("probeStaleBuild", () => { expect(reloads).toBe(0) }).pipe(Effect.provide(serving("next-build"))), ) - it.effect("an unreadable row raises the banner on a visible tab but does not reload it", () => + it.effect("a visible tab gets the banner, never a reload that could drop unsaved edits", () => Effect.gen(function* () { const row: StaleSignal = { trigger: "undecodable_row", detail: "alert_incidents x" } let reloads = 0 const reload = Effect.sync(() => { reloads += 1 }) - const shown = yield* probeStaleBuild(row, memoryStorage(), visible, reload) + const shown = yield* Effect.forEach([signal, row], (trigger) => + probeStaleBuild(trigger, memoryStorage(), visible, reload), + ) + expect(shown.every(Option.isSome)).toBe(true) expect(reloads).toBe(0) - expect(Option.isSome(shown)).toBe(true) - yield* probeStaleBuild(row, memoryStorage(), hidden, reload) - expect(reloads).toBe(1) }).pipe(Effect.provide(serving("next-build"))), ) }) diff --git a/apps/web/src/shell/version.ts b/apps/web/src/shell/version.ts index 7bb29abbb6..92db0a8254 100644 --- a/apps/web/src/shell/version.ts +++ b/apps/web/src/shell/version.ts @@ -89,9 +89,9 @@ export const claimReload = (storage: Pick, commi /** * `None` when the server runs this build (the failure is something else). Otherwise the - * `CheckedDeployedCommit` raises the banner, after one reload when it is safe: a missing chunk - * has already broken the page, but an unreadable row has not, so it reloads only a hidden tab - * and never throws away a visible tab's unsaved edits. + * `CheckedDeployedCommit` raises the banner at once. Only a hidden tab also reloads by itself: + * a visible one may hold unsaved edits (the rule editor keeps them in page state), so its + * user reloads from the banner. */ export const probeStaleBuild = ( signal: StaleSignal, @@ -107,9 +107,8 @@ export const probeStaleBuild = ( const isNewer = Option.exists(deployed, (commit) => commit !== BUILT_COMMIT) yield* Effect.annotateCurrentSpan({ "maple.stale_build.newer": isNewer }) if (!isNewer) return Option.none() - const mayReload = signal.trigger === "chunk_load" || (yield* isTabHidden) const isReloading = - mayReload && Option.isSome(deployed) && (yield* claimReload(storage, deployed.value)) + (yield* isTabHidden) && Option.isSome(deployed) && (yield* claimReload(storage, deployed.value)) yield* Effect.annotateCurrentSpan({ "maple.stale_build.reloading": isReloading }) if (isReloading) yield* reload return Option.some(Message.CheckedDeployedCommit({ maybeCommit: deployed })) From 98f5a856f50f828ba08b33c346a2eb9e4489e325 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 7 Nov 2026 12:55:36 +0100 Subject: [PATCH 4/7] fix(web): stop the rule editor when the open rule turns unreadable A newer build rewriting the rule while its form is open left a stale form whose save wrote the old values back. Row updates now re-check the rule and move the editor to its Unreadable state. Co-Authored-By: Claude Opus 5.5 --- .../src/page/alerts/editor/editor.scene.test.ts | 10 ++++++++++ apps/web/src/page/alerts/editor/steps.ts | 17 +++++++++++++---- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/apps/web/src/page/alerts/editor/editor.scene.test.ts b/apps/web/src/page/alerts/editor/editor.scene.test.ts index 65302fcf05..5a28d691c1 100644 --- a/apps/web/src/page/alerts/editor/editor.scene.test.ts +++ b/apps/web/src/page/alerts/editor/editor.scene.test.ts @@ -494,6 +494,16 @@ describe("alert rule editor", () => { ) }) + test("a rule a newer build rewrites while it is open stops the editor instead of saving over it", () => { + const open = withLists(entered({ ruleId: ruleId(2) })) + expect(open.form).not.toBeNull() + const rewritten = run(open, [ + Message.UpdatedRules({ orgId: ORG, rows: [ruleRow(2, { signal_type: "a_future_signal" })] }), + ]) + expect(rewritten.form).toBeNull() + expect(rewritten.status).toBe("Unreadable") + }) + test("a widget link without its snapshot prefills from the dashboard, or says it could not load", () => { const search = { dashboardId: "dash-1", widgetId: "w-1" } expect(withLists(entered(search)).form).toBeNull() diff --git a/apps/web/src/page/alerts/editor/steps.ts b/apps/web/src/page/alerts/editor/steps.ts index b0ac879926..939a9a85b4 100644 --- a/apps/web/src/page/alerts/editor/steps.ts +++ b/apps/web/src/page/alerts/editor/steps.ts @@ -1,12 +1,12 @@ import { Command } from "foldkit" import * as Dialog from "@maple/views/ui/dialog" import type { PageContext, PageReturn } from "../../contract" -import { initialization } from "./draft" +import { editingRuleOf, initialization } from "./draft" import type { RuleForm } from "./form" import { Message } from "./message" import type { Model } from "./model" import { applyDefaultDestination, planPreviews } from "./plan" -import { String as Str } from "effect" +import { Option, String as Str } from "effect" export type Return = PageReturn @@ -53,5 +53,14 @@ export const editForm = (model: Model, context: PageContext, edit: (form: RuleFo model.form === null ? { model } : planned({ ...model, form: edit(model.form) }, context) /** A list answered: the draft may now be known, and the default destination picked. */ -export const followRows = (model: Model, context: PageContext): Return => - model.form === null ? initialize(model, context) : { model: applyDefaultDestination(model) } +export const followRows = (model: Model, context: PageContext): Return => { + if (model.form === null) return initialize(model, context) + // A newer build rewrote the open rule into something this one cannot read: saving the stale + // form would write the old values back over it, so the editor stops as on open. + if ( + model.editingRuleId !== null && + Option.exists(editingRuleOf(model, model.editingRuleId), Option.isNone) + ) + return { model: { ...model, form: null, status: "Unreadable" } } + return { model: applyDefaultDestination(model) } +} From 172a17ea62e89dcf77b40b9345d1cbd95e18edea Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 7 Nov 2026 13:00:20 +0100 Subject: [PATCH 5/7] fix(web): no automatic reload over unsaved work; blocked storage falls back to the banner The stale-build reload now also requires that the page holds no unsaved work (the app's isBlockingUnload contract), and sessionStorage is only touched inside the reload guard, so a SecurityError from blocked site data can no longer end the recovery stream. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/shell/version.ts | 44 +++++++++++++++++++++++++---------- apps/web/src/subscription.ts | 13 +++++++---- 2 files changed, 41 insertions(+), 16 deletions(-) diff --git a/apps/web/src/shell/version.ts b/apps/web/src/shell/version.ts index 92db0a8254..30479f7f2e 100644 --- a/apps/web/src/shell/version.ts +++ b/apps/web/src/shell/version.ts @@ -89,14 +89,13 @@ export const claimReload = (storage: Pick, commi /** * `None` when the server runs this build (the failure is something else). Otherwise the - * `CheckedDeployedCommit` raises the banner at once. Only a hidden tab also reloads by itself: - * a visible one may hold unsaved edits (the rule editor keeps them in page state), so its - * user reloads from the banner. + * `CheckedDeployedCommit` raises the banner at once, and the tab reloads by itself only when + * `mayReloadUnasked` says nobody can lose work: anywhere else the user reloads from the banner. */ export const probeStaleBuild = ( signal: StaleSignal, storage: Pick, - isTabHidden: Effect.Effect, + mayReloadUnasked: Effect.Effect, reload: Effect.Effect, ) => Effect.gen(function* () { @@ -108,7 +107,9 @@ export const probeStaleBuild = ( yield* Effect.annotateCurrentSpan({ "maple.stale_build.newer": isNewer }) if (!isNewer) return Option.none() const isReloading = - (yield* isTabHidden) && Option.isSome(deployed) && (yield* claimReload(storage, deployed.value)) + (yield* mayReloadUnasked) && + Option.isSome(deployed) && + (yield* claimReload(storage, deployed.value)) yield* Effect.annotateCurrentSpan({ "maple.stale_build.reloading": isReloading }) if (isReloading) yield* reload return Option.some(Message.CheckedDeployedCommit({ maybeCommit: deployed })) @@ -159,11 +160,30 @@ const staleSignals: Stream.Stream = Stream.mergeAll( ) const reloadPage = Effect.sync(() => window.location.reload()) -const isDocumentHidden = Effect.sync(() => document.hidden) -/** Probes on a stale-build signal; a burst (many bad rows at once) probes once. */ -export const staleBuildRecovery: Stream.Stream = staleSignals.pipe( - Stream.debounce(Duration.seconds(1)), - Stream.mapEffect((signal) => probeStaleBuild(signal, sessionStorage, isDocumentHidden, reloadPage)), - Stream.filterMap((message) => Result.fromOption(message, () => undefined)), -) +// Reading the `sessionStorage` global itself throws when site data is blocked: touch it only +// inside `claimReload`'s guard. +const sessionGuard: Pick = { + getItem: (key) => sessionStorage.getItem(key), + setItem: (key, value) => sessionStorage.setItem(key, value), +} + +/** + * Probes on a stale-build signal; a burst (many bad rows at once) probes once. Reloads by + * itself only a hidden tab whose page holds no unsaved work (`isBlockingUnload`). + */ +export const staleBuildRecovery = ( + isBlockingUnload: boolean, +): Stream.Stream => + staleSignals.pipe( + Stream.debounce(Duration.seconds(1)), + Stream.mapEffect((signal) => + probeStaleBuild( + signal, + sessionGuard, + Effect.sync(() => document.hidden && !isBlockingUnload), + reloadPage, + ), + ), + Stream.filterMap((message) => Result.fromOption(message, () => undefined)), + ) diff --git a/apps/web/src/subscription.ts b/apps/web/src/subscription.ts index 2191a3edce..628d8a73d8 100644 --- a/apps/web/src/subscription.ts +++ b/apps/web/src/subscription.ts @@ -243,12 +243,17 @@ const root = Subscription.make()((entry) => ({ ), /** A removed chunk or an unreadable synced row probes for a newer build and reloads onto it. */ shellStaleBuild: entry( - { isWatching: Schema.Boolean }, + { isWatching: Schema.Boolean, isBlocking: Schema.Boolean }, { - modelToDependencies: () => ({ isWatching: Shell.BUILT_COMMIT.length > 0 }), - dependenciesToStream: ({ isWatching }) => + modelToDependencies: (model) => ({ + isWatching: Shell.BUILT_COMMIT.length > 0, + isBlocking: isBlockingUnload(model.page), + }), + dependenciesToStream: ({ isWatching, isBlocking }) => Stream.when( - Stream.map(staleBuildRecovery, (message) => Message.GotShellMessage({ message })), + Stream.map(staleBuildRecovery(isBlocking), (message) => + Message.GotShellMessage({ message }), + ), Effect.sync(() => isWatching), ), }, From 4f63a97969bccb8f9052471998340b4322a30d85 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 7 Nov 2026 13:12:23 +0100 Subject: [PATCH 6/7] test(web): use effect Array.every in the stale-build test Co-Authored-By: Claude Opus 5.5 --- apps/web/src/shell/version.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/shell/version.test.ts b/apps/web/src/shell/version.test.ts index 911179b391..a8cd9f34bf 100644 --- a/apps/web/src/shell/version.test.ts +++ b/apps/web/src/shell/version.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest" -import { Effect, Layer, Option } from "effect" +import { Array as Arr, Effect, Layer, Option } from "effect" import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http" import { BUILT_COMMIT, probeStaleBuild, type StaleSignal } from "./version" @@ -126,7 +126,7 @@ describe("probeStaleBuild", () => { const shown = yield* Effect.forEach([signal, row], (trigger) => probeStaleBuild(trigger, memoryStorage(), visible, reload), ) - expect(shown.every(Option.isSome)).toBe(true) + expect(Arr.every(shown, Option.isSome)).toBe(true) expect(reloads).toBe(0) }).pipe(Effect.provide(serving("next-build"))), ) From 72078bd97c4b3f688291d3f7c7b6784a30f4567f Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 7 Nov 2026 13:16:34 +0100 Subject: [PATCH 7/7] test(web): the same-build probe test compares a real commit BUILT_COMMIT is empty under Vitest, so the test returned before the comparison. The probe now takes the built commit (defaulting to BUILT_COMMIT). Co-Authored-By: Claude Opus 5.5 --- apps/web/src/shell/version.test.ts | 7 ++++--- apps/web/src/shell/version.ts | 3 ++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/web/src/shell/version.test.ts b/apps/web/src/shell/version.test.ts index a8cd9f34bf..53fe0b8fbb 100644 --- a/apps/web/src/shell/version.test.ts +++ b/apps/web/src/shell/version.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "@effect/vitest" import { Array as Arr, Effect, Layer, Option } from "effect" import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http" -import { BUILT_COMMIT, probeStaleBuild, type StaleSignal } from "./version" +import { probeStaleBuild, type StaleSignal } from "./version" const memoryStorage = () => { const values = new Map() @@ -67,14 +67,15 @@ describe("probeStaleBuild", () => { const result = yield* probeStaleBuild( signal, memoryStorage(), - visible, + hidden, Effect.sync(() => { reloads += 1 }), + "this-build", ) expect(reloads).toBe(0) expect(Option.isNone(result)).toBe(true) - }).pipe(Effect.provide(serving(BUILT_COMMIT))), + }).pipe(Effect.provide(serving("this-build"))), ) it.effect("does not reload when the deployed commit cannot be read", () => diff --git a/apps/web/src/shell/version.ts b/apps/web/src/shell/version.ts index 30479f7f2e..d00ef0d35f 100644 --- a/apps/web/src/shell/version.ts +++ b/apps/web/src/shell/version.ts @@ -97,13 +97,14 @@ export const probeStaleBuild = ( storage: Pick, mayReloadUnasked: Effect.Effect, reload: Effect.Effect, + builtCommit: string = BUILT_COMMIT, ) => Effect.gen(function* () { yield* Effect.logWarning("Stale-build signal").pipe( Effect.annotateLogs({ trigger: signal.trigger, detail: signal.detail }), ) const deployed = yield* deployedCommit - const isNewer = Option.exists(deployed, (commit) => commit !== BUILT_COMMIT) + const isNewer = Option.exists(deployed, (commit) => commit !== builtCommit) yield* Effect.annotateCurrentSpan({ "maple.stale_build.newer": isNewer }) if (!isNewer) return Option.none() const isReloading =